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"