diff --git a/packages/apps/tenant/templates/networkpolicy.yaml b/packages/apps/tenant/templates/networkpolicy.yaml index ce3e33ab..4bd3ba02 100644 --- a/packages/apps/tenant/templates/networkpolicy.yaml +++ b/packages/apps/tenant/templates/networkpolicy.yaml @@ -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" . }} diff --git a/packages/system/linstor-gui/README.md b/packages/system/linstor-gui/README.md index e4de2314..2c9ac5d5 100644 --- a/packages/system/linstor-gui/README.md +++ b/packages/system/linstor-gui/README.md @@ -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.` 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.` 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 diff --git a/packages/system/linstor-gui/templates/configmap-nginx.yaml b/packages/system/linstor-gui/templates/configmap-nginx.yaml index 0ba8e4fe..86acd0f7 100644 --- a/packages/system/linstor-gui/templates/configmap-nginx.yaml +++ b/packages/system/linstor-gui/templates/configmap-nginx.yaml @@ -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 }}; diff --git a/packages/system/linstor-gui/templates/gatekeeper-sa.yaml b/packages/system/linstor-gui/templates/gatekeeper-sa.yaml new file mode 100644 index 00000000..d89f68dc --- /dev/null +++ b/packages/system/linstor-gui/templates/gatekeeper-sa.yaml @@ -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 }} diff --git a/packages/system/linstor-gui/templates/gatekeeper-svc.yaml b/packages/system/linstor-gui/templates/gatekeeper-svc.yaml new file mode 100644 index 00000000..7d50219e --- /dev/null +++ b/packages/system/linstor-gui/templates/gatekeeper-svc.yaml @@ -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 }} diff --git a/packages/system/linstor-gui/templates/gatekeeper.yaml b/packages/system/linstor-gui/templates/gatekeeper.yaml new file mode 100644 index 00000000..0830a5fa --- /dev/null +++ b/packages/system/linstor-gui/templates/gatekeeper.yaml @@ -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 }} diff --git a/packages/system/linstor-gui/templates/ingress.yaml b/packages/system/linstor-gui/templates/ingress.yaml new file mode 100644 index 00000000..bf950eab --- /dev/null +++ b/packages/system/linstor-gui/templates/ingress.yaml @@ -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 ~8–10 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 }} diff --git a/packages/system/linstor-gui/templates/keycloakclient.yaml b/packages/system/linstor-gui/templates/keycloakclient.yaml new file mode 100644 index 00000000..e5dab87b --- /dev/null +++ b/packages/system/linstor-gui/templates/keycloakclient.yaml @@ -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 }} diff --git a/packages/system/linstor-gui/tests/ingress_auth_test.yaml b/packages/system/linstor-gui/tests/ingress_auth_test.yaml new file mode 100644 index 00000000..fe735f50 --- /dev/null +++ b/packages/system/linstor-gui/tests/ingress_auth_test.yaml @@ -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