Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions .github/workflows/deploy-lgtm-gke.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ on:
push:
branches:
- main
- 25-setup-pipeline-for-deploying-terraform-scripts
- feature/integrate-keycloak-sso
paths:
- 'lgtm-stack/terraform/**'
- '.github/workflows/deploy-lgtm-gke.yaml'
Expand All @@ -37,6 +37,8 @@ permissions:
contents: read
pull-requests: write
id-token: write
actions: write


env:
TERRAFORM_VERSION: '1.6.0'
Expand Down Expand Up @@ -150,6 +152,13 @@ jobs:
force_destroy = ${{ inputs.force_destroy || 'false' }}
gke_endpoint = "${{ steps.cluster_info.outputs.endpoint }}"
gke_ca_certificate = "${{ steps.cluster_info.outputs.ca_cert }}"
keycloak_url = "${{ secrets.KEYCLOAK_URL }}"
keycloak_realm = "${{ secrets.KEYCLOAK_REALM }}"
keycloak_admin_user = "${{ secrets.KEYCLOAK_ADMIN_USER }}"
keycloak_admin_password = "${{ secrets.KEYCLOAK_PASSWORD }}"
grafana_keycloak_user = "${{ secrets.GRAFANA_KEYCLOAK_USER }}"
grafana_keycloak_email = "${{ secrets.GRAFANA_KEYCLOAK_EMAIL }}"
grafana_keycloak_password = "${{ secrets.GRAFANA_KEYCLOAK_PASSWORD }}"
EOF

# Critical Step: Generate execution plan and capture exit code (0=no change, 2=changes)
Expand Down Expand Up @@ -193,13 +202,13 @@ jobs:

# Job 2: Terraform Apply
# Execution job that applies the previously reviewed plan.
# This job only runs on merges to main or manual approval.
# This job only runs on merges to main, target branch, or manual approval.
terraform-apply:
name: Terraform Apply
runs-on: ubuntu-latest
needs: [terraform-plan]
if: |
(github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/25-setup-pipeline-for-deploying-terraform-scripts')) ||
(github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/feature/integrate-keycloak-sso')) ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.terraform_action == 'apply')
environment:
name: production
Expand Down Expand Up @@ -267,6 +276,13 @@ jobs:
force_destroy = ${{ inputs.force_destroy || 'false' }}
gke_endpoint = "${{ steps.cluster_info.outputs.endpoint }}"
gke_ca_certificate = "${{ steps.cluster_info.outputs.ca_cert }}"
keycloak_url = "${{ secrets.KEYCLOAK_URL }}"
keycloak_realm = "${{ secrets.KEYCLOAK_REALM }}"
keycloak_admin_user = "${{ secrets.KEYCLOAK_ADMIN_USER }}"
keycloak_admin_password = "${{ secrets.KEYCLOAK_PASSWORD }}"
grafana_keycloak_user = "${{ secrets.GRAFANA_KEYCLOAK_USER }}"
grafana_keycloak_email = "${{ secrets.GRAFANA_KEYCLOAK_EMAIL }}"
grafana_keycloak_password = "${{ secrets.GRAFANA_KEYCLOAK_PASSWORD }}"
EOF

# Step: Download the execution plan artifact
Expand Down
75 changes: 75 additions & 0 deletions docs/keycloak-sso-integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Keycloak SSO Integration for LGTM Stack

## 1. Architectural Overview
The LGTM stack (Loki, Grafana, Tempo, Mimir) has been configured to use **Keycloak** as the strict single source of truth for authentication and authorization.

Local basic authentication (username/password) has been completely disabled in Grafana. All users attempting to access `https://grafana.<domain>` are automatically redirected to the Keycloak login page (via OIDC RP-Initiated login).

Access is strictly governed by **Keycloak Groups**. If a user in the Keycloak realm is not a member of an authorized `grafana-*` group, they are fundamentally blocked from accessing the LGTM stack.

## 2. Infrastructure as Code (Terraform)
The entire Keycloak integration is automated via Terraform in `terraform/keycloak.tf` using the `mrparkers/keycloak` provider.

### The OIDC Client (`grafana-oauth`)
A Confidential OpenID Connect client is created specifically for Grafana.
- **Redirect URIs:** Meticulously configured to allow the standard `/login/generic_oauth` callback, as well as explicitly authorizing the `/login` path to satisfy Keycloak 18+ strict post-logout redirect security policies.

### Group & Role Provisioning
Terraform automates the creation of three dedicated groups inside the target Keycloak realm:
1. `grafana-admins`
2. `grafana-editors`
3. `grafana-viewers`

Simultaneously, Terraform creates three Realm Roles (`grafana-admin`, `grafana-editor`, `grafana-viewer`) and explicitly maps them to their respective groups. *Any user added to a group automatically inherits the underlying role.*

### Protocol Mappers (JWT Injection)
To pass authorization data to Grafana, Terraform attaches two Protocol Mappers to the client:
1. **Roles Mapper:** Injects the user's realm roles into the JWT token under the `roles` claim.
2. **Groups Mapper:** Injects the user's group memberships into the JWT under the `groups` claim.

### Dedicated Admin User
To prevent credential sharing and maintain clean security boundaries, a dedicated Grafana admin user is generated in Keycloak and automatically joined to the `grafana-admins` group.

## 3. Grafana Authentication Configuration
Grafana's configuration (`terraform/values/grafana-values.yaml`) is hardened to enforce the Keycloak SSO policies.

### Strict Role Mapping
Grafana evaluates the `roles` array inside the incoming JWT using JMESPath logic:
```yaml
role_attribute_path: "contains(roles[*], 'grafana-admin') && 'Admin' || contains(roles[*], 'grafana-editor') && 'Editor' || contains(roles[*], 'grafana-viewer') && 'Viewer'"
```
Because `role_attribute_strict: true` is enabled, any user who manages to log in but possesses none of these roles is immediately rejected by Grafana.

### Group-Based Access Control (GBAC)
Grafana evaluates the `groups` array against the `allowed_groups` list (`grafana-admins grafana-editors grafana-viewers`). This guarantees that only authorized teams can initiate a session.

### Security Configurations
- **Client Secret:** The OAuth client secret is never stored in plaintext within the `grafana.ini`. It is securely passed as an environment variable (`GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET`) to satisfy Helm validation rules.
- **Admin Recovery (`oauth_allow_insecure_email_lookup`):** This critical setting ensures the Keycloak `admin` account can successfully map to the built-in Grafana `admin` account via email, bypassing the default security block that otherwise results in a "User Sync Failed" error.

## 4. User Management Workflow (IMPORTANT)

> [!WARNING]
> **Users CANNOT be invited or managed from within the Grafana UI.**

Because Keycloak is the strict source of truth, any roles assigned manually within Grafana will be instantly overwritten and downgraded the next time the user logs in.

**To grant a user access to Grafana:**
1. A System Administrator must log into the Keycloak Admin Console.
2. Navigate to the target Realm (e.g., `<realm>`).
3. Create the user or locate an existing user.
4. Navigate to the user's **Groups** tab.
5. Join the user to either `grafana-admins`, `grafana-editors`, or `grafana-viewers`.

Upon their next login, Grafana will automatically sync the user and grant them the appropriate permissions.

## 5. Required CI/CD Secrets
For this configuration to deploy successfully via GitHub Actions, the following secrets must be present in the repository:

1. `KEYCLOAK_URL` (e.g., `https://<keycloak-domain>/<realm>.com`)
2. `KEYCLOAK_REALM` (e.g., `<realm>`)
3. `KEYCLOAK_ADMIN_USER`
4. `KEYCLOAK_ADMIN_PASSWORD`
5. `GRAFANA_KEYCLOAK_USER` (The dedicated Grafana admin username)
6. `GRAFANA_KEYCLOAK_EMAIL`
7. `GRAFANA_KEYCLOAK_PASSWORD`
170 changes: 170 additions & 0 deletions lgtm-stack/terraform/keycloak.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
# ============================================================
# Keycloak Terraform Configuration — Grafana SSO
# ============================================================
# This file automates the full Keycloak-side setup inside the
# existing realm on <keycloak-domain> (shared with Auth/SSO).
#
# What it creates:
# 1. OpenID Connect client: grafana-oauth
# 2. Keycloak groups: grafana-admins, grafana-editors, grafana-viewers
# 3. Realm roles: admin, editor, viewer (mapped to groups)
# 4. Protocol mappers: realm roles + groups into JWT
# 5. A dedicated Grafana admin user (separate from NetBird users)
#
# Access control:
# - Only users in a grafana-* group can access Grafana
# - Users NOT in any group are BLOCKED (strict mode)
# - Group membership determines Grafana role (Admin/Editor/Viewer)
# ============================================================

# ---- OpenID Connect Client -----------------------------------

resource "keycloak_openid_client" "grafana" {
realm_id = var.keycloak_realm
client_id = "grafana-oauth"
name = "Grafana LGTM Monitoring"
enabled = true

access_type = "CONFIDENTIAL"

standard_flow_enabled = true
implicit_flow_enabled = false
direct_access_grants_enabled = true

root_url = "https://grafana.${var.monitoring_domain}"
base_url = "https://grafana.${var.monitoring_domain}"
admin_url = "https://grafana.${var.monitoring_domain}"

valid_redirect_uris = [
"https://grafana.${var.monitoring_domain}/login/generic_oauth",
# Required for KC 18+ post-logout redirect to function correctly.
# Must perfectly match the post_logout_redirect_uri parameter sent by Grafana.
"https://grafana.${var.monitoring_domain}/login"
]

web_origins = [
"https://grafana.${var.monitoring_domain}"
]
}

# ---- Keycloak Groups -----------------------------------------
# Groups provide clean access control in a shared realm.
# Users of NetBird won't have Grafana access unless explicitly
# added to one of these groups.
#
# grafana-admins → Grafana Admin (full control)
# grafana-editors → Grafana Editor (create/edit dashboards)
# grafana-viewers → Grafana Viewer (read-only)

resource "keycloak_group" "grafana_admins" {
realm_id = var.keycloak_realm
name = "grafana-admins"
}

resource "keycloak_group" "grafana_editors" {
realm_id = var.keycloak_realm
name = "grafana-editors"
}

resource "keycloak_group" "grafana_viewers" {
realm_id = var.keycloak_realm
name = "grafana-viewers"
}

# ---- Realm Roles ---------------------------------------------

resource "keycloak_role" "grafana_admin" {
realm_id = var.keycloak_realm
name = "grafana-admin"
description = "Grafana Admin — full access to dashboards and settings"
}

resource "keycloak_role" "grafana_editor" {
realm_id = var.keycloak_realm
name = "grafana-editor"
description = "Grafana Editor — can create and edit dashboards"
}

resource "keycloak_role" "grafana_viewer" {
realm_id = var.keycloak_realm
name = "grafana-viewer"
description = "Grafana Viewer — read-only access to dashboards"
}

# ---- Group → Role Mappings -----------------------------------
# Everyone in grafana-admins automatically gets the grafana-admin role.

resource "keycloak_group_roles" "grafana_admin_roles" {
realm_id = var.keycloak_realm
group_id = keycloak_group.grafana_admins.id
role_ids = [keycloak_role.grafana_admin.id]
}

resource "keycloak_group_roles" "grafana_editor_roles" {
realm_id = var.keycloak_realm
group_id = keycloak_group.grafana_editors.id
role_ids = [keycloak_role.grafana_editor.id]
}

resource "keycloak_group_roles" "grafana_viewer_roles" {
realm_id = var.keycloak_realm
group_id = keycloak_group.grafana_viewers.id
role_ids = [keycloak_role.grafana_viewer.id]
}

# ---- Protocol Mappers ----------------------------------------

# Mapper 1: Realm Roles → "roles" claim in JWT
# Grafana uses this for role_attribute_path (Admin/Editor/Viewer mapping)
resource "keycloak_openid_user_realm_role_protocol_mapper" "grafana_roles" {
realm_id = var.keycloak_realm
client_id = keycloak_openid_client.grafana.id
name = "grafana-roles-mapper"

claim_name = "roles"
multivalued = true
add_to_id_token = true
add_to_userinfo = true
}

# Mapper 2: Group Membership → "groups" claim in JWT
# Grafana uses this for allowed_groups (strict access control)
resource "keycloak_openid_group_membership_protocol_mapper" "grafana_groups" {
realm_id = var.keycloak_realm
client_id = keycloak_openid_client.grafana.id
name = "grafana-groups-mapper"

claim_name = "groups"
full_path = false
add_to_id_token = true
add_to_userinfo = true
}

# ---- Dedicated Grafana Admin User ----------------------------
# A separate user from the NetBird admin user, to avoid
# confusion and credential sharing between services.

resource "keycloak_user" "grafana_admin" {
realm_id = var.keycloak_realm
username = var.grafana_keycloak_user
enabled = true

first_name = "Grafana"
last_name = "Admin"
email = var.grafana_keycloak_email

initial_password {
value = var.grafana_keycloak_password
temporary = false
}
}

# Add the dedicated user to the grafana-admins group
resource "keycloak_user_groups" "grafana_admin_membership" {
realm_id = var.keycloak_realm
user_id = keycloak_user.grafana_admin.id

group_ids = [
keycloak_group.grafana_admins.id
]
}
37 changes: 36 additions & 1 deletion lgtm-stack/terraform/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ terraform {
source = "hashicorp/helm"
version = "~> 2.12"
}
keycloak = {
source = "mrparkers/keycloak"
version = "~> 4.0"
}
}

# Production Best Practice: Store state remotely
Expand Down Expand Up @@ -55,6 +59,28 @@ provider "helm" {
}
}

# Keycloak Provider
# ---------------------------------------------------------------
# Authentication model: Password Grant via admin-cli
# - The provider hits: <url>/realms/<realm>/protocol/openid-connect/token
# - The admin user must have 'realm-admin' from 'realm-management'
# client in the target realm. No master-realm/server-admin needed.
#
# KC 17+ Quarkus (this instance): NO base_path needed — the /auth
# prefix was removed. Older Wildfly builds need base_path = "/auth".
# ---------------------------------------------------------------
provider "keycloak" {
client_id = "admin-cli"
username = var.keycloak_admin_user
password = var.keycloak_admin_password
url = var.keycloak_url # https://<keycloak-domain>
realm = var.keycloak_realm

# base_path is NOT set — correct for Keycloak 17+ (Quarkus distribution)
# If you see 404 errors on init, the instance may be legacy Wildfly;
# in that case set: base_path = "/auth"
}

data "google_client_config" "default" {
count = var.cloud_provider == "gke" ? 1 : 0
}
Expand Down Expand Up @@ -321,14 +347,23 @@ resource "helm_release" "grafana" {
grafana_admin_password = var.grafana_admin_password
ingress_class_name = var.ingress_class_name
cert_issuer_name = var.cert_issuer_name
# Keycloak OAuth2 — URL and realm for grafana.ini endpoint construction
keycloak_url = var.keycloak_url
keycloak_realm = var.keycloak_realm
# Client secret is read directly from the Keycloak Terraform resource
# (no manual copy-paste or separate secret management needed)
keycloak_client_secret = keycloak_openid_client.grafana.client_secret
})
]

depends_on = [
helm_release.prometheus,
helm_release.loki,
helm_release.mimir,
helm_release.tempo
helm_release.tempo,
# Keycloak client + roles + mapper must exist before Grafana starts
keycloak_openid_client.grafana,
keycloak_openid_user_realm_role_protocol_mapper.grafana_roles,
]

timeout = 600
Expand Down
Loading
Loading