[linstor-gui] Add Keycloak-protected Ingress for the UI (#2390)

## What this PR does

Stacked on top of #2382. Publishes the linstor-gui UI on
`https://linstor-gui.<root-host>` behind the cluster Keycloak realm
using the same oauth2-proxy gatekeeper pattern that the dashboard uses,
so the LINSTOR REST API is never exposed unauthenticated through
Ingress.

The Ingress is rendered only when **both** gates are satisfied:
- `_cluster.oidc-enabled == "true"`
- `linstor-gui` is listed in `_cluster.expose-services` (i.e. in
`publishing.exposedServices` in the core cozystack values)

If OIDC is disabled cluster-wide we intentionally do **not** ship an
unauthenticated ``token-proxy`` fallback — unlike dashboard, there is no
reason to front a raw storage-management API with k8s bearer tokens.
Operators can still reach the UI via \`kubectl port-forward\` to the
ClusterIP service.

## What's new

- \`templates/gatekeeper.yaml\` — oauth2-proxy Deployment (OIDC mode),
upstream \`linstor-gui.<ns>.svc:80\`
- \`templates/gatekeeper-svc.yaml\` — ClusterIP :8000 in front of it
- \`templates/gatekeeper-sa.yaml\` — dedicated SA, no auto-token
- \`templates/keycloakclient.yaml\` — persistent \`linstor-gui-client\`
+ \`linstor-gui-auth-config\` Secrets, \`KeycloakClient\` CRD that
auto-provisions the OIDC client with \`redirectUris:
[/oauth2/callback/*]\`
- \`templates/ingress.yaml\` — Ingress to the gatekeeper Service with
cert-manager ClusterIssuer, gated on expose-services + oidc-enabled
- \`tests/ingress_auth_test.yaml\` — unit tests for each conditional
branch, KeycloakClient rendering, oauth2-proxy args

README updated with Option 1 (Keycloak-protected Ingress) and Option 2
(port-forward).

### Release note

\`\`\`release-note
Added an opt-in Keycloak-protected Ingress for the linstor-gui package.
Add \`linstor-gui\` to \`publishing.exposedServices\` to publish
\`https://linstor-gui.<root-host>\` behind the cluster OIDC realm.
\`\`\`

## Test plan

- [ ] Deploy to dev10 with \`linstor-gui\` added to
\`publishing.exposedServices\`
- [ ] Verify cert-manager issues the TLS certificate
- [ ] Open \`https://linstor-gui.<root-host>\` in a browser, confirm
Keycloak login challenge
- [ ] After login, confirm LINSTOR node list loads via the nginx → mTLS
proxy
- [ ] Verify helm-unittest cases pass in CI
- [ ] Sanity-check that when \`oidc-enabled=false\` or the service is
not in \`expose-services\`, the Ingress + gatekeeper resources are
skipped
This commit is contained in:
myasnikovdaniil 2026-04-15 17:52:29 +05:00 committed by GitHub
commit cbc79600dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 545 additions and 4 deletions

View file

@ -186,6 +186,23 @@ spec:
---
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
# Allow the tenant's ingress-nginx to reach both the linstor-gui
# gatekeeper pods (for /oauth2/* and app traffic) AND the transient
# ACME http-01 solver pods that cert-manager spins up in the same
# namespace during certificate issuance/renewal. Matching the whole
# namespace mirrors allow-to-dashboard / allow-to-keycloak.
name: allow-to-linstor-gui
namespace: {{ include "tenant.name" . }}
spec:
endpointSelector: {}
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": cozy-linstor
---
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: allow-to-keycloak
namespace: {{ include "tenant.name" . }}

View file

@ -9,10 +9,28 @@ using mTLS with the `linstor-client-tls` secret created by the `linstor` package
## Exposing the UI
This package only creates a `ClusterIP` Service. It does **not** ship an ingress,
because authentication depends on the deployment's Keycloak / OIDC setup and
LINSTOR's controller API is a privileged cluster-wide storage management
surface. Cluster admins should wire up ingress + auth explicitly, for example:
### Option 1 — Keycloak-protected Ingress (recommended)
The chart ships an `oauth2-proxy` based gatekeeper plus a `KeycloakClient` CRD
so the UI can be published on `linstor-gui.<root-host>` behind the cluster
Keycloak realm. Access is granted to any user who can authenticate against the
`cozy` realm.
To turn it on, add `linstor-gui` to `publishing.exposedServices` in the core
`cozystack` values (same list that controls `dashboard`). OIDC must be
enabled (`authentication.oidc.enabled: true`) — if it is not, the Ingress and
gatekeeper Deployment are deliberately **not** rendered, because the LINSTOR
REST API surface must not be exposed unauthenticated.
Once enabled, the UI is reachable at `https://linstor-gui.<root-host>` and
authentication is delegated to Keycloak via the `linstor-gui` client
(auto-provisioned through the `KeycloakClient` CRD; the client secret is
persisted in the `linstor-gui-client` Secret in `cozy-linstor`).
### Option 2 — Port-forward
If you have not set up Keycloak or want ad-hoc access, use the `ClusterIP`
Service:
```bash
kubectl -n cozy-linstor port-forward svc/linstor-gui 3373:80

View file

@ -60,6 +60,38 @@ data:
try_files $uri $uri/ /index.html;
}
# Lock the linstor-gui in-app authentication panel.
#
# Unlike most LINSTOR features, the GUI's "authenticationEnabled"
# toggle and user list are NOT controller-side properties — they
# are persisted as ordinary KeyValueStore instances named
# `__gui__settings` and `__gui__users`. The SPA reads them on
# every page load and, if `authenticationEnabled=true`, gates the
# whole UI behind its own login screen against a self-managed
# admin user.
#
# Cozystack already enforces authentication at the Ingress
# (oauth2-proxy + Keycloak), and the in-app login is a foot-gun:
# if a user enables it and forgets/misconfigures the password, the
# only recovery is shelling into the linstor-controller pod and
# running `linstor key-value-store modify __gui__settings
# authenticationEnabled false`.
#
# We allow GETs (so the SPA can read state and render normally
# with auth=off) but reject every mutating method on those two
# KeyValueStore instances. Any other key-value-store entry the
# GUI uses (e.g. __gui__mode) keeps working as expected.
location ~ ^/v1/key-value-store/__gui__(settings|users)(/|$) {
default_type application/json;
# `if ... return` is the one documented-safe use of `if` in
# nginx (see "If is Evil"), so it's fine here. We allow GET/HEAD
# for the SPA's read path and reject every mutating method.
if ($request_method !~ ^(GET|HEAD)$) {
return 403 '{"ret_code":-1,"message":"linstor-gui in-app authentication is disabled by cozystack: authentication is enforced at the Ingress via Keycloak."}';
}
proxy_pass {{ .Values.linstor.endpoint }};
}
# Proxy LINSTOR REST API over mTLS to the controller
location /v1 {
proxy_pass {{ .Values.linstor.endpoint }};

View file

@ -0,0 +1,13 @@
{{- $oidcEnabled := index .Values._cluster "oidc-enabled" }}
{{- if eq $oidcEnabled "true" }}
apiVersion: v1
kind: ServiceAccount
metadata:
name: linstor-gui-gatekeeper
labels:
app.kubernetes.io/instance: linstor-gui
app.kubernetes.io/name: gatekeeper
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: cozystack
automountServiceAccountToken: false
{{- end }}

View file

@ -0,0 +1,22 @@
{{- $oidcEnabled := index .Values._cluster "oidc-enabled" }}
{{- if eq $oidcEnabled "true" }}
apiVersion: v1
kind: Service
metadata:
name: linstor-gui-gatekeeper
labels:
app.kubernetes.io/instance: linstor-gui
app.kubernetes.io/name: gatekeeper
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: cozystack
spec:
type: ClusterIP
ports:
- name: ingress
port: 8000
protocol: TCP
targetPort: 8000
selector:
app.kubernetes.io/instance: linstor-gui
app.kubernetes.io/name: gatekeeper
{{- end }}

View file

@ -0,0 +1,142 @@
{{- $host := index .Values._cluster "root-host" }}
{{- $oidcEnabled := index .Values._cluster "oidc-enabled" }}
{{- $oidcInsecureSkipVerify := index .Values._cluster "oidc-insecure-skip-verify" }}
{{- $keycloakInternalUrl := index .Values._cluster "keycloak-internal-url" | default "" }}
{{- /*
Gatekeeper (oauth2-proxy) fronts the linstor-gui Service and handles the
OIDC flow against the cluster Keycloak realm. The Ingress below points at
this Service — not at linstor-gui directly — so every external request
authenticates before hitting nginx/mTLS→controller.
Only rendered when oidc-enabled=true. If the cluster has OIDC disabled we
deliberately do not ship an unauthenticated token-proxy fallback (unlike
dashboard): linstor-gui surfaces raw storage state and should not be
reachable via Ingress without Keycloak login.
*/ -}}
{{- if eq $oidcEnabled "true" }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: linstor-gui-gatekeeper
labels:
app.kubernetes.io/instance: linstor-gui
app.kubernetes.io/name: gatekeeper
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: cozystack
annotations:
reloader.stakater.com/auto: "true"
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/instance: linstor-gui
app.kubernetes.io/name: gatekeeper
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app.kubernetes.io/instance: linstor-gui
app.kubernetes.io/name: gatekeeper
spec:
serviceAccountName: linstor-gui-gatekeeper
automountServiceAccountToken: false
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
app.kubernetes.io/instance: linstor-gui
app.kubernetes.io/name: gatekeeper
topologyKey: kubernetes.io/hostname
containers:
- name: auth-proxy
image: quay.io/oauth2-proxy/oauth2-proxy:v7.12.0
imagePullPolicy: IfNotPresent
args:
- --provider=oidc
- --upstream=http://linstor-gui.{{ .Release.Namespace }}.svc:80
- --http-address=0.0.0.0:8000
- --redirect-url=https://linstor-gui.{{ $host }}/oauth2/callback
- --oidc-issuer-url=https://keycloak.{{ $host }}/realms/cozy
{{- if $keycloakInternalUrl }}
- --skip-oidc-discovery
- --login-url=https://keycloak.{{ $host }}/realms/cozy/protocol/openid-connect/auth
- --redeem-url={{ $keycloakInternalUrl }}/protocol/openid-connect/token
- --oidc-jwks-url={{ $keycloakInternalUrl }}/protocol/openid-connect/certs
- --validate-url={{ $keycloakInternalUrl }}/protocol/openid-connect/userinfo
- --backend-logout-url={{ $keycloakInternalUrl }}/protocol/openid-connect/logout?id_token_hint={id_token}
{{- else }}
- --backend-logout-url=https://keycloak.{{ $host }}/realms/cozy/protocol/openid-connect/logout?id_token_hint={id_token}
{{- end }}
- --whitelist-domain=keycloak.{{ $host }}
- --email-domain=*
- --pass-access-token=true
- --pass-authorization-header=true
- --cookie-refresh=3m
- --cookie-name=kc-access
- --cookie-secure=true
- --cookie-secret=$(OAUTH2_PROXY_COOKIE_SECRET)
- --skip-provider-button
- --scope=openid email profile offline_access
{{- if eq $oidcInsecureSkipVerify "true" }}
- --ssl-insecure-skip-verify=true
{{- end }}
env:
- name: OAUTH2_PROXY_CLIENT_ID
value: linstor-gui
- name: OAUTH2_PROXY_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: linstor-gui-client
key: client-secret-key
- name: OAUTH2_PROXY_COOKIE_SECRET
valueFrom:
secretKeyRef:
name: linstor-gui-auth-config
key: cookieSecret
ports:
- name: proxy
containerPort: 8000
protocol: TCP
livenessProbe:
httpGet:
path: /ping
port: proxy
initialDelaySeconds: 10
periodSeconds: 15
timeoutSeconds: 2
failureThreshold: 3
readinessProbe:
httpGet:
path: /ping
port: proxy
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 256Mi
securityContext:
runAsNonRoot: true
runAsUser: 1001
runAsGroup: 1001
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
seccompProfile:
type: RuntimeDefault
{{- end }}

View file

@ -0,0 +1,54 @@
{{- $solver := (index .Values._cluster "solver") | default "http01" }}
{{- $clusterIssuer := (index .Values._cluster "issuer-name") | default "letsencrypt-prod" }}
{{- $host := index .Values._cluster "root-host" }}
{{- $oidcEnabled := index .Values._cluster "oidc-enabled" }}
{{- $exposeServices := splitList "," ((index .Values._cluster "expose-services") | default "") }}
{{- $exposeIngress := (index .Values._cluster "expose-ingress") | default "tenant-root" }}
{{- /*
Only publish an Ingress when the operator has opted this service in AND
OIDC is enabled cluster-wide. If OIDC is off we deliberately do not
expose linstor-gui: without Keycloak there is no authentication layer in
front of the LINSTOR REST API proxy. Users can still reach the UI via
`kubectl port-forward` to the ClusterIP service.
*/ -}}
{{- if and (has "linstor-gui" $exposeServices) (eq $oidcEnabled "true") }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: linstor-gui-ingress
labels:
{{- include "linstor-gui.labels" . | nindent 4 }}
annotations:
cert-manager.io/cluster-issuer: {{ $clusterIssuer }}
{{- if eq $solver "http01" }}
acme.cert-manager.io/http01-ingress-class: {{ $exposeIngress }}
{{- end }}
nginx.ingress.kubernetes.io/proxy-body-size: 100m
# Keycloak access+refresh+id tokens make the oauth2-proxy session
# cookie ~810 KB (split across multiple Set-Cookie headers), which
# overflows the default proxy_buffer_size and causes "upstream sent
# too big header" -> HTTP 502 on /oauth2/callback. Same numbers as
# dashboard ingress.
nginx.ingress.kubernetes.io/proxy-buffer-size: 100m
nginx.ingress.kubernetes.io/proxy-buffers-number: "4"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
spec:
ingressClassName: {{ $exposeIngress }}
rules:
- host: linstor-gui.{{ $host }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: linstor-gui-gatekeeper
port:
number: 8000
tls:
- hosts:
- linstor-gui.{{ $host }}
secretName: linstor-gui-ingress-tls
{{- end }}

View file

@ -0,0 +1,72 @@
{{- $host := index .Values._cluster "root-host" }}
{{- /*
Persist client + cookie secrets across upgrades: if the Secrets already
exist, reuse their values; otherwise generate new random ones. The
KeycloakClient CRD references the same client secret so Keycloak stays
in sync with what oauth2-proxy reads.
*/ -}}
{{- $existingClientSecret := lookup "v1" "Secret" .Release.Namespace "linstor-gui-client" }}
{{- $clientSecret := "" }}
{{- if $existingClientSecret }}
{{- $clientSecret = index $existingClientSecret.data "client-secret-key" | b64dec }}
{{- else }}
{{- $clientSecret = randAlphaNum 32 }}
{{- end }}
{{- $existingAuthConfig := lookup "v1" "Secret" .Release.Namespace "linstor-gui-auth-config" }}
{{- $cookieSecret := "" }}
{{- if $existingAuthConfig }}
{{- $cookieSecret = index $existingAuthConfig.data "cookieSecret" | b64dec }}
{{- else }}
{{- $cookieSecret = randAlphaNum 16 }}
{{- end }}
---
apiVersion: v1
kind: Secret
metadata:
name: linstor-gui-client
labels:
{{- include "linstor-gui.labels" . | nindent 4 }}
type: Opaque
data:
client-secret-key: {{ $clientSecret | b64enc }}
---
apiVersion: v1
kind: Secret
metadata:
name: linstor-gui-auth-config
labels:
{{- include "linstor-gui.labels" . | nindent 4 }}
type: Opaque
data:
cookieSecret: {{ $cookieSecret | b64enc }}
{{- if .Capabilities.APIVersions.Has "v1.edp.epam.com/v1" }}
---
apiVersion: v1.edp.epam.com/v1
kind: KeycloakClient
metadata:
name: linstor-gui-client
annotations:
# Re-reconcile the Keycloak client if the random secret regenerates.
secret-hash: {{ $clientSecret | sha256sum }}
spec:
realmRef:
name: keycloakrealm-cozy
kind: ClusterKeycloakRealm
secret: $linstor-gui-client:client-secret-key
advancedProtocolMappers: true
name: linstor-gui
clientId: linstor-gui
directAccess: true
public: false
webUrl: "https://linstor-gui.{{ $host }}"
defaultClientScopes:
- groups
optionalClientScopes:
- offline_access
attributes:
post.logout.redirect.uris: "+"
redirectUris:
- "https://linstor-gui.{{ $host }}/oauth2/callback/*"
{{- end }}

View file

@ -0,0 +1,171 @@
suite: linstor-gui ingress + keycloak auth
templates:
- templates/ingress.yaml
- templates/gatekeeper.yaml
- templates/gatekeeper-svc.yaml
- templates/gatekeeper-sa.yaml
- templates/keycloakclient.yaml
release:
name: linstor-gui
namespace: cozy-linstor
tests:
- it: does not render an Ingress when OIDC is disabled cluster-wide
template: templates/ingress.yaml
set:
_cluster:
root-host: example.org
oidc-enabled: "false"
expose-services: "linstor-gui"
expose-ingress: "tenant-root"
issuer-name: "letsencrypt-prod"
solver: "http01"
asserts:
- hasDocuments:
count: 0
- it: does not render an Ingress when linstor-gui is not in expose-services
template: templates/ingress.yaml
set:
_cluster:
root-host: example.org
oidc-enabled: "true"
expose-services: "dashboard,api"
expose-ingress: "tenant-root"
issuer-name: "letsencrypt-prod"
solver: "http01"
asserts:
- hasDocuments:
count: 0
- it: renders an Ingress to the gatekeeper service when exposed and OIDC enabled
template: templates/ingress.yaml
set:
_cluster:
root-host: dev10.example.org
oidc-enabled: "true"
expose-services: "dashboard,linstor-gui"
expose-ingress: "tenant-root"
issuer-name: "letsencrypt-prod"
solver: "http01"
asserts:
- isKind:
of: Ingress
- equal:
path: spec.ingressClassName
value: tenant-root
- equal:
path: spec.rules[0].host
value: linstor-gui.dev10.example.org
- equal:
path: spec.rules[0].http.paths[0].backend.service.name
value: linstor-gui-gatekeeper
- equal:
path: spec.rules[0].http.paths[0].backend.service.port.number
value: 8000
- equal:
path: metadata.annotations["cert-manager.io/cluster-issuer"]
value: letsencrypt-prod
- it: does not render the gatekeeper Deployment when OIDC is disabled
template: templates/gatekeeper.yaml
set:
_cluster:
root-host: example.org
oidc-enabled: "false"
expose-services: "linstor-gui"
asserts:
- hasDocuments:
count: 0
- it: renders an oauth2-proxy Deployment pointing at the cluster Keycloak realm
template: templates/gatekeeper.yaml
set:
_cluster:
root-host: dev10.example.org
oidc-enabled: "true"
oidc-insecure-skip-verify: "false"
keycloak-internal-url: ""
expose-services: "linstor-gui"
asserts:
- isKind:
of: Deployment
- equal:
path: metadata.name
value: linstor-gui-gatekeeper
- matchRegex:
path: spec.template.spec.containers[0].image
pattern: "^quay\\.io/oauth2-proxy/oauth2-proxy:"
- contains:
path: spec.template.spec.containers[0].args
content: --provider=oidc
- contains:
path: spec.template.spec.containers[0].args
content: --upstream=http://linstor-gui.cozy-linstor.svc:80
- contains:
path: spec.template.spec.containers[0].args
content: --redirect-url=https://linstor-gui.dev10.example.org/oauth2/callback
- contains:
path: spec.template.spec.containers[0].args
content: --oidc-issuer-url=https://keycloak.dev10.example.org/realms/cozy
- contains:
path: spec.template.spec.containers[0].env
content:
name: OAUTH2_PROXY_CLIENT_ID
value: linstor-gui
- equal:
path: spec.template.spec.containers[0].securityContext.readOnlyRootFilesystem
value: true
- it: creates client + cookie Secrets and a KeycloakClient CRD
template: templates/keycloakclient.yaml
capabilities:
apiVersions:
- v1.edp.epam.com/v1
set:
_cluster:
root-host: dev10.example.org
asserts:
- hasDocuments:
count: 3
- documentIndex: 0
isKind:
of: Secret
- documentIndex: 0
equal:
path: metadata.name
value: linstor-gui-client
- documentIndex: 1
isKind:
of: Secret
- documentIndex: 1
equal:
path: metadata.name
value: linstor-gui-auth-config
- documentIndex: 2
isKind:
of: KeycloakClient
- documentIndex: 2
equal:
path: spec.clientId
value: linstor-gui
- documentIndex: 2
contains:
path: spec.redirectUris
content: "https://linstor-gui.dev10.example.org/oauth2/callback/*"
- it: skips the KeycloakClient CRD when the API is absent, still creates Secrets
template: templates/keycloakclient.yaml
set:
_cluster:
root-host: example.org
asserts:
- hasDocuments:
count: 2
- documentIndex: 0
isKind:
of: Secret
- documentIndex: 1
isKind:
of: Secret