From 1bf8993c4196915a79c97643cd0c671ea17ef114 Mon Sep 17 00:00:00 2001 From: Olivier Levitt Date: Fri, 23 Oct 2026 14:21:29 +0200 Subject: [PATCH 1/9] Mode service account in target namespace --- .../api/configuration/kubernetes/HelmClientProvider.java | 8 +++++++- .../kubernetes/KubernetesClientProvider.java | 4 +++- .../insee/onyxia/api/services/impl/HelmAppsService.java | 6 +++--- .../main/java/fr/insee/onyxia/model/region/Region.java | 3 ++- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/onyxia-api/src/main/java/fr/insee/onyxia/api/configuration/kubernetes/HelmClientProvider.java b/onyxia-api/src/main/java/fr/insee/onyxia/api/configuration/kubernetes/HelmClientProvider.java index 9eb34f73..a5177ea3 100644 --- a/onyxia-api/src/main/java/fr/insee/onyxia/api/configuration/kubernetes/HelmClientProvider.java +++ b/onyxia-api/src/main/java/fr/insee/onyxia/api/configuration/kubernetes/HelmClientProvider.java @@ -27,7 +27,7 @@ public HelmVersionService defaultHelmVersionService() { return new HelmVersionService(); } - public HelmConfiguration getConfiguration(Region region, User user) { + public HelmConfiguration getConfiguration(Region region, User user, String namespace) { final HelmConfiguration helmConfiguration = new HelmConfiguration(); if (region.getServices().getServer() != null) { final Region.Auth auth = region.getServices().getServer().getAuth(); @@ -47,6 +47,12 @@ public HelmConfiguration getConfiguration(Region region, User user) { helmConfiguration.setAsKubeUser(username); } + if (region.getServices().getAuthenticationMode() + == Region.Services.AuthenticationMode.SERVICEACCOUNTINTARGETNAMESPACE) { + helmConfiguration.setAsKubeUser( + String.format("system:serviceaccount:%s:onyxia-local", namespace)); + } + if (region.getServices().getAuthenticationMode() == Region.Services.AuthenticationMode.TOKEN_PASSTHROUGH) { helmConfiguration.setKubeToken((String) user.getAttributes().get("access_token")); diff --git a/onyxia-api/src/main/java/fr/insee/onyxia/api/configuration/kubernetes/KubernetesClientProvider.java b/onyxia-api/src/main/java/fr/insee/onyxia/api/configuration/kubernetes/KubernetesClientProvider.java index 085cc8b7..9cbc89c1 100644 --- a/onyxia-api/src/main/java/fr/insee/onyxia/api/configuration/kubernetes/KubernetesClientProvider.java +++ b/onyxia-api/src/main/java/fr/insee/onyxia/api/configuration/kubernetes/KubernetesClientProvider.java @@ -42,7 +42,9 @@ public KubernetesClient getUserClient(Region region, User user) { // In case of SERVICEACCOUNT authentication, we can safely mutualize and use a single // KubernetesClient if (region.getServices().getAuthenticationMode() - == Region.Services.AuthenticationMode.SERVICEACCOUNT) { + == Region.Services.AuthenticationMode.SERVICEACCOUNT + || region.getServices().getAuthenticationMode() + == Region.Services.AuthenticationMode.SERVICEACCOUNTINTARGETNAMESPACE) { if (userKubernetesClientCache != null) { return userKubernetesClientCache; } diff --git a/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/HelmAppsService.java b/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/HelmAppsService.java index d0e69d5a..1905f9c2 100644 --- a/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/HelmAppsService.java +++ b/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/HelmAppsService.java @@ -88,8 +88,8 @@ public HelmAppsService( this.helmReleaseHealthResolver = helmReleaseHealthResolver; } - private HelmConfiguration getHelmConfiguration(Region region, User user) { - return helmClientProvider.getConfiguration(region, user); + private HelmConfiguration getHelmConfiguration(Region region, User user, String namespace) { + return helmClientProvider.getConfiguration(region, user, namespace); } private HelmInstallService getHelmInstallService() { @@ -118,7 +118,7 @@ public Collection installApp( HelmInstaller res = getHelmInstallService() .installChart( - getHelmConfiguration(region, user), + getHelmConfiguration(region, user, namespaceId), catalogId + "/" + pkg.getName(), namespaceId, requestDTO.getName(), diff --git a/onyxia-model/src/main/java/fr/insee/onyxia/model/region/Region.java b/onyxia-model/src/main/java/fr/insee/onyxia/model/region/Region.java index 8fb6f6ed..a1660091 100644 --- a/onyxia-model/src/main/java/fr/insee/onyxia/model/region/Region.java +++ b/onyxia-model/src/main/java/fr/insee/onyxia/model/region/Region.java @@ -352,7 +352,8 @@ public static enum AuthenticationMode { @JsonProperty("serviceAccount") @JsonAlias("admin") SERVICEACCOUNT, - + @JsonProperty("serviceAccountInTargetNamespace") + SERVICEACCOUNTINTARGETNAMESPACE, @JsonProperty("tokenPassthrough") TOKEN_PASSTHROUGH } From 938e4fa7e283b076deb29de5d78f3c60b2043ab9 Mon Sep 17 00:00:00 2001 From: Olivier Levitt Date: Fri, 23 Oct 2026 14:30:05 +0200 Subject: [PATCH 2/9] Fix --- .../api/services/impl/HelmAppsService.java | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/HelmAppsService.java b/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/HelmAppsService.java index 1905f9c2..cc650d41 100644 --- a/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/HelmAppsService.java +++ b/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/HelmAppsService.java @@ -180,7 +180,8 @@ public CompletableFuture getUserServices( Arrays.asList( getHelmInstallService() .listChartInstall( - getHelmConfiguration(region, user), + getHelmConfiguration( + region, user, project.getNamespace()), project.getNamespace())); } catch (Exception e) { return CompletableFuture.completedFuture(new ServicesListing()); @@ -234,13 +235,14 @@ public Service getUserService(Region region, Project project, User user, String if (serviceId.startsWith("/")) { serviceId = serviceId.substring(1); } + String namespace = + kubernetesService.determineNamespaceAndCreateIfNeeded(region, project, user); HelmLs result = getHelmInstallService() .getAppById( - getHelmConfiguration(region, user), + getHelmConfiguration(region, user, namespace), serviceId, - kubernetesService.determineNamespaceAndCreateIfNeeded( - region, project, user)); + namespace); return getHelmApp(region, user, result); } @@ -257,14 +259,15 @@ public UninstallService destroyService( // If bulk in kub we ignore the path and delete every helm release HelmLs[] releases = getHelmInstallService() - .listChartInstall(getHelmConfiguration(region, user), namespace); + .listChartInstall( + getHelmConfiguration(region, user, namespace), namespace); for (HelmLs release : releases) { status = Math.max( 0, getHelmInstallService() .uninstaller( - getHelmConfiguration(region, user), + getHelmConfiguration(region, user, namespace), release.getName(), namespace)); UninstallServiceEvent uninstallServiceEvent = @@ -277,7 +280,9 @@ public UninstallService destroyService( status = getHelmInstallService() .uninstaller( - getHelmConfiguration(region, user), cannonicalPath, namespace); + getHelmConfiguration(region, user, namespace), + cannonicalPath, + namespace); UninstallServiceEvent uninstallServiceEvent = new UninstallServiceEvent(namespace, cannonicalPath, user.getIdep()); onyxiaEventPublisher.publishEvent(uninstallServiceEvent); @@ -290,7 +295,7 @@ private Service getHelmApp(Region region, User user, HelmLs release) { HelmReleaseInfo helmReleaseInfo = getHelmInstallService() .getAll( - getHelmConfiguration(region, user), + getHelmConfiguration(region, user, release.getNamespace()), release.getName(), release.getNamespace()); Service service = @@ -481,7 +486,7 @@ public void suspendOrResume( if (suspend) { getHelmInstallService() .suspend( - getHelmConfiguration(region, user), + getHelmConfiguration(region, user, namespaceId), catalogId + "/" + chartName, namespaceId, serviceId, @@ -493,7 +498,7 @@ public void suspendOrResume( } else { getHelmInstallService() .resume( - getHelmConfiguration(region, user), + getHelmConfiguration(region, user, namespaceId), catalogId + "/" + chartName, namespaceId, serviceId, From 54055f248edd45d1bc0513f3048827f74868a701 Mon Sep 17 00:00:00 2001 From: Olivier Levitt Date: Fri, 23 Oct 2026 15:12:57 +0200 Subject: [PATCH 3/9] Dirty fix --- .../java/io/github/inseefrlab/helmwrapper/utils/Command.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/helm-wrapper/src/main/java/io/github/inseefrlab/helmwrapper/utils/Command.java b/helm-wrapper/src/main/java/io/github/inseefrlab/helmwrapper/utils/Command.java index d0519caa..7f2d80d0 100644 --- a/helm-wrapper/src/main/java/io/github/inseefrlab/helmwrapper/utils/Command.java +++ b/helm-wrapper/src/main/java/io/github/inseefrlab/helmwrapper/utils/Command.java @@ -114,6 +114,11 @@ private static String addConfigToCommand(String command, HelmConfiguration helmC if (helmConfiguration.getAsKubeUser() != null) { newCommand = newCommand.concat(" --kube-as-user " + helmConfiguration.getAsKubeUser() + " "); + + // Temp fix for https://github.com/kubernetes/kubernetes/issues/93474 + newCommand = + newCommand.concat( + " --kube-ca-file /var/run/secrets/kubernetes.io/serviceaccount/ca.crt --kube-token \"$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)\" --kube-apiserver \"https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT}\" "); } String kubeConfig = null; if (StringUtils.isNotEmpty(helmConfiguration.getApiserverUrl())) { From e3d60fba9b1fe6d09edf9a8d1b8609b8a67ed9b9 Mon Sep 17 00:00:00 2001 From: Olivier Levitt Date: Fri, 23 Oct 2026 16:14:22 +0200 Subject: [PATCH 4/9] WIP : fix --- .../inseefrlab/helmwrapper/utils/Command.java | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/helm-wrapper/src/main/java/io/github/inseefrlab/helmwrapper/utils/Command.java b/helm-wrapper/src/main/java/io/github/inseefrlab/helmwrapper/utils/Command.java index 7f2d80d0..1f41f480 100644 --- a/helm-wrapper/src/main/java/io/github/inseefrlab/helmwrapper/utils/Command.java +++ b/helm-wrapper/src/main/java/io/github/inseefrlab/helmwrapper/utils/Command.java @@ -2,6 +2,8 @@ import io.github.inseefrlab.helmwrapper.configuration.HelmConfiguration; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.HashMap; import java.util.Map; import java.util.concurrent.TimeoutException; @@ -116,9 +118,27 @@ private static String addConfigToCommand(String command, HelmConfiguration helmC newCommand.concat(" --kube-as-user " + helmConfiguration.getAsKubeUser() + " "); // Temp fix for https://github.com/kubernetes/kubernetes/issues/93474 - newCommand = - newCommand.concat( - " --kube-ca-file /var/run/secrets/kubernetes.io/serviceaccount/ca.crt --kube-token \"$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)\" --kube-apiserver \"https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT}\" "); + try { + String token = + Files.readString( + Path.of( + "/var/run/secrets/kubernetes.io/serviceaccount/token")) + .trim(); + String apiserver = + "https://" + + System.getenv("KUBERNETES_SERVICE_HOST") + + ":" + + System.getenv("KUBERNETES_SERVICE_PORT"); + newCommand = + newCommand.concat( + " --kube-ca-file /var/run/secrets/kubernetes.io/serviceaccount/ca.crt --kube-token " + + token + + " --kube-apiserver " + + apiserver + + " "); + } catch (IOException e) { + LOGGER.debug("Could not retrieve in-cluster token"); + } } String kubeConfig = null; if (StringUtils.isNotEmpty(helmConfiguration.getApiserverUrl())) { From d3bb98d39bf91b05f1eaf14ae9dac6168b2f1bf1 Mon Sep 17 00:00:00 2001 From: Olivier Levitt Date: Fri, 23 Oct 2026 16:38:38 +0200 Subject: [PATCH 5/9] Fix token leak --- .../io/github/inseefrlab/helmwrapper/utils/Command.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/helm-wrapper/src/main/java/io/github/inseefrlab/helmwrapper/utils/Command.java b/helm-wrapper/src/main/java/io/github/inseefrlab/helmwrapper/utils/Command.java index 1f41f480..cb731cb1 100644 --- a/helm-wrapper/src/main/java/io/github/inseefrlab/helmwrapper/utils/Command.java +++ b/helm-wrapper/src/main/java/io/github/inseefrlab/helmwrapper/utils/Command.java @@ -32,7 +32,9 @@ private static ProcessExecutor getProcessExecutor() { new ProcessListener() { @Override public void afterStart(Process process, ProcessExecutor executor) { - process.info().commandLine().ifPresent(cli -> LOGGER.info(cli)); + process.info() + .commandLine() + .ifPresent(cli -> LOGGER.info(redactToken(cli))); super.afterStart(process, executor); } }); @@ -171,6 +173,10 @@ private static String addConfigToCommand(String command, HelmConfiguration helmC return newCommand; } + private static String redactToken(String commandLine) { + return commandLine.replaceAll("(--kube-token=)[^&\\s]+", "$1[REDACTED]"); + } + public static void safeConcat(StringBuilder currentCommand, String toConcat) throws IllegalArgumentException { if (!safeToConcatenate.matcher(toConcat).matches()) { From 55129877772e75f80d2f467c59994a4328436c97 Mon Sep 17 00:00:00 2001 From: Olivier Levitt Date: Fri, 23 Oct 2026 16:51:53 +0200 Subject: [PATCH 6/9] Fix & test redact token --- .../inseefrlab/helmwrapper/utils/Command.java | 6 +- .../helmwrapper/utils/RedactTokenTest.java | 62 +++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 helm-wrapper/src/test/java/io/github/inseefrlab/helmwrapper/utils/RedactTokenTest.java diff --git a/helm-wrapper/src/main/java/io/github/inseefrlab/helmwrapper/utils/Command.java b/helm-wrapper/src/main/java/io/github/inseefrlab/helmwrapper/utils/Command.java index cb731cb1..33369c67 100644 --- a/helm-wrapper/src/main/java/io/github/inseefrlab/helmwrapper/utils/Command.java +++ b/helm-wrapper/src/main/java/io/github/inseefrlab/helmwrapper/utils/Command.java @@ -133,9 +133,9 @@ private static String addConfigToCommand(String command, HelmConfiguration helmC + System.getenv("KUBERNETES_SERVICE_PORT"); newCommand = newCommand.concat( - " --kube-ca-file /var/run/secrets/kubernetes.io/serviceaccount/ca.crt --kube-token " + " --kube-ca-file=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt --kube-token=" + token - + " --kube-apiserver " + + " --kube-apiserver=" + apiserver + " "); } catch (IOException e) { @@ -174,7 +174,7 @@ private static String addConfigToCommand(String command, HelmConfiguration helmC } private static String redactToken(String commandLine) { - return commandLine.replaceAll("(--kube-token=)[^&\\s]+", "$1[REDACTED]"); + return commandLine.replaceAll("--kube-token[= ]+[^\\s]+", "--kube-token=[REDACTED]"); } public static void safeConcat(StringBuilder currentCommand, String toConcat) diff --git a/helm-wrapper/src/test/java/io/github/inseefrlab/helmwrapper/utils/RedactTokenTest.java b/helm-wrapper/src/test/java/io/github/inseefrlab/helmwrapper/utils/RedactTokenTest.java new file mode 100644 index 00000000..d7868c38 --- /dev/null +++ b/helm-wrapper/src/test/java/io/github/inseefrlab/helmwrapper/utils/RedactTokenTest.java @@ -0,0 +1,62 @@ +package io.github.inseefrlab.helmwrapper.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.lang.reflect.Method; +import org.junit.jupiter.api.Test; + +public class RedactTokenTest { + + private String invokeRedactToken(String commandLine) throws Exception { + Method method = Command.class.getDeclaredMethod("redactToken", String.class); + method.setAccessible(true); + return (String) method.invoke(null, commandLine); + } + + @Test + public void shouldRedactKubeTokenWithEquals() throws Exception { + String input = + "helm ls --kube-token=eyJhbGciOiJSUzI1NiIsImtpZCI6IiJ9.eyJpc3MiOiJrdWJlcm5ldGVzL3NlcnZpY2VhY2NvdW50Iiwia3ViZXJuZXRlcy5pby9zZXJ2aWNlYWNjb3VudC9uYW1lc3BhY2UiOiJkZWZhdWx0In0.test"; + String result = invokeRedactToken(input); + assertEquals("helm ls --kube-token=[REDACTED]", result); + } + + @Test + public void shouldRedactKubeTokenWithSpace() throws Exception { + String input = + "helm ls --kube-token eyJhbGciOiJSUzI1NiIsImtpZCI6IiJ9.eyJpc3MiOiJrdWJlcm5ldGVzL3NlcnZpY2VhY2NvdW50Iiwia3ViZXJuZXRlcy5pby9zZXJ2aWNlYWNjb3VudC9uYW1lc3BhY2UiOiJkZWZhdWx0In0.test"; + String result = invokeRedactToken(input); + assertEquals("helm ls --kube-token=[REDACTED]", result); + } + + @Test + public void shouldRedactMultipleKubeTokens() throws Exception { + String input = "helm ls --kube-token=token1 --kube-token=token2"; + String result = invokeRedactToken(input); + assertEquals("helm ls --kube-token=[REDACTED] --kube-token=[REDACTED]", result); + } + + @Test + public void shouldLeaveOtherOptionsUnchanged() throws Exception { + String input = + "helm ls --kubeconfig=/path/to/config --kube-apiserver=https://api.example.com"; + String result = invokeRedactToken(input); + assertEquals( + "helm ls --kubeconfig=/path/to/config --kube-apiserver=https://api.example.com", + result); + } + + @Test + public void shouldNotRedactIfNoToken() throws Exception { + String input = "helm ls --namespace default"; + String result = invokeRedactToken(input); + assertEquals("helm ls --namespace default", result); + } + + @Test + public void shouldRedactTokenWithTrailingOptions() throws Exception { + String input = "helm ls --kube-token=secret123 --namespace myns --output json"; + String result = invokeRedactToken(input); + assertEquals("helm ls --kube-token=[REDACTED] --namespace myns --output json", result); + } +} From 2560e51c8b059cb877d1da34b119e0ed17bc8634 Mon Sep 17 00:00:00 2001 From: Dylan Decrulle <81740200+ddecrulle@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:41:03 +0100 Subject: [PATCH 7/9] Impersonate the target namespace service account for every Kubernetes call In serviceAccountInTargetNamespace mode, the fabric8 client now also impersonates the namespace service account (logs, events, Onyxia secrets, pods, workload health, namespace check and quota), not only Helm. The API service account only needs the impersonate verb on that service account. - the service account name is configurable per region with services.targetNamespaceServiceAccount (default onyxia-local) - namespace creation by the API is deprecated, and disabled in this mode: namespaces must be created by the onboarding API Co-Authored-By: Claude Opus 5.5 --- docs/region-configuration.md | 5 +- .../kubernetes/HelmClientProvider.java | 3 +- .../kubernetes/KubernetesClientProvider.java | 36 +++++++++-- .../properties/RegionsConfiguration.java | 32 ++++++++++ .../api/onboarding/OnboardingController.java | 8 ++- .../api/services/impl/HelmAppsService.java | 30 ++++----- .../impl/kubernetes/KubernetesService.java | 41 ++++++++++-- .../KubernetesClientProviderTest.java | 62 +++++++++++++++++++ .../fr/insee/onyxia/model/region/Region.java | 9 +++ 9 files changed, 197 insertions(+), 29 deletions(-) create mode 100644 onyxia-api/src/test/java/fr/insee/onyxia/api/configuration/kubernetes/KubernetesClientProviderTest.java diff --git a/docs/region-configuration.md b/docs/region-configuration.md index 8343e969..e367c687 100644 --- a/docs/region-configuration.md +++ b/docs/region-configuration.md @@ -50,7 +50,7 @@ Users can work on Onyxia as a User or as a Group to which they belong. Each user | Key | Default | Description | Example | |-------------------------------| ------- |--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------| | `type` | | Type of the platform on which services are launched. Only Kubernetes is supported, Marathon has been removed. | "KUBERNETES" | -| `allowNamespaceCreation` | true | If true, the /onboarding endpoint is enabled and the user will have a namespace created on its first request on a service resource. | true | +| `allowNamespaceCreation` | true | **Deprecated**, use the onboarding API instead. If true, the /onboarding endpoint is enabled and the user will have a namespace created on its first request on a service resource. Ignored (forced to false) with `serviceAccountInTargetNamespace` authentication. | true | | `namespaceLabels` | | Static labels to add to the namespace (at creation and subsequent user logins) | {"zone":"prod"} | | `namespaceAnnotations` | | Static annotations to add to the namespace (at creation and subsequent user logins) | {"zone":"prod"} | | `namespaceAnnotationsDynamic` | | Dynamic annotations (currently only based on user JWT token) to add to the namespace (at creation and subsequent user logins). Annotations names will be prefixed with `onyxia_`. `onyxia_last_login_timestamp` is also added. | {"enabled": true, "userAttributes": ["sub", "email"] } | @@ -60,7 +60,8 @@ Users can work on Onyxia as a User or as a Group to which they belong. Each user | `groupNamespacePrefix` | "projet-" | User in a group groupId can access the namespace groupeNamespacePrefix + groupId. This prefix is also used for the Vault group directory. | | | `usernamePrefix` | | If set, the Kubernetes user corresponding to the Onyxia user is named usernamePrefix + userId on impersonation mode, otherwise it is identified only as userId | "user-" | | `groupPrefix` | | not used | | -| `authenticationMode` | serviceAccount | serviceAccount, impersonate or tokenPassthrough : on serviceAccount mode Onyxia API uses its own serviceAccount (by default admin or cluster-admin), with impersonate mode Onyxia requests the API with user's permissions (helm option `--kube-as-user`). With tokenPassthrough, the authentication token is passed to the API server. | | +| `authenticationMode` | serviceAccount | serviceAccount, serviceAccountInTargetNamespace, impersonate or tokenPassthrough : on serviceAccount mode Onyxia API uses its own serviceAccount (by default admin or cluster-admin), with serviceAccountInTargetNamespace mode Onyxia impersonates the service account named `targetNamespaceServiceAccount` created by the onboarding API in each namespace (its own serviceAccount only needs the `impersonate` verb on `serviceaccounts` with that name), with impersonate mode Onyxia requests the API with user's permissions (helm option `--kube-as-user`). With tokenPassthrough, the authentication token is passed to the API server. | | +| `targetNamespaceServiceAccount` | onyxia-local | Name of the service account, created by the onboarding API in each namespace, impersonated with serviceAccountInTargetNamespace authentication. | | | `expose` | | When users request to expose their service, only subdomain of this object domain are allowed | See [Expose properties](#expose-properties) | | `monitoring` | | Define the URL pattern of the monitoring service that is to be launched with each service. Only for client purposes. | {URLPattern: "https://$NAMESPACE-$INSTANCE.mymonitoring.sspcloud.fr"} | | | `allowedURIPattern` | "^https://" | Init scripts set by the user have to respect this pattern. | | diff --git a/onyxia-api/src/main/java/fr/insee/onyxia/api/configuration/kubernetes/HelmClientProvider.java b/onyxia-api/src/main/java/fr/insee/onyxia/api/configuration/kubernetes/HelmClientProvider.java index a5177ea3..b00c0d7d 100644 --- a/onyxia-api/src/main/java/fr/insee/onyxia/api/configuration/kubernetes/HelmClientProvider.java +++ b/onyxia-api/src/main/java/fr/insee/onyxia/api/configuration/kubernetes/HelmClientProvider.java @@ -50,7 +50,8 @@ public HelmConfiguration getConfiguration(Region region, User user, String names if (region.getServices().getAuthenticationMode() == Region.Services.AuthenticationMode.SERVICEACCOUNTINTARGETNAMESPACE) { helmConfiguration.setAsKubeUser( - String.format("system:serviceaccount:%s:onyxia-local", namespace)); + KubernetesClientProvider.getTargetNamespaceServiceAccountUsername( + region, namespace)); } if (region.getServices().getAuthenticationMode() diff --git a/onyxia-api/src/main/java/fr/insee/onyxia/api/configuration/kubernetes/KubernetesClientProvider.java b/onyxia-api/src/main/java/fr/insee/onyxia/api/configuration/kubernetes/KubernetesClientProvider.java index 9cbc89c1..4fd1df5f 100644 --- a/onyxia-api/src/main/java/fr/insee/onyxia/api/configuration/kubernetes/KubernetesClientProvider.java +++ b/onyxia-api/src/main/java/fr/insee/onyxia/api/configuration/kubernetes/KubernetesClientProvider.java @@ -38,13 +38,27 @@ public synchronized KubernetesClient getRootClient(Region region) { return rootKubernetesClientCache.get(region.getId()); } - public KubernetesClient getUserClient(Region region, User user) { + /** + * Username of the service account created by the onboarding in the given namespace, + * impersonated in SERVICEACCOUNTINTARGETNAMESPACE mode. + */ + public static String getTargetNamespaceServiceAccountUsername(Region region, String namespace) { + return String.format( + "system:serviceaccount:%s:%s", + namespace, region.getServices().getTargetNamespaceServiceAccount()); + } + + /** + * Client to act on the given namespace on behalf of the user. + * + * @param namespace the namespace the client will act on, used to impersonate its service + * account in SERVICEACCOUNTINTARGETNAMESPACE mode + */ + public KubernetesClient getUserClient(Region region, User user, String namespace) { // In case of SERVICEACCOUNT authentication, we can safely mutualize and use a single // KubernetesClient if (region.getServices().getAuthenticationMode() - == Region.Services.AuthenticationMode.SERVICEACCOUNT - || region.getServices().getAuthenticationMode() - == Region.Services.AuthenticationMode.SERVICEACCOUNTINTARGETNAMESPACE) { + == Region.Services.AuthenticationMode.SERVICEACCOUNT) { if (userKubernetesClientCache != null) { return userKubernetesClientCache; } @@ -54,6 +68,20 @@ public KubernetesClient getUserClient(Region region, User user) { return client; } final Config config = getDefaultConfiguration(region).build(); + + if (region.getServices().getAuthenticationMode() + == Region.Services.AuthenticationMode.SERVICEACCOUNTINTARGETNAMESPACE) { + if (StringUtils.isEmpty(namespace)) { + throw new IllegalArgumentException( + "A namespace is required to impersonate its service account"); + } + config.setImpersonateUsername( + getTargetNamespaceServiceAccountUsername(region, namespace)); + // Without explicit groups, the API server adds the service account groups itself + config.setImpersonateGroups(null); + return new KubernetesClientBuilder().withConfig(config).build(); + } + String username = user.getIdep(); if (region.getServices().getUsernamePrefix() != null) { username = region.getServices().getUsernamePrefix() + user.getIdep(); diff --git a/onyxia-api/src/main/java/fr/insee/onyxia/api/configuration/properties/RegionsConfiguration.java b/onyxia-api/src/main/java/fr/insee/onyxia/api/configuration/properties/RegionsConfiguration.java index d9222d78..f47dbda5 100644 --- a/onyxia-api/src/main/java/fr/insee/onyxia/api/configuration/properties/RegionsConfiguration.java +++ b/onyxia-api/src/main/java/fr/insee/onyxia/api/configuration/properties/RegionsConfiguration.java @@ -9,6 +9,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -66,6 +67,37 @@ public void load() throws Exception { "Using token passthrough authentication for region {}. User token will be used by Onyxia to interact with the API Server.", region.getId()); } + + if (region.getServices() + .getAuthenticationMode() + .equals( + Region.Services.AuthenticationMode + .SERVICEACCOUNTINTARGETNAMESPACE)) { + if (StringUtils.isBlank( + region.getServices().getTargetNamespaceServiceAccount())) { + throw new IllegalStateException( + "services.targetNamespaceServiceAccount is required with serviceAccountInTargetNamespace authentication for region " + + region.getId()); + } + LOGGER.info( + "Using the {} service account of each namespace for region {}.", + region.getServices().getTargetNamespaceServiceAccount(), + region.getId()); + if (!region.getServices().isSingleNamespace() + && region.getServices().isAllowNamespaceCreation()) { + // Namespaces and their service account are created by the onboarding + // API, the API has no permission to do it in this mode + LOGGER.warn( + "Namespace creation is disabled for region {}: the onboarding API must be used with serviceAccountInTargetNamespace authentication.", + region.getId()); + region.getServices().setAllowNamespaceCreation(false); + } + } else if (!region.getServices().isSingleNamespace() + && region.getServices().isAllowNamespaceCreation()) { + LOGGER.warn( + "Namespace creation by Onyxia API (allowNamespaceCreation) is deprecated for region {} and will be removed, use the onboarding API instead.", + region.getId()); + } }); } diff --git a/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/api/onboarding/OnboardingController.java b/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/api/onboarding/OnboardingController.java index ae615121..4b16caf1 100644 --- a/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/api/onboarding/OnboardingController.java +++ b/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/api/onboarding/OnboardingController.java @@ -35,10 +35,16 @@ public OnboardingController(KubernetesService kubernetesService, UserProvider us this.userProvider = userProvider; } + /** + * @deprecated namespaces should be created by the onboarding API. Disabled with + * serviceAccountInTargetNamespace authentication. + */ + @Deprecated @Operation( summary = "Init a namespace for a user or a group.", description = - "create or replace the namespace of the user or the namespace of a group if the user is in the requested group and the according rbac policies. with the group prefix / user prefix of the region", + "Deprecated: use the onboarding API instead. create or replace the namespace of the user or the namespace of a group if the user is in the requested group and the according rbac policies. with the group prefix / user prefix of the region", + deprecated = true, parameters = { @Parameter( name = "ONYXIA-REGION", diff --git a/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/HelmAppsService.java b/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/HelmAppsService.java index cc650d41..b04e1c42 100644 --- a/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/HelmAppsService.java +++ b/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/HelmAppsService.java @@ -205,13 +205,10 @@ public CompletableFuture getUserServices( @Override public String getLogs( Region region, Project project, User user, String serviceId, String taskId) { - KubernetesClient client = kubernetesClientProvider.getUserClient(region, user); - return client.pods() - .inNamespace( - kubernetesService.determineNamespaceAndCreateIfNeeded( - region, project, user)) - .withName(taskId) - .getLog(); + String namespace = + kubernetesService.determineNamespaceAndCreateIfNeeded(region, project, user); + KubernetesClient client = kubernetesClientProvider.getUserClient(region, user, namespace); + return client.pods().inNamespace(namespace).withName(taskId).getLog(); } @Override @@ -220,13 +217,10 @@ public Watch getEvents( Project project, User user, Watcher watcher) { - KubernetesClient client = kubernetesClientProvider.getUserClient(region, user); - return client.v1() - .events() - .inNamespace( - kubernetesService.determineNamespaceAndCreateIfNeeded( - region, project, user)) - .watch(watcher); + String namespace = + kubernetesService.determineNamespaceAndCreateIfNeeded(region, project, user); + KubernetesClient client = kubernetesClientProvider.getUserClient(region, user, namespace); + return client.v1().events().inNamespace(namespace).watch(watcher); } @Override @@ -306,7 +300,8 @@ private Service getHelmApp(Region region, User user, HelmLs release) { service.setStartedAt(0); } try { - KubernetesClient client = kubernetesClientProvider.getUserClient(region, user); + KubernetesClient client = + kubernetesClientProvider.getUserClient(region, user, release.getNamespace()); Secret secret = client.secrets() .inNamespace(release.getNamespace()) @@ -382,7 +377,7 @@ private void patchOnyxiaSecret( Region region, Project project, User user, String serviceId, Map data) { String namespaceId = kubernetesService.determineNamespaceAndCreateIfNeeded(region, project, user); - KubernetesClient client = kubernetesClientProvider.getUserClient(region, user); + KubernetesClient client = kubernetesClientProvider.getUserClient(region, user, namespaceId); Secret secret = client.secrets() .inNamespace(namespaceId) @@ -530,7 +525,8 @@ private void mapAppender( private Service getServiceFromRelease( Region region, HelmLs release, String manifest, User user) { - KubernetesClient client = kubernetesClientProvider.getUserClient(region, user); + KubernetesClient client = + kubernetesClientProvider.getUserClient(region, user, release.getNamespace()); Service service = new Service(); diff --git a/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/kubernetes/KubernetesService.java b/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/kubernetes/KubernetesService.java index 6790af42..22b63476 100644 --- a/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/kubernetes/KubernetesService.java +++ b/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/kubernetes/KubernetesService.java @@ -55,7 +55,7 @@ public String determineNamespaceAndCreateIfNeeded(Region region, Project project if (StringUtils.isEmpty(project.getNamespace())) { throw new NamespaceNotFoundException(); } - if (!isNamespaceAlreadyExisting(region, project.getNamespace())) { + if (!isNamespaceAlreadyExisting(region, user, project.getNamespace())) { if (!region.getServices().isAllowNamespaceCreation()) { throw new NamespaceNotFoundException(); } else { @@ -168,7 +168,24 @@ private String createNamespace(Region region, String namespaceId, Owner owner, U return namespaceId; } - private boolean isNamespaceAlreadyExisting(Region region, String namespaceId) { + private boolean isNamespaceAlreadyExisting(Region region, User user, String namespaceId) { + if (isTargetNamespaceServiceAccountMode(region)) { + // The namespace service account can read its own namespace. If the namespace does not + // exist or has not been onboarded yet, it has no permission there: 403, not 404. + try { + return kubernetesClientProvider + .getUserClient(region, user, namespaceId) + .namespaces() + .withName(namespaceId) + .get() + != null; + } catch (KubernetesClientException e) { + if (e.getCode() == 403) { + return false; + } + throw e; + } + } return kubernetesClientProvider .getRootClient(region) .namespaces() @@ -177,6 +194,22 @@ private boolean isNamespaceAlreadyExisting(Region region, String namespaceId) { != null; } + private boolean isTargetNamespaceServiceAccountMode(Region region) { + return region.getServices().getAuthenticationMode() + == Region.Services.AuthenticationMode.SERVICEACCOUNTINTARGETNAMESPACE; + } + + /** + * Client for calls on a user namespace that do not depend on the user permissions: the + * namespace service account in SERVICEACCOUNTINTARGETNAMESPACE mode, the root client otherwise. + */ + private KubernetesClient getNamespaceClient(Region region, User user, String namespace) { + if (isTargetNamespaceServiceAccountMode(region)) { + return kubernetesClientProvider.getUserClient(region, user, namespace); + } + return kubernetesClientProvider.getRootClient(region); + } + private void applyQuotas( String namespaceId, KubernetesClient kubClient, @@ -286,14 +319,14 @@ private String getDefaultNamespace(Region region, Owner owner) { } public void applyQuota(Region region, Project project, User user, Quota quota) { - final KubernetesClient kubClient = kubernetesClientProvider.getRootClient(region); final String namespace = determineNamespaceAndCreateIfNeeded(region, project, user); + final KubernetesClient kubClient = getNamespaceClient(region, user, namespace); applyQuotas(namespace, kubClient, quota, true); } public ResourceQuota getOnyxiaQuota(Region region, Project project, User user) { - final KubernetesClient kubClient = kubernetesClientProvider.getRootClient(region); final String namespace = determineNamespaceAndCreateIfNeeded(region, project, user); + final KubernetesClient kubClient = getNamespaceClient(region, user, namespace); return kubClient.resourceQuotas().inNamespace(namespace).withName(ONYXIA_QUOTA).get(); } diff --git a/onyxia-api/src/test/java/fr/insee/onyxia/api/configuration/kubernetes/KubernetesClientProviderTest.java b/onyxia-api/src/test/java/fr/insee/onyxia/api/configuration/kubernetes/KubernetesClientProviderTest.java new file mode 100644 index 00000000..8ff4e39c --- /dev/null +++ b/onyxia-api/src/test/java/fr/insee/onyxia/api/configuration/kubernetes/KubernetesClientProviderTest.java @@ -0,0 +1,62 @@ +package fr.insee.onyxia.api.configuration.kubernetes; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import fr.insee.onyxia.model.User; +import fr.insee.onyxia.model.region.Region; +import io.fabric8.kubernetes.client.KubernetesClient; +import org.junit.jupiter.api.Test; + +class KubernetesClientProviderTest { + + private final KubernetesClientProvider provider = new KubernetesClientProvider(); + + private Region region(Region.Services.AuthenticationMode authenticationMode) { + Region region = new Region(); + region.setId("test"); + region.getServices().setAuthenticationMode(authenticationMode); + return region; + } + + @Test + void shouldImpersonateTheTargetNamespaceServiceAccount() { + Region region = region(Region.Services.AuthenticationMode.SERVICEACCOUNTINTARGETNAMESPACE); + + try (KubernetesClient client = provider.getUserClient(region, new User(), "user-jdoe")) { + assertEquals( + "system:serviceaccount:user-jdoe:onyxia-local", + client.getConfiguration().getImpersonateUsername()); + } + } + + @Test + void shouldImpersonateTheConfiguredServiceAccount() { + Region region = region(Region.Services.AuthenticationMode.SERVICEACCOUNTINTARGETNAMESPACE); + region.getServices().setTargetNamespaceServiceAccount("onyxia-installer"); + + try (KubernetesClient client = provider.getUserClient(region, new User(), "user-jdoe")) { + assertEquals( + "system:serviceaccount:user-jdoe:onyxia-installer", + client.getConfiguration().getImpersonateUsername()); + } + } + + @Test + void shouldRequireANamespaceToImpersonateItsServiceAccount() { + Region region = region(Region.Services.AuthenticationMode.SERVICEACCOUNTINTARGETNAMESPACE); + + assertThrows( + IllegalArgumentException.class, + () -> provider.getUserClient(region, new User(), null)); + } + + @Test + void shouldNotImpersonateInServiceAccountMode() { + Region region = region(Region.Services.AuthenticationMode.SERVICEACCOUNT); + + KubernetesClient client = provider.getUserClient(region, new User(), "user-jdoe"); + assertNull(client.getConfiguration().getImpersonateUsername()); + } +} diff --git a/onyxia-model/src/main/java/fr/insee/onyxia/model/region/Region.java b/onyxia-model/src/main/java/fr/insee/onyxia/model/region/Region.java index a1660091..397c0edb 100644 --- a/onyxia-model/src/main/java/fr/insee/onyxia/model/region/Region.java +++ b/onyxia-model/src/main/java/fr/insee/onyxia/model/region/Region.java @@ -183,6 +183,7 @@ public static class Services { private String usernamePrefix; private String groupPrefix; private AuthenticationMode authenticationMode = AuthenticationMode.SERVICEACCOUNT; + private String targetNamespaceServiceAccount = "onyxia-local"; private Expose expose; private Server server; private Monitoring monitoring; @@ -314,6 +315,14 @@ public void setAuthenticationMode(AuthenticationMode authenticationMode) { this.authenticationMode = authenticationMode; } + public String getTargetNamespaceServiceAccount() { + return targetNamespaceServiceAccount; + } + + public void setTargetNamespaceServiceAccount(String targetNamespaceServiceAccount) { + this.targetNamespaceServiceAccount = targetNamespaceServiceAccount; + } + public String getGroupNamespacePrefix() { return groupNamespacePrefix; } From 54d92f1da5e33a0261be2e8152330a8cab4806b8 Mon Sep 17 00:00:00 2001 From: Dylan Decrulle <81740200+ddecrulle@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:04:30 +0100 Subject: [PATCH 8/9] Create the Onyxia release secret with the namespace client In serviceAccountInTargetNamespace mode, the secret is now created by impersonating the namespace service account, like every other call. Co-Authored-By: Claude Opus 5.5 --- .../insee/onyxia/api/services/impl/HelmAppsService.java | 4 ++-- .../api/services/impl/kubernetes/KubernetesService.java | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/HelmAppsService.java b/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/HelmAppsService.java index b04e1c42..0af7584b 100644 --- a/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/HelmAppsService.java +++ b/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/HelmAppsService.java @@ -146,7 +146,7 @@ public Collection installApp( } metadata.put(SHARE, Base64Utils.base64Encode(String.valueOf(requestDTO.isShare()))); kubernetesService.createOnyxiaSecret( - region, namespaceId, requestDTO.getName(), metadata); + region, user, namespaceId, requestDTO.getName(), metadata); return List.of(res.getManifest()); } catch (IllegalArgumentException e) { throw new AccessDeniedException(e.getMessage()); @@ -400,7 +400,7 @@ private void patchOnyxiaSecret( Map metadata = new HashMap<>(); metadata.put(OWNER, user.getIdep()); metadata.putAll(data); - kubernetesService.createOnyxiaSecret(region, namespaceId, serviceId, metadata); + kubernetesService.createOnyxiaSecret(region, user, namespaceId, serviceId, metadata); } } diff --git a/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/kubernetes/KubernetesService.java b/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/kubernetes/KubernetesService.java index 22b63476..8a6366fd 100644 --- a/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/kubernetes/KubernetesService.java +++ b/onyxia-api/src/main/java/fr/insee/onyxia/api/services/impl/kubernetes/KubernetesService.java @@ -262,9 +262,13 @@ private void applyQuotas( } public void createOnyxiaSecret( - Region region, String namespaceId, String releaseName, Map secretData) { + Region region, + User user, + String namespaceId, + String releaseName, + Map secretData) { - final KubernetesClient kubClient = kubernetesClientProvider.getRootClient(region); + final KubernetesClient kubClient = getNamespaceClient(region, user, namespaceId); String ownerSecretName = "sh.helm.release.v1." + releaseName + ".v1"; // Name of the Secret managed by Helm From 1cfe5c38f8f3f928f2caade5836649a6b8dcd62d Mon Sep 17 00:00:00 2001 From: Olivier Levitt Date: Wed, 4 Nov 2026 09:48:52 +0100 Subject: [PATCH 9/9] Region configuration --- docs/region-configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/region-configuration.md b/docs/region-configuration.md index e367c687..c97f5ea3 100644 --- a/docs/region-configuration.md +++ b/docs/region-configuration.md @@ -50,7 +50,7 @@ Users can work on Onyxia as a User or as a Group to which they belong. Each user | Key | Default | Description | Example | |-------------------------------| ------- |--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------| | `type` | | Type of the platform on which services are launched. Only Kubernetes is supported, Marathon has been removed. | "KUBERNETES" | -| `allowNamespaceCreation` | true | **Deprecated**, use the onboarding API instead. If true, the /onboarding endpoint is enabled and the user will have a namespace created on its first request on a service resource. Ignored (forced to false) with `serviceAccountInTargetNamespace` authentication. | true | +| `allowNamespaceCreation` | true | If true, the /onboarding endpoint is enabled and the user will have a namespace created on its first request on a service resource. | true | | `namespaceLabels` | | Static labels to add to the namespace (at creation and subsequent user logins) | {"zone":"prod"} | | `namespaceAnnotations` | | Static annotations to add to the namespace (at creation and subsequent user logins) | {"zone":"prod"} | | `namespaceAnnotationsDynamic` | | Dynamic annotations (currently only based on user JWT token) to add to the namespace (at creation and subsequent user logins). Annotations names will be prefixed with `onyxia_`. `onyxia_last_login_timestamp` is also added. | {"enabled": true, "userAttributes": ["sub", "email"] } |