diff --git a/compute/eks/addons/README.md b/compute/eks/addons/README.md index 37eef06d..0f905e9f 100644 --- a/compute/eks/addons/README.md +++ b/compute/eks/addons/README.md @@ -494,17 +494,17 @@ The chart generates an admin password into a Secret; `grafana_helm_values` is th - `method = "load_balancer"` (the default): the chosen shared ALB reaches Grafana's pods through an IP target group, a host-header rule on the HTTPS listener, and a TargetGroupBinding the load balancer controller keeps registered. `load_balancer = "private"` (the default) uses the internal ALB, reachable only from the VPC and networks connected to it, such as a VPN or a Tailscale subnet router; its security group admits `private_alb_ingress_cidr_blocks`, which by default covers the private ranges a subnet router's traffic arrives from. `"public"` uses the internet-facing ALB, so anyone can reach the sign-in page. Plan refuses it without the chosen ALB's HTTPS listener. Point the hostname at `private_alb_dns_name` or `public_alb_dns_name`; a public record works for the private ALB too, since it resolves to private addresses only reachable from inside. - `method = "ingress"`: the Grafana chart's own Ingress routes `hostname` to the Service and asks for TLS for it, served by the controller of `ingress_class_name` (the cluster's default IngressClass when null), with `ingress_annotations` and `ingress_tls_secret_name` passed to it as they are. With the [Tailscale Kubernetes operator](https://tailscale.com/kb/1439/kubernetes-operator-cluster-ingress)'s `tailscale` class and a `..ts.net` hostname, Grafana becomes one device on the tailnet named ``, with a Let's Encrypt certificate, and nothing else in the VPC is reachable through it. The tailnet needs MagicDNS and HTTPS certificates turned on. Install the class's controller before turning this on: with any shared load balancer, the AWS Load Balancer Controller runs too, and its admission webhook refuses an Ingress whose IngressClass does not exist yet (`invalid ingress class: IngressClass.networking.k8s.io "tailscale" not found`). -**Sign-in** (`grafana_auth`, `grafana_auth_providers`) is Grafana's own, under Grafana's own setting names: +**Sign-in** (`grafana_auth_providers`, `grafana_default_role`) is Grafana's own, under Grafana's own setting names. `grafana_auth_providers` lists every way to sign in, one entry each, and plan refuses an empty list: -- `grafana_auth.login_form_enabled` (default `true`) keeps username and password for Grafana's local users, including the generated admin. `false` turns off the login form and HTTP basic auth both, so a password works nowhere. -- `grafana_auth_providers` lists any of Grafana's OAuth providers side by side, one entry each: `google`, `github`, `gitlab`, `azuread` (Microsoft Entra ID), `okta` and `generic_oauth` (Keycloak, Auth0, Authentik or any OpenID Connect provider). Each entry becomes Grafana's `[auth.]` section. In the form, each one is a card edited in its own dialog. +- `basic` (the default, `[{ provider = "basic" }]`) is username and password for Grafana's local users, including the generated admin, and takes no other field. Without it, the login form and HTTP basic auth are both off, so a password works nowhere. +- Any of Grafana's OAuth providers side by side: `google`, `github`, `gitlab`, `azuread` (Microsoft Entra ID), `okta` and `generic_oauth` (Keycloak, Auth0, Authentik or any OpenID Connect provider). Each entry becomes Grafana's `[auth.]` section. In the form, each one is a card edited in its own dialog. - Typed fields cover what each provider needs: allowed domains, groups, organizations or team IDs, role mapping (`role_attribute_path`), and the tenant, org URL, self-managed URL or endpoints. - `settings` passes any other key of the section through verbatim. + - `grafana_default_role` (`Viewer`, `Editor` or `Admin`; default `Viewer`) is the role an account gets the first time it signs in through one, unless its role mapping says otherwise. - Every client secret is a Secrets Manager ARN. The External Secrets Operator writes it into a Secret, and Grafana reads it as `GF_AUTH__CLIENT_SECRET`, never as a Helm value. - Grafana takes environment variables as an EKS workload does. `grafana_environment_variables` is plain `{ name, value }` objects, and `grafana_secrets` is `{ name, value_from }` references, each a full Secrets Manager or SSM Parameter Store ARN. A Secrets Manager ARN may end in ECS's `:::` to read one key of a JSON secret, as in `arn:aws:secretsmanager:us-east-2:111122223333:secret:prod/app-AbCdEf:GRAFANA_CLIENT_SECRET::`. The External Secrets Operator reads every reference inside the cluster, so none is a Helm value. - Grafana reads any setting from `GF_
_`, so `GF_AUTH__CLIENT_ID` and `GF_AUTH__CLIENT_SECRET` in `grafana_secrets` give a provider its client, in place of `client_id` and `client_secret_arn`. - Grafana expands `$__env{NAME}` in every setting, so a role mapping can name a group from an environment variable: `contains(groups[*], '$__env{ADMINS_GROUP}') && 'Admin' || 'Viewer'`. -- `grafana_auth.default_role` (default `Viewer`) is the role an account gets on first sign-in, unless the provider's role mapping says otherwise. - Roles can follow groups at every sign-in through `role_attribute_path`. For Google Workspace groups, Grafana reads a user's direct group memberships, as group emails, only when `scopes` includes `https://www.googleapis.com/auth/cloud-identity.groups.readonly`, and the Cloud Identity API is enabled in the OAuth client's Google Cloud project. For example, `scopes = "openid email profile https://www.googleapis.com/auth/cloud-identity.groups.readonly"` with `role_attribute_path = "contains(groups[*], 'grafana-admins@example.com') && 'Admin' || 'Viewer'"`. - Plan refuses these configurations: - No way to sign in at all. @@ -853,8 +853,8 @@ failed during initialization have no provider resources to migrate. | grafana_role_creation_enabled | Create the IAM role Amazon Managed Grafana assumes to read the workspace and log groups. | `bool` | `false` | no | | grafana_source_account_id | Account whose Grafana workspaces may assume that role (`aws:SourceAccount`). Null uses this account. | `string` | `null` | no | | grafana_access | `{ enabled, method, hostname, load_balancer, listener_rule_priority, ingress_class_name, ingress_annotations, ingress_tls_secret_name }` — serve the in-cluster Grafana over HTTPS at `hostname`. `method = "load_balancer"` (default) routes it on the `private` (default) or `public` ALB's HTTPS listener, a null priority letting AWS assign one; `"ingress"` gives it an Ingress of `ingress_class_name` (the cluster default when null), such as `tailscale`. | `object` | `{}` | no | -| grafana_auth | `{ login_form_enabled, default_role }` — username and password sign-in (default `true`) and the role for new accounts (`Viewer`, `Editor` or `Admin`; default `Viewer`). | `object` | `{}` | no | -| grafana_auth_providers | One entry per Grafana OAuth provider: `provider` (`google`, `github`, `gitlab`, `azuread`, `okta`, `generic_oauth`), `client_id` and `client_secret_arn` (or `GF_AUTH__CLIENT_ID` and `GF_AUTH__CLIENT_SECRET` in `grafana_secrets`), and Grafana's own settings for it: `tenant_id` (Entra ID), `url` (Okta org, self-managed GitLab), `name`, `auth_url`, `token_url`, `api_url`, `scopes` (generic), `allowed_domains`, `allowed_groups`, `allowed_organizations`, `team_ids`, `role_attribute_path`, and a verbatim `settings` map. | `list(object)` | `[]` | no | +| grafana_auth_providers | One entry per way to sign in: `provider` (`basic` for username and password, which takes no other field, or an OAuth provider: `google`, `github`, `gitlab`, `azuread`, `okta`, `generic_oauth`), `client_id` and `client_secret_arn` (or `GF_AUTH__CLIENT_ID` and `GF_AUTH__CLIENT_SECRET` in `grafana_secrets`), and Grafana's own settings for it: `tenant_id` (Entra ID), `url` (Okta org, self-managed GitLab), `name`, `auth_url`, `token_url`, `api_url`, `scopes` (generic), `allowed_domains`, `allowed_groups`, `allowed_organizations`, `team_ids`, `role_attribute_path`, and a verbatim `settings` map. | `list(object)` | `[{ provider = "basic" }]` | no | +| grafana_default_role | The role an account gets the first time it signs in through an OAuth provider: `Viewer`, `Editor` or `Admin`. | `string` | `"Viewer"` | no | | grafana_environment_variables | Plain environment variables for Grafana, `{ name, value }` objects as an EKS workload's `environment_variables`. | `list(object)` | `[]` | no | | grafana_secrets | Secret environment variables for Grafana, `{ name, value_from }` objects as an EKS workload's `secrets`: a full Secrets Manager or SSM Parameter Store ARN, read through External Secrets. A Secrets Manager ARN may end in `:::`. | `list(object)` | `[]` | no | | logs_enabled | Collect container logs with Alloy into an in-cluster Loki storing to S3 in this account. | `bool` | `false` | no | diff --git a/compute/eks/addons/grafana.tf b/compute/eks/addons/grafana.tf index a142b5fc..0ee52788 100644 --- a/compute/eks/addons/grafana.tf +++ b/compute/eks/addons/grafana.tf @@ -240,8 +240,8 @@ resource "helm_release" "grafana" { } precondition { - condition = var.grafana_auth.login_form_enabled || length(local.grafana_auth_entries) > 0 - error_message = "Grafana would have no way to sign in: grafana_auth.login_form_enabled is false and grafana_auth_providers is empty." + condition = length(local.grafana_auth_entries) > 0 + error_message = "Grafana would have no way to sign in: grafana_auth_providers is empty. Add basic for username and password, or an OAuth provider." } precondition { diff --git a/compute/eks/addons/grafana_auth.tf b/compute/eks/addons/grafana_auth.tf index ba3a14c8..7c920235 100644 --- a/compute/eks/addons/grafana_auth.tf +++ b/compute/eks/addons/grafana_auth.tf @@ -1,10 +1,10 @@ ################################################################################ # In-cluster Grafana sign-in # -# Grafana's own authentication, as Grafana's own configuration names it: -# username and password for local users, and the OAuth providers Grafana -# supports out of the box. Each grafana_auth_providers entry becomes one -# [auth.] section in grafana.ini. +# Grafana's own authentication, as Grafana's own configuration names it. The +# basic entry of grafana_auth_providers is username and password for local +# users; each other entry is one of the OAuth providers Grafana supports out of +# the box and becomes one [auth.] section in grafana.ini. # # Every client secret is a Secrets Manager ARN. The External Secrets Operator # materializes it into a Kubernetes Secret, and Grafana reads it from @@ -16,8 +16,10 @@ locals { grafana_auth_entries = var.grafana_enabled ? var.grafana_auth_providers : [] - # The validation guarantees each provider appears once. - grafana_auth_by_provider = { for entry in local.grafana_auth_entries : entry.provider => entry } + grafana_password_sign_in_enabled = contains([for entry in var.grafana_auth_providers : entry.provider], "basic") + + # The OAuth providers. The validation guarantees each provider appears once. + grafana_auth_by_provider = { for entry in local.grafana_auth_entries : entry.provider => entry if entry.provider != "basic" } grafana_auth_values = { for provider, entry in local.grafana_auth_by_provider : provider => { @@ -128,20 +130,19 @@ locals { # grafana.ini: SigV4 for the AMP data source always; the role new accounts # get; the external URL OAuth providers redirect back to while grafana_access - # serves Grafana; and the sign-in methods. Turning username and password - # off removes the login form and HTTP basic auth both, so a password is - # accepted nowhere. + # serves Grafana; and the sign-in methods. Without the basic provider there + # is no login form and no HTTP basic auth, so a password is accepted nowhere. grafana_ini = merge( { auth = merge( { sigv4_auth_enabled = true }, - var.grafana_auth.login_form_enabled ? {} : { disable_login_form = true }, + local.grafana_password_sign_in_enabled ? {} : { disable_login_form = true }, ) users = { - auto_assign_org_role = var.grafana_auth.default_role + auto_assign_org_role = var.grafana_default_role } }, - { for section, settings in { "auth.basic" = { enabled = false } } : section => settings if !var.grafana_auth.login_form_enabled }, + { for section, settings in { "auth.basic" = { enabled = false } } : section => settings if !local.grafana_password_sign_in_enabled }, local.grafana_access_enabled ? { server = { domain = local.grafana_hostname diff --git a/compute/eks/addons/rvn-eks-addons-definition.yml b/compute/eks/addons/rvn-eks-addons-definition.yml index 69cb6915..0139e1a9 100644 --- a/compute/eks/addons/rvn-eks-addons-definition.yml +++ b/compute/eks/addons/rvn-eks-addons-definition.yml @@ -4,8 +4,8 @@ definition: name: EKS Add-ons description: Selectable add-ons for an existing EKS cluster. release: - version: 0.24.1 - description: The cert-manager setting now explains that it is for TLS that ends inside the cluster, and that load balancer HTTPS with Ravion-managed domains or ACM certificates doesn't need it. + version: 0.25.0 + description: "Username and password is now one of Grafana's sign-in providers instead of a separate toggle, and new clusters start with it. The cert-manager setting now explains that it is for TLS that ends inside the cluster, and that load balancer HTTPS with Ravion-managed domains or ACM certificates doesn't need it." module: inputs: - id: cluster @@ -993,15 +993,8 @@ module: - id: section_grafana_sign_in label: Grafana sign-in type: section - - default: true - description: Grafana's built-in sign-in for local users, including the generated admin. Turning it off removes the login form and password access to the API, so only the providers below can sign in. - id: grafana_login_form_enabled - label: Username and password - show_when: - grafana_enabled: true - type: boolean - default: Viewer - description: Grafana role an account gets the first time it signs in through a provider, unless the provider's role mapping says otherwise. + description: Grafana role an account gets the first time it signs in through an OAuth provider, unless the provider's role mapping says otherwise. id: grafana_default_role label: Role for new accounts show_when: @@ -1014,16 +1007,21 @@ module: value: Editor - label: Admin value: Admin - - description: Grafana's OAuth providers people can sign in with, each a button on Grafana's sign-in page. Google, GitHub and gitlab.com need allowed domains, organizations, teams or groups, since anyone with an account there could otherwise sign in. + - default: + - provider: basic + description: How people sign in to Grafana. Without Username and password there is no login form and no password works, including the generated admin's. Google, GitHub and gitlab.com need allowed domains, organizations, teams or groups, since anyone with an account there could otherwise sign in. id: grafana_auth_providers item_description: Client ID {client_id} item_inputs: - - description: Which of Grafana's OAuth providers this is. + - description: Username and password, or one of Grafana's OAuth providers. id: provider label: Provider required: true type: string values: + - description: Grafana's login form for local users, including the generated admin + label: Username and password + value: basic - label: Google value: google - label: GitHub @@ -1047,11 +1045,17 @@ module: - description: Client ID of the OAuth application. Leave blank when Secrets below gives it as GF_AUTH__CLIENT_ID. Its redirect URI is https:///login/, for example /login/google or /login/generic_oauth. id: client_id label: Client ID + show_when: + provider: + not: basic type: string - description: Secrets Manager ARN containing the client secret as plain text. External Secrets reads it into the cluster; it is never a Helm value. Leave blank when Secrets below gives it as GF_AUTH__CLIENT_SECRET. id: client_secret_arn label: Client secret ARN placeholder: arn:aws:secretsmanager:us-east-2:111122223333:secret:grafana-oauth-client-secret + show_when: + provider: + not: basic type: string - description: Directory (tenant) ID of the app registration. Roles come from the app registration's app roles. id: tenant_id @@ -1094,12 +1098,18 @@ module: id: scopes label: Scopes placeholder: openid email profile + show_when: + provider: + not: basic type: string - add_button_label: Add domain description: Email domains whose accounts can sign in. For Google, your Google Workspace domains. id: allowed_domains label: Allowed domains placeholder: example.com + show_when: + provider: + not: basic type: string_array - add_button_label: Add organization description: GitHub organizations, Entra ID tenant IDs, or the provider's organizations whose members can sign in. @@ -1142,13 +1152,18 @@ module: placeholder: contains(groups[*], 'grafana-admins') && 'Admin' || 'Viewer' show_when: provider: - not: azuread + not: + - azuread + - basic type: string - collapsible: true default: {} description: 'Any other key of Grafana''s [auth.] section, as JSON, passed through verbatim, such as {"auto_login": "true"}. The client secret goes in the ARN above, never here.' id: settings label: Other Grafana settings + show_when: + provider: + not: basic type: object item_label: Sign-in provider item_title: @@ -1850,10 +1865,8 @@ module: ingress_annotations: '<< module.input.grafana_ingress_annotations != nil ? module.input.grafana_ingress_annotations : {} >>' ingress_tls_secret_name: << module.input.grafana_ingress_tls_secret_name >> hostname: << module.input.grafana_hostname >> - grafana_auth: - login_form_enabled: '<< module.input.grafana_login_form_enabled != nil ? module.input.grafana_login_form_enabled : true >>' - default_role: << module.input.grafana_default_role || "Viewer" >> - grafana_auth_providers: '<< module.input.grafana_auth_providers != nil ? module.input.grafana_auth_providers : [] >>' + grafana_default_role: << module.input.grafana_default_role || "Viewer" >> + grafana_auth_providers: '<< module.input.grafana_auth_providers != nil ? module.input.grafana_auth_providers : [{"provider": "basic"}] >>' grafana_environment_variables: '<< module.input.grafana_environment_variables != nil ? module.input.grafana_environment_variables : [] >>' grafana_secrets: '<< module.input.grafana_secrets != nil ? module.input.grafana_secrets : [] >>' eso_enabled: '<< module.input.eso_enabled != nil ? module.input.eso_enabled : true >>' @@ -2126,7 +2139,7 @@ module: - **Shared load balancer** routes the hostname on the HTTPS listener of the shared private or public ALB. The private ALB keeps Grafana inside the VPC and the networks connected to it, such as a VPN or a Tailscale subnet router; the public ALB puts its sign-in page on the internet. Point the hostname's DNS at `private_alb_dns_name` or `public_alb_dns_name`. - **Kubernetes Ingress** gives Grafana an Ingress for the hostname with TLS, served by the controller of the ingress class you name, with any annotations it needs. With the Tailscale Kubernetes operator's `tailscale` class and a `..ts.net` hostname, Grafana becomes one device on the tailnet with a Let's Encrypt certificate, and nothing else in the VPC is reachable through it. Install the class's controller first: the AWS Load Balancer Controller refuses an Ingress whose class does not exist yet. - **Grafana sign-in** is Grafana's own: username and password for local users, and any of Grafana's OAuth providers side by side (Google, GitHub, GitLab, Microsoft Entra ID, Okta, or any OpenID Connect provider through generic OAuth), each configured under Grafana's own setting names. Each provider's client secret is a Secrets Manager ARN that External Secrets reads into the cluster. Register `https:///login/` as the OAuth application's redirect URI. + **Grafana sign-in** is Grafana's own: a list of sign-in providers, username and password for local users (the default) and any of Grafana's OAuth providers side by side (Google, GitHub, GitLab, Microsoft Entra ID, Okta, or any OpenID Connect provider through generic OAuth), each configured under Grafana's own setting names. Without username and password, Grafana has no login form and accepts no password, including the generated admin's. Each provider's client secret is a Secrets Manager ARN that External Secrets reads into the cluster. Register `https:///login/` as the OAuth application's redirect URI. **Several destinations that render are a fallback chain, not a merge.** Ravion reads the first one that can answer right now - the in-cluster store, then CloudWatch Logs; Amazon Managed Prometheus when selected, then Prometheus through Thanos, then Container Insights - and says which store it is showing. It never merges two of them for the same service, which would show every line twice. @@ -2276,8 +2289,7 @@ module: | Existing log bucket | No | Creates one | Store logs in an existing bucket instead of `ravion-loki--` | | In-cluster Grafana | No | `false` | Grafana in the cluster, wired to the selected logs, metrics and traces stores | | Access | No | `false` | Grafana over HTTPS at a hostname, on the private (default) or public ALB, or through a Kubernetes Ingress | - | Username and password | No | `true` | Grafana's built-in sign-in; off leaves only the providers | - | Sign-in providers | No | None | Any of Google, GitHub, GitLab, Microsoft Entra ID, Okta and generic OAuth | + | Sign-in providers | No | Username and password | Username and password, and any of Google, GitHub, GitLab, Microsoft Entra ID, Okta and generic OAuth | | Grafana read access | No | `false` | Read-only IAM role for Amazon Managed Grafana over the AMP workspace | | Ravion EKS Management | No | `true` | Registers the cluster, installs Operator, and enables in-cluster deployments | | Public ALB | No | `true` | Shared internet-facing ALB (HTTPS with ACM certificate) | diff --git a/compute/eks/addons/tests/grafana_auth.tftest.hcl b/compute/eks/addons/tests/grafana_auth.tftest.hcl index 57dac849..9c6a89bc 100644 --- a/compute/eks/addons/tests/grafana_auth.tftest.hcl +++ b/compute/eks/addons/tests/grafana_auth.tftest.hcl @@ -210,10 +210,7 @@ run "providers_only_turns_off_passwords_everywhere" { command = plan variables { - grafana_auth = { - login_form_enabled = false - default_role = "Editor" - } + grafana_default_role = "Editor" grafana_auth_providers = [ { provider = "google" @@ -230,18 +227,62 @@ run "providers_only_turns_off_passwords_everywhere" { } } +run "username_and_password_beside_a_provider" { + command = plan + + variables { + grafana_auth_providers = [ + { provider = "basic" }, + { + provider = "google" + client_id = "1234.apps.googleusercontent.com" + client_secret_arn = "arn:aws:secretsmanager:us-east-2:123456789012:secret:grafana-google-AbCdEf" + allowed_domains = ["example.com"] + }, + ] + } + + assert { + condition = yamldecode(helm_release.grafana[0].values[0])["grafana.ini"].auth == { sigv4_auth_enabled = true } && !contains(keys(yamldecode(helm_release.grafana[0].values[0])["grafana.ini"]), "auth.basic") && yamldecode(helm_release.grafana[0].values[0])["grafana.ini"]["auth.google"].enabled == true + error_message = "The basic provider must keep the login form beside Google, and become no [auth.basic] section of its own" + } +} + run "rejects_no_way_to_sign_in" { command = plan variables { - grafana_auth = { - login_form_enabled = false - } + grafana_auth_providers = [] } expect_failures = [helm_release.grafana] } +run "rejects_oauth_settings_on_username_and_password" { + command = plan + + variables { + grafana_auth_providers = [ + { provider = "basic", client_id = "1234.apps.googleusercontent.com" }, + ] + } + + expect_failures = [var.grafana_auth_providers] +} + +run "rejects_username_and_password_twice" { + command = plan + + variables { + grafana_auth_providers = [ + { provider = "basic" }, + { provider = "basic" }, + ] + } + + expect_failures = [var.grafana_auth_providers] +} + run "rejects_a_provider_without_its_client" { command = plan @@ -342,12 +383,10 @@ run "rejects_unknown_role" { command = plan variables { - grafana_auth = { - default_role = "Owner" - } + grafana_default_role = "Owner" } - expect_failures = [var.grafana_auth] + expect_failures = [var.grafana_default_role] } run "rejects_provider_secrets_without_external_secrets" { @@ -397,10 +436,6 @@ run "google_roles_from_workspace_groups" { command = plan variables { - grafana_auth = { - login_form_enabled = false - default_role = "Viewer" - } grafana_auth_providers = [ { provider = "google" diff --git a/compute/eks/addons/variables.tf b/compute/eks/addons/variables.tf index 9b026494..25184c6f 100644 --- a/compute/eks/addons/variables.tf +++ b/compute/eks/addons/variables.tf @@ -1509,7 +1509,7 @@ variable "grafana_access" { ingress_tls_secret_name = optional(string) hostname = optional(string) }) - description = "Serve the in-cluster Grafana over HTTPS at hostname. method is load_balancer (the default) or ingress. load_balancer routes hostname on a shared ALB's HTTPS listener: load_balancer is private (the default: the internal ALB, reachable from the VPC and networks connected to it, such as a VPN or a Tailscale subnet router) or public (the internet-facing ALB), and a null listener_rule_priority lets AWS assign one. ingress gives Grafana a Kubernetes Ingress for hostname with TLS, served by the controller of ingress_class_name (the cluster's default IngressClass when null), such as tailscale to put Grafana on a tailnet; ingress_annotations and ingress_tls_secret_name are passed to it as they are. Install that controller before turning this on: the AWS Load Balancer Controller, which this module runs alongside any shared load balancer, refuses an Ingress whose IngressClass does not exist yet. DNS is not managed: point hostname at the chosen ALB's DNS name (public_alb_dns_name or private_alb_dns_name), or at what the ingress controller serves it on. Who can sign in is grafana_auth and grafana_auth_providers." + description = "Serve the in-cluster Grafana over HTTPS at hostname. method is load_balancer (the default) or ingress. load_balancer routes hostname on a shared ALB's HTTPS listener: load_balancer is private (the default: the internal ALB, reachable from the VPC and networks connected to it, such as a VPN or a Tailscale subnet router) or public (the internet-facing ALB), and a null listener_rule_priority lets AWS assign one. ingress gives Grafana a Kubernetes Ingress for hostname with TLS, served by the controller of ingress_class_name (the cluster's default IngressClass when null), such as tailscale to put Grafana on a tailnet; ingress_annotations and ingress_tls_secret_name are passed to it as they are. Install that controller before turning this on: the AWS Load Balancer Controller, which this module runs alongside any shared load balancer, refuses an Ingress whose IngressClass does not exist yet. DNS is not managed: point hostname at the chosen ALB's DNS name (public_alb_dns_name or private_alb_dns_name), or at what the ingress controller serves it on. Who can sign in is grafana_auth_providers." default = {} nullable = false @@ -1524,18 +1524,15 @@ variable "grafana_access" { } } -variable "grafana_auth" { - type = object({ - login_form_enabled = optional(bool, true) - default_role = optional(string, "Viewer") - }) - description = "Grafana's own sign-in settings. login_form_enabled keeps username and password sign-in for Grafana's local users, including the generated admin; false removes the login form and HTTP basic auth both. default_role is the role an account gets the first time it signs in through a provider (users.auto_assign_org_role): Viewer, Editor or Admin." - default = {} +variable "grafana_default_role" { + type = string + description = "The Grafana role an account gets the first time it signs in through an OAuth provider (users.auto_assign_org_role): Viewer, Editor or Admin." + default = "Viewer" nullable = false validation { - condition = contains(["Viewer", "Editor", "Admin"], var.grafana_auth.default_role) - error_message = "The grafana_auth.default_role must be Viewer, Editor or Admin." + condition = contains(["Viewer", "Editor", "Admin"], var.grafana_default_role) + error_message = "The grafana_default_role must be Viewer, Editor or Admin." } } @@ -1559,19 +1556,32 @@ variable "grafana_auth_providers" { settings = optional(map(string), {}) })) description = <<-EOT - OAuth providers people sign in to the in-cluster Grafana with, one entry per provider, each becoming Grafana's [auth.] section under Grafana's own key names. provider is one of Grafana's own: google, github, gitlab, azuread (Microsoft Entra ID), okta, generic_oauth. + How people sign in to the in-cluster Grafana, one entry per provider. provider is basic or one of Grafana's own OAuth providers: google, github, gitlab, azuread (Microsoft Entra ID), okta, generic_oauth. + - basic is username and password for Grafana's local users, including the generated admin, and takes no other field. Without it, Grafana has no login form and accepts no password, HTTP basic auth included. + - Each OAuth entry becomes Grafana's [auth.] section under Grafana's own key names. - client_id and client_secret_arn (a Secrets Manager ARN, read through External Secrets) give the provider its client. Leave either out when grafana_secrets gives it as GF_AUTH__CLIENT_ID or GF_AUTH__CLIENT_SECRET. - azuread needs tenant_id. okta needs url (the org URL). gitlab takes url for a self-managed GitLab (null uses gitlab.com). generic_oauth needs auth_url and token_url, and takes name (the button label), api_url and scopes. - allowed_domains, allowed_groups, allowed_organizations and team_ids restrict who can sign in. Google, GitHub and gitlab.com need one, since anyone with an account there could otherwise sign in. - role_attribute_path maps the provider's user info to a Grafana role. - settings passes any other key of the section through verbatim. EOT - default = [] + default = [{ provider = "basic" }] nullable = false validation { - condition = alltrue([for entry in var.grafana_auth_providers : contains(["google", "github", "gitlab", "azuread", "okta", "generic_oauth"], entry.provider)]) - error_message = "Each grafana_auth_providers entry's provider must be one of: google, github, gitlab, azuread, okta, generic_oauth." + condition = alltrue([for entry in var.grafana_auth_providers : contains(["basic", "google", "github", "gitlab", "azuread", "okta", "generic_oauth"], entry.provider)]) + error_message = "Each grafana_auth_providers entry's provider must be one of: basic, google, github, gitlab, azuread, okta, generic_oauth." + } + + validation { + condition = alltrue([ + for entry in var.grafana_auth_providers : entry.provider != "basic" || ( + alltrue([for value in [entry.name, entry.client_id, entry.client_secret_arn, entry.tenant_id, entry.url, entry.auth_url, entry.token_url, entry.api_url, entry.scopes, entry.role_attribute_path] : try(trimspace(value), "") == ""]) && + alltrue([for values in [entry.allowed_domains, entry.allowed_groups, entry.allowed_organizations, entry.team_ids] : length(values) == 0]) && + length(entry.settings) == 0 + ) + ]) + error_message = "The basic grafana_auth_providers entry is username and password and takes only provider: clients, endpoints, scopes, restrictions, role mapping and settings belong to OAuth providers." } validation {