diff --git a/terraform/README.md b/terraform/README.md index 0d9a02f..e72c7ce 100644 --- a/terraform/README.md +++ b/terraform/README.md @@ -32,6 +32,8 @@ | Name | Type | |------|------| | [aws_cloudwatch_log_group.database](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | +| [aws_cognito_user_pool.shared](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cognito_user_pool) | resource | +| [aws_cognito_user_pool_domain.shared](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cognito_user_pool_domain) | resource | | [aws_db_instance.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/db_instance) | resource | | [aws_db_parameter_group.postgres15](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/db_parameter_group) | resource | | [aws_db_subnet_group.incubator_prod](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/db_subnet_group) | resource | diff --git a/terraform/cognito.tf b/terraform/cognito.tf new file mode 100644 index 0000000..e9774d4 --- /dev/null +++ b/terraform/cognito.tf @@ -0,0 +1,83 @@ +# The shared Cognito user pool, for Hack for LA apps whose users are HfLA volunteers +# rather than each app's own external users. Projects get access to it through +# modules/shared-user-pool-access, which gives each one its own app clients and, +# optionally, admin rights for its task role. See hackforla/incubator#17. +# +# Adopted in place from the pool that was created by hand in 2022 as `vrms-dev`. Despite +# the name it is not specific to VRMS: three of its four app clients belong to +# people-depot, which is also the only project whose running container points at it. +# The name stays because renaming a Cognito user pool replaces it, which would destroy +# its user accounts. +# +# Deliberately carries no `project` tag. It belongs to no single project, and the +# container module grants Cognito admin rights on pools tagged with a project's name, so +# a tag here would hand that project admin rights over every other project's users. +# Access is granted explicitly by modules/shared-user-pool-access instead. +# +# Every value below is written to match live AWS, so that the plan after the imports in +# import.tf reports no changes apart from the provider's default tags. +resource "aws_cognito_user_pool" "shared" { + name = "vrms-dev" + + // The pool predates Cognito's tier feature and is on LITE. The provider defaults this + // attribute to ESSENTIALS, so omitting it would plan a billing upgrade. + user_pool_tier = "LITE" + + mfa_configuration = "OFF" + username_attributes = ["email"] + auto_verified_attributes = ["email"] + deletion_protection = "INACTIVE" + + account_recovery_setting { + recovery_mechanism { + name = "verified_email" + priority = 1 + } + } + + // Cognito's default wording, but it is stored on the pool and the provider does not + // fill it in when the attribute is omitted, so leaving these out plans to clear them. + sms_authentication_message = "Your authentication code is {####}. " + + admin_create_user_config { + allow_admin_create_user_only = false + + invite_message_template { + email_message = "Your username is {username} and temporary password is {####}. " + email_subject = "Your temporary password" + sms_message = "Your username is {username} and temporary password is {####}. " + } + } + + email_configuration { + email_sending_account = "COGNITO_DEFAULT" + } + + password_policy { + minimum_length = 8 + require_lowercase = true + require_numbers = true + require_symbols = true + require_uppercase = true + temporary_password_validity_days = 7 + } + + username_configuration { + case_sensitive = false + } + + verification_message_template { + default_email_option = "CONFIRM_WITH_CODE" + } + + // Destroying this pool destroys every user account in it, which recreating it cannot + // bring back. + lifecycle { + prevent_destroy = true + } +} + +resource "aws_cognito_user_pool_domain" "shared" { + domain = "hackforla-vrms-dev" + user_pool_id = aws_cognito_user_pool.shared.id +} diff --git a/terraform/import.tf b/terraform/import.tf index 216cae1..e4558ec 100644 --- a/terraform/import.tf +++ b/terraform/import.tf @@ -582,3 +582,31 @@ import { to = module.home-unite-us.aws_cloudwatch_log_group.lambda[each.key] id = "/aws/lambda/home-unite-us-${each.key}" } + +# Adopts the shared Cognito user pool -- created by hand in 2022 as `vrms-dev` -- its +# domain, and its four app clients: three people-depot's, one VRMS's. See +# hackforla/incubator#17. +import { + to = aws_cognito_user_pool.shared + id = "us-west-2_Fn4rkZpuB" +} + +import { + to = aws_cognito_user_pool_domain.shared + id = "hackforla-vrms-dev" +} + +import { + for_each = { + peopledepot = "52n88hbq9kn00utcjk2hg0e8nl" + pd-2 = "2pn3qa717ae8lq8u801v8t9hps" + backend = "3e3bi1ct2ks9rcktrde8v60v3u" + } + to = module.people-depot.module.shared_user_pool_access.aws_cognito_user_pool_client.this[each.key] + id = "us-west-2_Fn4rkZpuB/${each.value}" +} + +import { + to = module.vrms.module.shared_user_pool_access.aws_cognito_user_pool_client.this["vrms"] + id = "us-west-2_Fn4rkZpuB/5u7s2nj55mp9v5qmt9scja4hnr" +} diff --git a/terraform/main.tf b/terraform/main.tf index cdd25e0..53693f1 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -9,6 +9,9 @@ module "people-depot" { // peopledepot-dev.vrms.io sits in a zone the vrms project owns. vrms_zone_id = module.vrms.zone_id + + shared_user_pool_id = aws_cognito_user_pool.shared.id + shared_user_pool_arn = aws_cognito_user_pool.shared.arn } module "civic-tech-jobs" { @@ -21,6 +24,9 @@ module "home-unite-us" { module "vrms" { source = "./projects/vrms" + + shared_user_pool_id = aws_cognito_user_pool.shared.id + shared_user_pool_arn = aws_cognito_user_pool.shared.arn } module "civic-tech-index" { diff --git a/terraform/modules/shared-user-pool-access/README.md b/terraform/modules/shared-user-pool-access/README.md new file mode 100644 index 0000000..78943a7 --- /dev/null +++ b/terraform/modules/shared-user-pool-access/README.md @@ -0,0 +1,65 @@ + +# shared-user-pool-access + +Gives one project access to the shared Cognito user pool declared in +`terraform/cognito.tf`: the project's own app clients in that pool and, optionally, +admin rights on the pool for the project's task role. See hackforla/incubator#17. + +Every client in the pool shares the pool's users, so a project that wants users of its +own should declare its own pool instead, as home-unite-us does. + +## Admin rights + +Set `task_role_name` to grant the role the four Cognito operations that need IAM -- +`AdminGetUser`, `AdminCreateUser`, `AdminAddUserToGroup` and `AdminDeleteUser` -- on the +shared pool only. These are the same four the container module already grants on pools +tagged with the project's name; the shared pool carries no `project` tag, so this module +is the only way a project gets them there. Leave it unset for a project that only signs +users in: those APIs authorize against the end user's own credentials and need no IAM. + +Note that the pool is shared, so these rights cover every project's users in it, not +just this project's. + +## Client secrets + +`generate_secret` cannot be read back from the API, so an imported client plans a +replacement unless it is ignored -- and replacing a client mints a new client id, which +breaks any application configured with the old one. The module therefore ignores it, +and prevents destroy so any future replacement fails loudly instead. + +## Requirements + +No requirements. + +## Providers + +| Name | Version | +|------|---------| +| [aws](#provider\_aws) | n/a | + +## Modules + +No modules. + +## Resources + +| Name | Type | +|------|------| +| [aws_cognito_user_pool_client.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cognito_user_pool_client) | resource | +| [aws_iam_role_policy.admin](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [clients](#input\_clients) | this project's app clients in the shared pool, keyed by a short stable name. `name` is the client name Cognito shows, which is also what an application sees. |
map(object({
name = string
generate_secret = optional(bool, false)
explicit_auth_flows = optional(list(string), ["ALLOW_REFRESH_TOKEN_AUTH", "ALLOW_USER_SRP_AUTH"])
supported_identity_providers = optional(list(string))
callback_urls = optional(list(string))
allowed_oauth_flows = optional(list(string))
allowed_oauth_scopes = optional(list(string))
allowed_oauth_flows_user_pool_client = optional(bool, false)
}))
| n/a | yes | +| [task\_role\_name](#input\_task\_role\_name) | name of the project's task role, to grant it Cognito admin operations on the shared pool. Leave unset if the project only signs users in. | `string` | `null` | no | +| [user\_pool\_arn](#input\_user\_pool\_arn) | ARN of the shared user pool, the resource the admin policy is scoped to | `string` | n/a | yes | +| [user\_pool\_id](#input\_user\_pool\_id) | id of the shared user pool, from aws\_cognito\_user\_pool.shared in terraform/cognito.tf | `string` | n/a | yes | + +## Outputs + +| Name | Description | +|------|-------------| +| [client\_ids](#output\_client\_ids) | app client id for each entry in `clients`, keyed the same way | + diff --git a/terraform/modules/shared-user-pool-access/main.tf b/terraform/modules/shared-user-pool-access/main.tf new file mode 100644 index 0000000..df2e2cf --- /dev/null +++ b/terraform/modules/shared-user-pool-access/main.tf @@ -0,0 +1,116 @@ +/** + * # shared-user-pool-access + * + * Gives one project access to the shared Cognito user pool declared in + * `terraform/cognito.tf`: the project's own app clients in that pool and, optionally, + * admin rights on the pool for the project's task role. See hackforla/incubator#17. + * + * Every client in the pool shares the pool's users, so a project that wants users of its + * own should declare its own pool instead, as home-unite-us does. + * + * ## Admin rights + * + * Set `task_role_name` to grant the role the four Cognito operations that need IAM -- + * `AdminGetUser`, `AdminCreateUser`, `AdminAddUserToGroup` and `AdminDeleteUser` -- on the + * shared pool only. These are the same four the container module already grants on pools + * tagged with the project's name; the shared pool carries no `project` tag, so this module + * is the only way a project gets them there. Leave it unset for a project that only signs + * users in: those APIs authorize against the end user's own credentials and need no IAM. + * + * Note that the pool is shared, so these rights cover every project's users in it, not + * just this project's. + * + * ## Client secrets + * + * `generate_secret` cannot be read back from the API, so an imported client plans a + * replacement unless it is ignored -- and replacing a client mints a new client id, which + * breaks any application configured with the old one. The module therefore ignores it, + * and prevents destroy so any future replacement fails loudly instead. + */ + +locals { + // Every client in the pool reads and writes the full set of standard attributes, bar + // the two verification flags, which are read-only. + write_attributes = [ + "address", + "birthdate", + "email", + "family_name", + "gender", + "given_name", + "locale", + "middle_name", + "name", + "nickname", + "phone_number", + "picture", + "preferred_username", + "profile", + "updated_at", + "website", + "zoneinfo", + ] + read_attributes = concat(local.write_attributes, ["email_verified", "phone_number_verified"]) +} + +resource "aws_cognito_user_pool_client" "this" { + for_each = var.clients + + name = each.value.name + user_pool_id = var.user_pool_id + + generate_secret = each.value.generate_secret + explicit_auth_flows = each.value.explicit_auth_flows + supported_identity_providers = each.value.supported_identity_providers + callback_urls = each.value.callback_urls + allowed_oauth_flows = each.value.allowed_oauth_flows + allowed_oauth_scopes = each.value.allowed_oauth_scopes + allowed_oauth_flows_user_pool_client = each.value.allowed_oauth_flows_user_pool_client + + read_attributes = local.read_attributes + write_attributes = local.write_attributes + + access_token_validity = 60 + id_token_validity = 60 + refresh_token_validity = 30 + auth_session_validity = 3 + + token_validity_units { + access_token = "minutes" + id_token = "minutes" + refresh_token = "days" + } + + prevent_user_existence_errors = "ENABLED" + enable_token_revocation = true + enable_propagate_additional_user_context_data = false + + lifecycle { + ignore_changes = [generate_secret] + prevent_destroy = true + } +} + +resource "aws_iam_role_policy" "admin" { + count = var.task_role_name == null ? 0 : 1 + + name = "shared-user-pool-admin" + role = var.task_role_name + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "SharedUserPoolAdmin" + Effect = "Allow" + Action = [ + "cognito-idp:AdminGetUser", + "cognito-idp:AdminCreateUser", + "cognito-idp:AdminAddUserToGroup", + "cognito-idp:AdminDeleteUser", + ] + Resource = var.user_pool_arn + }, + ] + }) +} diff --git a/terraform/modules/shared-user-pool-access/outputs.tf b/terraform/modules/shared-user-pool-access/outputs.tf new file mode 100644 index 0000000..a7efd05 --- /dev/null +++ b/terraform/modules/shared-user-pool-access/outputs.tf @@ -0,0 +1,4 @@ +output "client_ids" { + description = "app client id for each entry in `clients`, keyed the same way" + value = { for k, c in aws_cognito_user_pool_client.this : k => c.id } +} diff --git a/terraform/modules/shared-user-pool-access/variables.tf b/terraform/modules/shared-user-pool-access/variables.tf new file mode 100644 index 0000000..04ea0be --- /dev/null +++ b/terraform/modules/shared-user-pool-access/variables.tf @@ -0,0 +1,29 @@ +variable "user_pool_id" { + type = string + description = "id of the shared user pool, from aws_cognito_user_pool.shared in terraform/cognito.tf" +} + +variable "user_pool_arn" { + type = string + description = "ARN of the shared user pool, the resource the admin policy is scoped to" +} + +variable "clients" { + type = map(object({ + name = string + generate_secret = optional(bool, false) + explicit_auth_flows = optional(list(string), ["ALLOW_REFRESH_TOKEN_AUTH", "ALLOW_USER_SRP_AUTH"]) + supported_identity_providers = optional(list(string)) + callback_urls = optional(list(string)) + allowed_oauth_flows = optional(list(string)) + allowed_oauth_scopes = optional(list(string)) + allowed_oauth_flows_user_pool_client = optional(bool, false) + })) + description = "this project's app clients in the shared pool, keyed by a short stable name. `name` is the client name Cognito shows, which is also what an application sees." +} + +variable "task_role_name" { + type = string + default = null + description = "name of the project's task role, to grant it Cognito admin operations on the shared pool. Leave unset if the project only signs users in." +} diff --git a/terraform/projects/people-depot/README.md b/terraform/projects/people-depot/README.md index 0c4bbe9..a4d3777 100644 --- a/terraform/projects/people-depot/README.md +++ b/terraform/projects/people-depot/README.md @@ -19,6 +19,7 @@ No requirements. | [dev\_dns\_entry](#module\_dev\_dns\_entry) | ../../modules/dns-entry | n/a | | [people\_depot\_cicd](#module\_people\_depot\_cicd) | ../../modules/cicd_integration | n/a | | [people\_depot\_ecr\_backend](#module\_people\_depot\_ecr\_backend) | ../../modules/ecr | n/a | +| [shared\_user\_pool\_access](#module\_shared\_user\_pool\_access) | ../../modules/shared-user-pool-access | n/a | ## Resources @@ -30,6 +31,8 @@ No requirements. | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| +| [shared\_user\_pool\_arn](#input\_shared\_user\_pool\_arn) | ARN of the shared Cognito user pool | `string` | n/a | yes | +| [shared\_user\_pool\_id](#input\_shared\_user\_pool\_id) | id of the shared Cognito user pool | `string` | n/a | yes | | [vrms\_zone\_id](#input\_vrms\_zone\_id) | the vrms.io hosted zone id, owned by the vrms project | `string` | n/a | yes | ## Outputs diff --git a/terraform/projects/people-depot/cognito.tf b/terraform/projects/people-depot/cognito.tf new file mode 100644 index 0000000..b90b19a --- /dev/null +++ b/terraform/projects/people-depot/cognito.tf @@ -0,0 +1,45 @@ +// people-depot's app clients in the shared Cognito user pool, adopted in place. See +// hackforla/incubator#17. +// +// The client names are long descriptions rather than names, and they are kept as-is: +// they are what Cognito shows, and changing them is a live change for no benefit. +module "shared_user_pool_access" { + source = "../../modules/shared-user-pool-access" + + user_pool_id = var.shared_user_pool_id + user_pool_arn = var.shared_user_pool_arn + + // Grants the dev backend's task role admin operations on the shared pool. Note this + // covers every user in the pool, not only people-depot's. + task_role_name = module.backend_dev_service.task_role_name + + clients = { + peopledepot = { + name = "PEOPLEDEPOT is a backend app client that contains a secret" + generate_secret = true + supported_identity_providers = ["COGNITO"] + callback_urls = ["http://localhost:8000/accounts/amazon-cognito/login/callback/", "http://localhost:8000/admin/"] + allowed_oauth_flows = ["code"] + allowed_oauth_scopes = ["email", "openid", "profile"] + allowed_oauth_flows_user_pool_client = true + } + pd-2 = { + name = "pd-2 is a secret-less app client which should be used with a frontend and NOT with a backend" + supported_identity_providers = ["COGNITO"] + callback_urls = ["http://localhost:8000/accounts/amazon-cognito/login/callback/", "http://localhost:8000/admin/"] + allowed_oauth_flows = ["code"] + allowed_oauth_scopes = ["email", "openid", "profile"] + allowed_oauth_flows_user_pool_client = true + } + backend = { + name = "backend is the old app client used by PD, which returns the auth token in the url" + generate_secret = true + explicit_auth_flows = ["ALLOW_ADMIN_USER_PASSWORD_AUTH", "ALLOW_REFRESH_TOKEN_AUTH"] + supported_identity_providers = ["COGNITO"] + callback_urls = ["http://localhost:8000/admin"] + allowed_oauth_flows = ["implicit"] + allowed_oauth_scopes = ["openid"] + allowed_oauth_flows_user_pool_client = true + } + } +} diff --git a/terraform/projects/people-depot/environment-dev.tf b/terraform/projects/people-depot/environment-dev.tf index eadee58..1fe53be 100644 --- a/terraform/projects/people-depot/environment-dev.tf +++ b/terraform/projects/people-depot/environment-dev.tf @@ -53,7 +53,7 @@ module "backend_dev_service" { { "name": "SQL_PORT", "value": module.dev_database.port}, { "name": "COGNITO_DOMAIN", "value": "peopledepot"}, { "name": "COGNITO_AWS_REGION", "value": "us-west-2"}, - { "name": "COGNITO_USER_POOL", "value": "us-west-2_Fn4rkZpuB"}, + { "name": "COGNITO_USER_POOL", "value": var.shared_user_pool_id}, ] container_environment_secrets = [ { "name": "SQL_PASSWORD", "valueFrom": module.dev_database.owner_password_arn}, diff --git a/terraform/projects/people-depot/variables.tf b/terraform/projects/people-depot/variables.tf index a65b2d8..667f7f7 100644 --- a/terraform/projects/people-depot/variables.tf +++ b/terraform/projects/people-depot/variables.tf @@ -4,3 +4,14 @@ variable "vrms_zone_id" { type = string description = "the vrms.io hosted zone id, owned by the vrms project" } + +// The shared Cognito user pool in ../../cognito.tf. See hackforla/incubator#17. +variable "shared_user_pool_id" { + type = string + description = "id of the shared Cognito user pool" +} + +variable "shared_user_pool_arn" { + type = string + description = "ARN of the shared Cognito user pool" +} diff --git a/terraform/projects/vrms/README.md b/terraform/projects/vrms/README.md index cf26708..051272d 100644 --- a/terraform/projects/vrms/README.md +++ b/terraform/projects/vrms/README.md @@ -30,6 +30,7 @@ No requirements. | [mailhog\_password\_secret](#module\_mailhog\_password\_secret) | ../../modules/secret | n/a | | [mailhog\_user\_secret](#module\_mailhog\_user\_secret) | ../../modules/secret | n/a | | [prod\_database\_url\_secret](#module\_prod\_database\_url\_secret) | ../../modules/secret | n/a | +| [shared\_user\_pool\_access](#module\_shared\_user\_pool\_access) | ../../modules/shared-user-pool-access | n/a | | [slack\_bot\_token\_secret](#module\_slack\_bot\_token\_secret) | ../../modules/secret | n/a | | [slack\_client\_secret\_secret](#module\_slack\_client\_secret\_secret) | ../../modules/secret | n/a | | [slack\_oauth\_token\_secret](#module\_slack\_oauth\_token\_secret) | ../../modules/secret | n/a | @@ -45,7 +46,10 @@ No requirements. ## Inputs -No inputs. +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [shared\_user\_pool\_arn](#input\_shared\_user\_pool\_arn) | ARN of the shared Cognito user pool | `string` | n/a | yes | +| [shared\_user\_pool\_id](#input\_shared\_user\_pool\_id) | id of the shared Cognito user pool | `string` | n/a | yes | ## Outputs diff --git a/terraform/projects/vrms/cognito.tf b/terraform/projects/vrms/cognito.tf new file mode 100644 index 0000000..7820063 --- /dev/null +++ b/terraform/projects/vrms/cognito.tf @@ -0,0 +1,17 @@ +// VRMS's app client in the shared Cognito user pool, adopted in place. See +// hackforla/incubator#17. +// +// No task_role_name: nothing in VRMS's container configuration references Cognito, so +// it is not granted admin operations on the pool. +module "shared_user_pool_access" { + source = "../../modules/shared-user-pool-access" + + user_pool_id = var.shared_user_pool_id + user_pool_arn = var.shared_user_pool_arn + + clients = { + vrms = { + name = "VRMS" + } + } +} diff --git a/terraform/projects/vrms/variables.tf b/terraform/projects/vrms/variables.tf new file mode 100644 index 0000000..32d54b1 --- /dev/null +++ b/terraform/projects/vrms/variables.tf @@ -0,0 +1,10 @@ +// The shared Cognito user pool in ../../cognito.tf. See hackforla/incubator#17. +variable "shared_user_pool_id" { + type = string + description = "id of the shared Cognito user pool" +} + +variable "shared_user_pool_arn" { + type = string + description = "ARN of the shared Cognito user pool" +}