diff --git a/README.md b/README.md
index dfddf85..fe9197b 100644
--- a/README.md
+++ b/README.md
@@ -163,44 +163,48 @@ 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
-| 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
-| 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/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..eac2c69
--- /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 "$(dirname "${BASH_SOURCE[0]}")"
+
+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..2b05547
--- /dev/null
+++ b/examples/llm_chat_app/2_create_test_objects.sh
@@ -0,0 +1,58 @@
+#!/bin/bash
+# Create test objects for verification
+
+set -e
+
+export PGHOST=localhost
+export PGPORT=5432
+export PGDATABASE=llm_chat_app
+
+echo "=============================================="
+echo "Creating Test Objects"
+echo "=============================================="
+echo ""
+
+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,
+ 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..17ad115
--- /dev/null
+++ b/examples/llm_chat_app/3_run_verification_tests.sh
@@ -0,0 +1,213 @@
+#!/bin/bash
+# Run all verification tests
+
+set -e
+
+export PGHOST=localhost
+export PGPORT=5432
+export PGDATABASE=llm_chat_app
+
+echo "=============================================="
+echo "Running Verification Tests"
+echo "=============================================="
+echo ""
+
+# 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 "
+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 1 PASSED: service_migrator inherits DDL access (including TRUNCATE) from role_service_migration' AS result;
+"
+echo ""
+
+# 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');
+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 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 2b PASSED: FastAPI RW correctly denied TRUNCATE"
+else
+ echo "TEST 2b FAILED: FastAPI RW should not have TRUNCATE permission"
+ exit 1
+fi
+echo ""
+
+# 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 3 PASSED: FastAPI RO has SELECT' AS result;
+"
+echo ""
+
+# 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;
+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 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 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 ---"
+
+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 = '${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 - all login and group roles
+echo "--- Test 8: Role Inheritance ---"
+
+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 = '${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 "=============================================="
+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..890a143
--- /dev/null
+++ b/examples/llm_chat_app/4_cleanup.sh
@@ -0,0 +1,60 @@
+#!/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_chat_app database..."
+psql -c "
+SELECT pg_terminate_backend(pid)
+FROM pg_stat_activity
+WHERE datname = 'llm_chat_app' AND pid <> pg_backend_pid();
+" 2>/dev/null || true
+
+echo ""
+echo "Step 2: Destroying Terraform-managed resources..."
+cd "$(dirname "${BASH_SOURCE[0]}")"
+tofu destroy -auto-approve
+
+echo ""
+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;
+DROP ROLE IF EXISTS role_pg_cluster_admin;
+DROP ROLE IF EXISTS role_pg_monitoring;
+EOF
+
+echo ""
+echo "Step 4: Verifying cleanup..."
+psql -c "
+SELECT rolname FROM pg_roles
+WHERE rolname LIKE 'role_service_%' OR rolname LIKE 'role_pg_%' OR rolname LIKE 'service_%'
+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..2df23b3
--- /dev/null
+++ b/examples/llm_chat_app/README.md
@@ -0,0 +1,224 @@
+# Example: LLM Chat App Setup
+
+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
+
+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';"
+ psql postgres -c "GRANT pg_monitor TO admin_user WITH ADMIN OPTION;"
+ ```
+
+## Usage
+
+```bash
+cd examples/llm_chat_app
+
+# Initialize Terraform
+tofu init
+
+# Preview the changes
+tofu plan
+
+# Apply the configuration
+tofu apply
+```
+
+## Verify the Setup
+
+After `tofu apply` succeeds, run these tests to verify the RBAC configuration.
+
+### Quick smoke test
+
+```bash
+export PGHOST=localhost
+export PGPORT=5432
+export PGDATABASE=llm_chat_app
+
+# 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;"
+```
+
+### Full test suite
+
+```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
+```
+
+## Roles and Permissions
+
+Group roles (no login) use the `role_` prefix. Login roles do not.
+
+| Role | Type | Purpose | Login | Connection Limit |
+| ------------------------ | ----- | ------------------------------------------------------- | ----- | ---------------- |
+| `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_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.
+
+## Schema Access Matrix
+
+| | 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
+
+## Role Hierarchy Diagram
+
+```mermaid
+flowchart TB
+ subgraph cluster["Cluster-Wide Roles"]
+ role_monitoring["role_pg_monitoring
no login • pg_monitor member"]
+ pg_monitoring["pg_monitoring
login • inherits monitoring"]
+
+ pg_monitoring -->|inherits| role_monitoring
+ end
+
+ 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"]
+ 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
+ end
+
+ migrator -->|inherits| migration
+ fastapi_rw -->|inherits| rw
+ fastapi_ro -->|inherits| ro
+ pipeline_rw -->|inherits| rw
+ pipeline_ro -->|inherits| ro
+
+ classDef clusterRole fill:#e1f5fe,stroke:#01579b
+ classDef groupRole fill:#fff3e0,stroke:#e65100
+ classDef loginRole fill:#e8f5e9,stroke:#2e7d32
+ classDef migrationGroup fill:#fce4ec,stroke:#c2185b
+
+ 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
+```
+
+## Schema Access Diagram
+
+```mermaid
+flowchart TB
+ subgraph ddl_access["DDL Access (Schema Owner)"]
+ migration["service_migrator"]
+ end
+
+ subgraph app_only["App Schema Only"]
+ fastapi_rw["service_fastapi_rw"]
+ fastapi_ro["service_fastapi_ro"]
+ end
+
+ subgraph all_schemas["All Schemas Access"]
+ pipeline_rw["service_pipeline_rw"]
+ pipeline_ro["service_pipeline_ro"]
+ end
+
+ subgraph schemas["Schemas"]
+ direction LR
+ app["app"]
+ ref_abc["ref_data_pipeline_abc"]
+ ref_xyz["ref_data_pipeline_xyz"]
+ end
+
+ migration -->|DDL| app
+ migration -->|DDL| ref_abc
+ migration -->|DDL| ref_xyz
+
+ fastapi_rw -->|RW| app
+ fastapi_ro -->|RO| app
+
+ 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
+
+ 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
+
+```bash
+./4_cleanup.sh
+```
+
+This drops test objects and runs `tofu 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..29542e3
--- /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="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+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..ef8ec77
--- /dev/null
+++ b/examples/llm_chat_app/TEST_INSTRUCTIONS.md
@@ -0,0 +1,133 @@
+# 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 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_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
+
+### 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 |
+| --------------------- | --------------------------- | ------------------------- |
+| `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
+
+Example:
+
+```bash
+export PGHOST=localhost
+export PGPORT=5432
+export PGDATABASE=llm_chat_app
+
+PGUSER=service_fastapi_rw PGPASSWORD=demo-password-fastapi-rw psql -c "SELECT * FROM app.test_users;"
+```
+
+## Cleanup
+
+To destroy all resources:
+
+```bash
+cd 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 using `4_cleanup.sh` script, or run individual DROP statements
diff --git a/examples/llm_chat_app/fixtures.auto.tfvars b/examples/llm_chat_app/fixtures.auto.tfvars
new file mode 100644
index 0000000..b802a89
--- /dev/null
+++ b/examples/llm_chat_app/fixtures.auto.tfvars
@@ -0,0 +1,251 @@
+# 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_chat_app"
+ connection_limit = 100
+ }
+]
+
+# Role configuration
+roles = [
+
+ # ========================================
+ # Cluster-wide roles
+ # ========================================
+ # 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 = {
+ 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"
+ }
+ },
+
+ # ========================================
+ # Migration group role - owns all schemas and DDL
+ # ========================================
+
+ {
+ role = {
+ name = "role_service_migration"
+ login = false
+ inherit = true
+ }
+ database_grants = {
+ role = "role_service_migration"
+ database = "llm_chat_app"
+ object_type = "database"
+ privileges = ["CREATE", "CONNECT", "TEMPORARY"]
+ }
+ schema_grants = [
+ { 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_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_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"] },
+ ]
+ },
+
+ # 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"
+ }
+ },
+
+ # ========================================
+ # RW group role - read/write on app schema
+ # ========================================
+
+ {
+ role = {
+ name = "role_service_rw"
+ login = false
+ inherit = true
+ }
+ database_grants = {
+ role = "role_service_rw"
+ database = "llm_chat_app"
+ object_type = "database"
+ privileges = ["CONNECT"]
+ }
+ schema_grants = [
+ { role = "role_service_rw", database = "llm_chat_app", schema = "app", object_type = "schema", privileges = ["USAGE"] },
+ ]
+ table_grants = [
+ { 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_chat_app", schema = "app", object_type = "sequence", privileges = ["USAGE", "SELECT", "UPDATE"] },
+ ]
+ default_privileges = [
+ { 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"] },
+ ]
+ },
+
+ # ========================================
+ # RO group role - read-only on app schema
+ # ========================================
+
+ {
+ role = {
+ name = "role_service_ro"
+ login = false
+ inherit = true
+ }
+ database_grants = {
+ role = "role_service_ro"
+ database = "llm_chat_app"
+ object_type = "database"
+ privileges = ["CONNECT"]
+ }
+ schema_grants = [
+ { role = "role_service_ro", database = "llm_chat_app", schema = "app", object_type = "schema", privileges = ["USAGE"] },
+ ]
+ table_grants = [
+ { role = "role_service_ro", database = "llm_chat_app", schema = "app", object_type = "table", privileges = ["SELECT"] },
+ ]
+ sequence_grants = [
+ { role = "role_service_ro", database = "llm_chat_app", schema = "app", object_type = "sequence", privileges = ["USAGE", "SELECT"] },
+ ]
+ default_privileges = [
+ { 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"] },
+ ]
+ },
+
+ # ========================================
+ # Login roles - app schema (inherit from rw/ro)
+ # ========================================
+
+ {
+ role = {
+ name = "service_fastapi_rw"
+ login = true
+ inherit = true
+ roles = ["role_service_rw"]
+ connection_limit = 30
+ password = "demo-password-fastapi-rw"
+ }
+ },
+
+ {
+ role = {
+ name = "service_fastapi_ro"
+ login = true
+ inherit = true
+ roles = ["role_service_ro"]
+ connection_limit = 30
+ password = "demo-password-fastapi-ro"
+ }
+ },
+
+ # ========================================
+ # Pipeline login roles - ref_data schemas + inherited app access
+ # ========================================
+
+ {
+ role = {
+ name = "service_pipeline_rw"
+ login = true
+ inherit = true
+ roles = ["role_service_rw"]
+ connection_limit = 10
+ password = "demo-password-pipeline-rw"
+ }
+ schema_grants = [
+ { 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_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_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_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"] },
+ ]
+ },
+
+ {
+ role = {
+ name = "service_pipeline_ro"
+ login = true
+ inherit = true
+ roles = ["role_service_ro"]
+ connection_limit = 10
+ password = "demo-password-pipeline-ro"
+ }
+ schema_grants = [
+ { 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_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_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_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
new file mode 100644
index 0000000..5a93afb
--- /dev/null
+++ b/examples/llm_chat_app/main.tf
@@ -0,0 +1,79 @@
+# llm_chat_app/main.tf
+
+# ========================================
+# Database (managed here so schemas can depend on it before the module runs)
+# ========================================
+
+resource "postgresql_database" "databases" {
+ for_each = { for db in var.databases : db.name => db }
+ name = each.value.name
+ connection_limit = each.value.connection_limit
+}
+
+# ========================================
+# Schemas (created after database, before module)
+# ========================================
+
+resource "postgresql_schema" "app" {
+ name = "app"
+ database = "llm_chat_app"
+
+ depends_on = [postgresql_database.databases]
+}
+
+resource "postgresql_schema" "ref_data_pipeline_abc" {
+ name = "ref_data_pipeline_abc"
+ database = "llm_chat_app"
+
+ depends_on = [postgresql_database.databases]
+}
+
+resource "postgresql_schema" "ref_data_pipeline_xyz" {
+ name = "ref_data_pipeline_xyz"
+ database = "llm_chat_app"
+
+ depends_on = [postgresql_database.databases]
+}
+
+# ========================================
+# 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
+# ========================================
+
+module "postgres_automation" {
+ source = "../../"
+
+ databases = []
+ roles = var.roles
+
+ depends_on = [
+ postgresql_database.databases,
+ postgresql_schema.app,
+ postgresql_schema.ref_data_pipeline_abc,
+ postgresql_schema.ref_data_pipeline_xyz,
+ ]
+}
+
+# ========================================
+# Security: Revoke PUBLIC privileges
+# ========================================
+
+resource "postgresql_grant" "revoke_public_connect" {
+ database = "llm_chat_app"
+ role = "public"
+ object_type = "database"
+ privileges = []
+
+ depends_on = [module.postgres_automation]
+}
+
+resource "postgresql_grant" "revoke_public_schema" {
+ database = "llm_chat_app"
+ role = "public"
+ schema = "public"
+ object_type = "schema"
+ privileges = []
+
+ depends_on = [module.postgres_automation]
+}
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..8663058
--- /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 "$(dirname "${BASH_SOURCE[0]}")"
+
+# 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_chat_app
+
+echo "--- Prerequisites: Creating Test Objects ---"
+echo ""
+
+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,
+ 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=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;
+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=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';
+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=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;
+"
+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_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;
+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=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;
+SELECT 'TEST 6 PASSED: Pipeline RO has SELECT on all schemas' AS result;
+"
+echo ""
+
+# Test 7: Connection Limits
+echo "--- Test 7: Connection Limits ---"
+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_%'
+ORDER BY rolname;
+"
+echo ""
+
+# Test 8: Role Inheritance
+echo "--- Test 8: Role Inheritance ---"
+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 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..d01d471
--- /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(list(object({
+ role = string
+ database = string
+ schema = string
+ object_type = 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)
+ })))
+ sequence_grants = optional(list(object({
+ role = string
+ database = string
+ schema = string
+ object_type = 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/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"
+ }
+ }
+}
diff --git a/main.tf b/main.tf
index 6b937db..87f3c71 100644
--- a/main.tf
+++ b/main.tf
@@ -19,18 +19,43 @@ 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 }
- 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 +76,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 +102,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 +141,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 +152,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 +164,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" {
@@ -125,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" {
@@ -138,7 +188,8 @@ 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.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..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"
@@ -186,9 +185,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 +199,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"
}
}
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"