Skip to content

Commit f3656eb

Browse files
authored
Merge pull request #51 from ADORSYS-GIS/feature/integrate-keycloak-sso
Feature/integrate keycloak sso
2 parents 8b24583 + ff48339 commit f3656eb

File tree

6 files changed

+436
-5
lines changed

6 files changed

+436
-5
lines changed

.github/workflows/deploy-lgtm-gke.yaml

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ on:
2424
push:
2525
branches:
2626
- main
27-
- 25-setup-pipeline-for-deploying-terraform-scripts
27+
- feature/integrate-keycloak-sso
2828
paths:
2929
- 'lgtm-stack/terraform/**'
3030
- '.github/workflows/deploy-lgtm-gke.yaml'
@@ -37,6 +37,8 @@ permissions:
3737
contents: read
3838
pull-requests: write
3939
id-token: write
40+
actions: write
41+
4042

4143
env:
4244
TERRAFORM_VERSION: '1.6.0'
@@ -150,6 +152,13 @@ jobs:
150152
force_destroy = ${{ inputs.force_destroy || 'false' }}
151153
gke_endpoint = "${{ steps.cluster_info.outputs.endpoint }}"
152154
gke_ca_certificate = "${{ steps.cluster_info.outputs.ca_cert }}"
155+
keycloak_url = "${{ secrets.KEYCLOAK_URL }}"
156+
keycloak_realm = "${{ secrets.KEYCLOAK_REALM }}"
157+
keycloak_admin_user = "${{ secrets.KEYCLOAK_ADMIN_USER }}"
158+
keycloak_admin_password = "${{ secrets.KEYCLOAK_PASSWORD }}"
159+
grafana_keycloak_user = "${{ secrets.GRAFANA_KEYCLOAK_USER }}"
160+
grafana_keycloak_email = "${{ secrets.GRAFANA_KEYCLOAK_EMAIL }}"
161+
grafana_keycloak_password = "${{ secrets.GRAFANA_KEYCLOAK_PASSWORD }}"
153162
EOF
154163
155164
# Critical Step: Generate execution plan and capture exit code (0=no change, 2=changes)
@@ -193,13 +202,13 @@ jobs:
193202
194203
# Job 2: Terraform Apply
195204
# Execution job that applies the previously reviewed plan.
196-
# This job only runs on merges to main or manual approval.
205+
# This job only runs on merges to main, target branch, or manual approval.
197206
terraform-apply:
198207
name: Terraform Apply
199208
runs-on: ubuntu-latest
200209
needs: [terraform-plan]
201210
if: |
202-
(github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/25-setup-pipeline-for-deploying-terraform-scripts')) ||
211+
(github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/feature/integrate-keycloak-sso')) ||
203212
(github.event_name == 'workflow_dispatch' && github.event.inputs.terraform_action == 'apply')
204213
environment:
205214
name: production
@@ -267,6 +276,13 @@ jobs:
267276
force_destroy = ${{ inputs.force_destroy || 'false' }}
268277
gke_endpoint = "${{ steps.cluster_info.outputs.endpoint }}"
269278
gke_ca_certificate = "${{ steps.cluster_info.outputs.ca_cert }}"
279+
keycloak_url = "${{ secrets.KEYCLOAK_URL }}"
280+
keycloak_realm = "${{ secrets.KEYCLOAK_REALM }}"
281+
keycloak_admin_user = "${{ secrets.KEYCLOAK_ADMIN_USER }}"
282+
keycloak_admin_password = "${{ secrets.KEYCLOAK_PASSWORD }}"
283+
grafana_keycloak_user = "${{ secrets.GRAFANA_KEYCLOAK_USER }}"
284+
grafana_keycloak_email = "${{ secrets.GRAFANA_KEYCLOAK_EMAIL }}"
285+
grafana_keycloak_password = "${{ secrets.GRAFANA_KEYCLOAK_PASSWORD }}"
270286
EOF
271287
272288
# Step: Download the execution plan artifact

docs/keycloak-sso-integration.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Keycloak SSO Integration for LGTM Stack
2+
3+
## 1. Architectural Overview
4+
The LGTM stack (Loki, Grafana, Tempo, Mimir) has been configured to use **Keycloak** as the strict single source of truth for authentication and authorization.
5+
6+
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).
7+
8+
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.
9+
10+
## 2. Infrastructure as Code (Terraform)
11+
The entire Keycloak integration is automated via Terraform in `terraform/keycloak.tf` using the `mrparkers/keycloak` provider.
12+
13+
### The OIDC Client (`grafana-oauth`)
14+
A Confidential OpenID Connect client is created specifically for Grafana.
15+
- **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.
16+
17+
### Group & Role Provisioning
18+
Terraform automates the creation of three dedicated groups inside the target Keycloak realm:
19+
1. `grafana-admins`
20+
2. `grafana-editors`
21+
3. `grafana-viewers`
22+
23+
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.*
24+
25+
### Protocol Mappers (JWT Injection)
26+
To pass authorization data to Grafana, Terraform attaches two Protocol Mappers to the client:
27+
1. **Roles Mapper:** Injects the user's realm roles into the JWT token under the `roles` claim.
28+
2. **Groups Mapper:** Injects the user's group memberships into the JWT under the `groups` claim.
29+
30+
### Dedicated Admin User
31+
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.
32+
33+
## 3. Grafana Authentication Configuration
34+
Grafana's configuration (`terraform/values/grafana-values.yaml`) is hardened to enforce the Keycloak SSO policies.
35+
36+
### Strict Role Mapping
37+
Grafana evaluates the `roles` array inside the incoming JWT using JMESPath logic:
38+
```yaml
39+
role_attribute_path: "contains(roles[*], 'grafana-admin') && 'Admin' || contains(roles[*], 'grafana-editor') && 'Editor' || contains(roles[*], 'grafana-viewer') && 'Viewer'"
40+
```
41+
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.
42+
43+
### Group-Based Access Control (GBAC)
44+
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.
45+
46+
### Security Configurations
47+
- **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.
48+
- **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.
49+
50+
## 4. User Management Workflow (IMPORTANT)
51+
52+
> [!WARNING]
53+
> **Users CANNOT be invited or managed from within the Grafana UI.**
54+
55+
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.
56+
57+
**To grant a user access to Grafana:**
58+
1. A System Administrator must log into the Keycloak Admin Console.
59+
2. Navigate to the target Realm (e.g., `<realm>`).
60+
3. Create the user or locate an existing user.
61+
4. Navigate to the user's **Groups** tab.
62+
5. Join the user to either `grafana-admins`, `grafana-editors`, or `grafana-viewers`.
63+
64+
Upon their next login, Grafana will automatically sync the user and grant them the appropriate permissions.
65+
66+
## 5. Required CI/CD Secrets
67+
For this configuration to deploy successfully via GitHub Actions, the following secrets must be present in the repository:
68+
69+
1. `KEYCLOAK_URL` (e.g., `https://<keycloak-domain>/<realm>.com`)
70+
2. `KEYCLOAK_REALM` (e.g., `<realm>`)
71+
3. `KEYCLOAK_ADMIN_USER`
72+
4. `KEYCLOAK_ADMIN_PASSWORD`
73+
5. `GRAFANA_KEYCLOAK_USER` (The dedicated Grafana admin username)
74+
6. `GRAFANA_KEYCLOAK_EMAIL`
75+
7. `GRAFANA_KEYCLOAK_PASSWORD`

lgtm-stack/terraform/keycloak.tf

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
# ============================================================
2+
# Keycloak Terraform Configuration — Grafana SSO
3+
# ============================================================
4+
# This file automates the full Keycloak-side setup inside the
5+
# existing realm on <keycloak-domain> (shared with Auth/SSO).
6+
#
7+
# What it creates:
8+
# 1. OpenID Connect client: grafana-oauth
9+
# 2. Keycloak groups: grafana-admins, grafana-editors, grafana-viewers
10+
# 3. Realm roles: admin, editor, viewer (mapped to groups)
11+
# 4. Protocol mappers: realm roles + groups into JWT
12+
# 5. A dedicated Grafana admin user (separate from NetBird users)
13+
#
14+
# Access control:
15+
# - Only users in a grafana-* group can access Grafana
16+
# - Users NOT in any group are BLOCKED (strict mode)
17+
# - Group membership determines Grafana role (Admin/Editor/Viewer)
18+
# ============================================================
19+
20+
# ---- OpenID Connect Client -----------------------------------
21+
22+
resource "keycloak_openid_client" "grafana" {
23+
realm_id = var.keycloak_realm
24+
client_id = "grafana-oauth"
25+
name = "Grafana LGTM Monitoring"
26+
enabled = true
27+
28+
access_type = "CONFIDENTIAL"
29+
30+
standard_flow_enabled = true
31+
implicit_flow_enabled = false
32+
direct_access_grants_enabled = true
33+
34+
root_url = "https://grafana.${var.monitoring_domain}"
35+
base_url = "https://grafana.${var.monitoring_domain}"
36+
admin_url = "https://grafana.${var.monitoring_domain}"
37+
38+
valid_redirect_uris = [
39+
"https://grafana.${var.monitoring_domain}/login/generic_oauth",
40+
# Required for KC 18+ post-logout redirect to function correctly.
41+
# Must perfectly match the post_logout_redirect_uri parameter sent by Grafana.
42+
"https://grafana.${var.monitoring_domain}/login"
43+
]
44+
45+
web_origins = [
46+
"https://grafana.${var.monitoring_domain}"
47+
]
48+
}
49+
50+
# ---- Keycloak Groups -----------------------------------------
51+
# Groups provide clean access control in a shared realm.
52+
# Users of NetBird won't have Grafana access unless explicitly
53+
# added to one of these groups.
54+
#
55+
# grafana-admins → Grafana Admin (full control)
56+
# grafana-editors → Grafana Editor (create/edit dashboards)
57+
# grafana-viewers → Grafana Viewer (read-only)
58+
59+
resource "keycloak_group" "grafana_admins" {
60+
realm_id = var.keycloak_realm
61+
name = "grafana-admins"
62+
}
63+
64+
resource "keycloak_group" "grafana_editors" {
65+
realm_id = var.keycloak_realm
66+
name = "grafana-editors"
67+
}
68+
69+
resource "keycloak_group" "grafana_viewers" {
70+
realm_id = var.keycloak_realm
71+
name = "grafana-viewers"
72+
}
73+
74+
# ---- Realm Roles ---------------------------------------------
75+
76+
resource "keycloak_role" "grafana_admin" {
77+
realm_id = var.keycloak_realm
78+
name = "grafana-admin"
79+
description = "Grafana Admin — full access to dashboards and settings"
80+
}
81+
82+
resource "keycloak_role" "grafana_editor" {
83+
realm_id = var.keycloak_realm
84+
name = "grafana-editor"
85+
description = "Grafana Editor — can create and edit dashboards"
86+
}
87+
88+
resource "keycloak_role" "grafana_viewer" {
89+
realm_id = var.keycloak_realm
90+
name = "grafana-viewer"
91+
description = "Grafana Viewer — read-only access to dashboards"
92+
}
93+
94+
# ---- Group → Role Mappings -----------------------------------
95+
# Everyone in grafana-admins automatically gets the grafana-admin role.
96+
97+
resource "keycloak_group_roles" "grafana_admin_roles" {
98+
realm_id = var.keycloak_realm
99+
group_id = keycloak_group.grafana_admins.id
100+
role_ids = [keycloak_role.grafana_admin.id]
101+
}
102+
103+
resource "keycloak_group_roles" "grafana_editor_roles" {
104+
realm_id = var.keycloak_realm
105+
group_id = keycloak_group.grafana_editors.id
106+
role_ids = [keycloak_role.grafana_editor.id]
107+
}
108+
109+
resource "keycloak_group_roles" "grafana_viewer_roles" {
110+
realm_id = var.keycloak_realm
111+
group_id = keycloak_group.grafana_viewers.id
112+
role_ids = [keycloak_role.grafana_viewer.id]
113+
}
114+
115+
# ---- Protocol Mappers ----------------------------------------
116+
117+
# Mapper 1: Realm Roles → "roles" claim in JWT
118+
# Grafana uses this for role_attribute_path (Admin/Editor/Viewer mapping)
119+
resource "keycloak_openid_user_realm_role_protocol_mapper" "grafana_roles" {
120+
realm_id = var.keycloak_realm
121+
client_id = keycloak_openid_client.grafana.id
122+
name = "grafana-roles-mapper"
123+
124+
claim_name = "roles"
125+
multivalued = true
126+
add_to_id_token = true
127+
add_to_userinfo = true
128+
}
129+
130+
# Mapper 2: Group Membership → "groups" claim in JWT
131+
# Grafana uses this for allowed_groups (strict access control)
132+
resource "keycloak_openid_group_membership_protocol_mapper" "grafana_groups" {
133+
realm_id = var.keycloak_realm
134+
client_id = keycloak_openid_client.grafana.id
135+
name = "grafana-groups-mapper"
136+
137+
claim_name = "groups"
138+
full_path = false
139+
add_to_id_token = true
140+
add_to_userinfo = true
141+
}
142+
143+
# ---- Dedicated Grafana Admin User ----------------------------
144+
# A separate user from the NetBird admin user, to avoid
145+
# confusion and credential sharing between services.
146+
147+
resource "keycloak_user" "grafana_admin" {
148+
realm_id = var.keycloak_realm
149+
username = var.grafana_keycloak_user
150+
enabled = true
151+
152+
first_name = "Grafana"
153+
last_name = "Admin"
154+
email = var.grafana_keycloak_email
155+
156+
initial_password {
157+
value = var.grafana_keycloak_password
158+
temporary = false
159+
}
160+
}
161+
162+
# Add the dedicated user to the grafana-admins group
163+
resource "keycloak_user_groups" "grafana_admin_membership" {
164+
realm_id = var.keycloak_realm
165+
user_id = keycloak_user.grafana_admin.id
166+
167+
group_ids = [
168+
keycloak_group.grafana_admins.id
169+
]
170+
}

lgtm-stack/terraform/main.tf

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ terraform {
1313
source = "hashicorp/helm"
1414
version = "~> 2.12"
1515
}
16+
keycloak = {
17+
source = "mrparkers/keycloak"
18+
version = "~> 4.0"
19+
}
1620
}
1721

1822
# Production Best Practice: Store state remotely
@@ -55,6 +59,28 @@ provider "helm" {
5559
}
5660
}
5761

62+
# Keycloak Provider
63+
# ---------------------------------------------------------------
64+
# Authentication model: Password Grant via admin-cli
65+
# - The provider hits: <url>/realms/<realm>/protocol/openid-connect/token
66+
# - The admin user must have 'realm-admin' from 'realm-management'
67+
# client in the target realm. No master-realm/server-admin needed.
68+
#
69+
# KC 17+ Quarkus (this instance): NO base_path needed — the /auth
70+
# prefix was removed. Older Wildfly builds need base_path = "/auth".
71+
# ---------------------------------------------------------------
72+
provider "keycloak" {
73+
client_id = "admin-cli"
74+
username = var.keycloak_admin_user
75+
password = var.keycloak_admin_password
76+
url = var.keycloak_url # https://<keycloak-domain>
77+
realm = var.keycloak_realm
78+
79+
# base_path is NOT set — correct for Keycloak 17+ (Quarkus distribution)
80+
# If you see 404 errors on init, the instance may be legacy Wildfly;
81+
# in that case set: base_path = "/auth"
82+
}
83+
5884
data "google_client_config" "default" {
5985
count = var.cloud_provider == "gke" ? 1 : 0
6086
}
@@ -321,14 +347,23 @@ resource "helm_release" "grafana" {
321347
grafana_admin_password = var.grafana_admin_password
322348
ingress_class_name = var.ingress_class_name
323349
cert_issuer_name = var.cert_issuer_name
350+
# Keycloak OAuth2 — URL and realm for grafana.ini endpoint construction
351+
keycloak_url = var.keycloak_url
352+
keycloak_realm = var.keycloak_realm
353+
# Client secret is read directly from the Keycloak Terraform resource
354+
# (no manual copy-paste or separate secret management needed)
355+
keycloak_client_secret = keycloak_openid_client.grafana.client_secret
324356
})
325357
]
326358

327359
depends_on = [
328360
helm_release.prometheus,
329361
helm_release.loki,
330362
helm_release.mimir,
331-
helm_release.tempo
363+
helm_release.tempo,
364+
# Keycloak client + roles + mapper must exist before Grafana starts
365+
keycloak_openid_client.grafana,
366+
keycloak_openid_user_realm_role_protocol_mapper.grafana_roles,
332367
]
333368

334369
timeout = 600

0 commit comments

Comments
 (0)