From 1bf8993c4196915a79c97643cd0c671ea17ef114 Mon Sep 17 00:00:00 2001 From: Olivier Levitt Date: Fri, 23 Oct 2026 14:21:29 +0200 Subject: [PATCH 1/8] 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/8] 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/8] 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/8] 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/8] 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/8] 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/8] 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 1c4be38ed5c8ff4a14b5c1785c5cc9b55651f038 Mon Sep 17 00:00:00 2001 From: Dylan Decrulle <81740200+ddecrulle@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:49:08 +0100 Subject: [PATCH 8/8] Remove the Java onboarding in favor of the onboarding API Namespaces, their quotas and RBAC are now created by the onboarding API only. - remove POST /onboarding and namespace creation on first request - remove the allowNamespaceCreation, namespaceLabels, namespaceAnnotations, namespaceAnnotationsDynamic and quotas user/group/roles region settings (quotas.userEnabled/groupEnabled still enable GET /my-lab/quota) - create the Onyxia release secret with the namespace client, so it impersonates the namespace service account too Co-Authored-By: Claude Opus 5.5 --- docs/SPECIFICATION.md | 11 - docs/region-configuration.md | 34 +-- .../properties/RegionsConfiguration.java | 14 -- .../api/onboarding/OnboardingController.java | 102 -------- .../NamespaceAlreadyExistException.java | 11 - .../OnboardingDisabledException.java | 11 - .../onyxia/api/events/InitNamespaceEvent.java | 46 ---- .../api/services/impl/HelmAppsService.java | 25 +- .../impl/kubernetes/KubernetesService.java | 229 ++---------------- onyxia-api/src/main/resources/regions.json | 31 +-- .../onboarding/OnboardingControllerTest.java | 114 --------- .../fr/insee/onyxia/model/region/Region.java | 95 -------- 12 files changed, 30 insertions(+), 693 deletions(-) delete mode 100644 onyxia-api/src/main/java/fr/insee/onyxia/api/controller/api/onboarding/OnboardingController.java delete mode 100644 onyxia-api/src/main/java/fr/insee/onyxia/api/controller/exception/NamespaceAlreadyExistException.java delete mode 100644 onyxia-api/src/main/java/fr/insee/onyxia/api/controller/exception/OnboardingDisabledException.java delete mode 100644 onyxia-api/src/main/java/fr/insee/onyxia/api/events/InitNamespaceEvent.java delete mode 100644 onyxia-api/src/test/java/fr/insee/onyxia/api/controller/api/onboarding/OnboardingControllerTest.java diff --git a/docs/SPECIFICATION.md b/docs/SPECIFICATION.md index 4cb6c8c2..839196ca 100644 --- a/docs/SPECIFICATION.md +++ b/docs/SPECIFICATION.md @@ -45,17 +45,6 @@ My Lab endpoints are available to authenticated users, offering a programmatic w The endpoints routed by `fr.insee.onyxia.api.controller.api.mylab.QuotaController`, are only functional if a quota feature is set on the Region, and allow users to: - `GET /my-lab/quota` get their quota on the namespace, which is implemented by a quota resource with the name `onyxia-quota` -- `POST /my-lab/quota` change their quota on the namepsace, if quota changing is enabled -- `POST /my-lab/quota/reset` reset their quota to the default one on the namespace - -### /onboarding/ - -The request to onboard process entails the creation of the Kubernetes namespace for the Group and the creation of a RoleBinding to that namespace giving the default ServiceAccount of that namespace admin permissions. There is a check in whether the user that made the request belongs to the group, based on the OIDC token, and the group onboarding process is triggered if so. - -*NOTE: this onboarding process is being done behind the scenes when requesting a first service, and the endpoint use is not the default behaviour.* - -If no group is given in the body, it is assumed that it is a user onboard, i.e., a namespace for a single user rather than a Group (Project), and the creation of the namespace and RoleBinding remains the same. However, in the user use-case, there is also the possibility to set Quotas on the namespace during creation, and these are obtained in the Region definition configuration. - ### /public/ Public endpoints are available to all, offering a programmatic way to comprehend the current configuration and the available services in Onyxia. They allow users to: diff --git a/docs/region-configuration.md b/docs/region-configuration.md index e367c687..13a8a616 100644 --- a/docs/region-configuration.md +++ b/docs/region-configuration.md @@ -50,10 +50,6 @@ 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 | -| `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"] } | | `singleNamespace` | true | When true, all users share the same namespace on the service provider. This configuration can be used if a project works on its own Onyxia region. | | | `userNamespace` | true | When true, all users have a namespace for their work. This configuration can be used if you don't allow a user to have their own space to work and only use project space | | | `namespacePrefix` | "user-" | User has a personal namespace like namespacePrefix + userId (should only be used when not singleNamespace but not the case) | | @@ -92,30 +88,12 @@ It can be used to add additional features to Onyxia. It helps Onyxia users to di ### Quotas properties -When this feature is enabled, Onyxia creates and maintains a [ResourceQuota](https://kubernetes.io/docs/concepts/policy/resource-quotas/) in the user / group namespace. -This feature can be enabled for either or both `user` and `group` namespaces and, for user namespaces, quotas can depend on the user roles. - -| Key | Default | Description | Example | -|----------------| ------- |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| ------ | -| `userEnabled` | false | Whether or not `ResourceQuotas` should be created for user namespaces. | true | -| `user` | | Quota to apply to user namespaces. | `{ "count/pods": "5" }` | -| `roles` | | Map of quotas corresponding to user roles. In case the user has multiple of those roles, only the first one will be applied. If user has no role from this list then `user` quota will be applied. | `{"admin": { "count/pods": "999" }}` | -| `groupEnabled` | false | Whether or not `ResourceQuotas` should be created for group namespaces. | true | -| `group` | | Quota to apply to group namespaces. | `{ "count/pods": "5" }` | - -A quota follows the Kubernetes model which is composed of: -* "requests.memory" -* "requests.cpu" -* "limits.memory" -* "limits.cpu" -* "requests.storage" -* "count/pods" -* "requests.ephemeral-storage" -* "limits.ephemeral-storage" -* "requests.nvidia.com/gpu" -* "limits.nvidia.com/gpu" - -Note : If you want Onyxia to create the ResourceQuota but not override it at each user login / action (e.g you override it yourself for some specific users), you can add the annotation `onyxia_ignore` to the ResourceQuota. If this annotation is set on a ResourceQuota then Onyxia won't touch it anymore. +ResourceQuotas are created by the onboarding API in each namespace. These properties only enable the `GET /my-lab/quota` endpoint, which displays the [ResourceQuota](https://kubernetes.io/docs/concepts/policy/resource-quotas/) named `onyxia-quota` of the user / group namespace. + +| Key | Default | Description | +|----------------| ------- |----------------------------------------------------------------| +| `userEnabled` | false | Whether or not quotas are displayed for user namespaces. | +| `groupEnabled` | false | Whether or not quotas are displayed for group namespaces. | ### Expose properties 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 f47dbda5..5e0b79d9 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 @@ -83,20 +83,6 @@ public void load() throws Exception { "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 deleted file mode 100644 index 4b16caf1..00000000 --- a/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/api/onboarding/OnboardingController.java +++ /dev/null @@ -1,102 +0,0 @@ -package fr.insee.onyxia.api.controller.api.onboarding; - -import fr.insee.onyxia.api.controller.exception.NamespaceAlreadyExistException; -import fr.insee.onyxia.api.controller.exception.OnboardingDisabledException; -import fr.insee.onyxia.api.services.UserProvider; -import fr.insee.onyxia.api.services.impl.kubernetes.KubernetesService; -import fr.insee.onyxia.model.User; -import fr.insee.onyxia.model.region.Region; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.enums.ParameterIn; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; -import io.swagger.v3.oas.annotations.tags.Tag; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.security.access.AccessDeniedException; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@Tag(name = "Onboarding", description = "Onboarding related services") -@RequestMapping("/onboarding") -@RestController -@SecurityRequirement(name = "auth") -public class OnboardingController { - - private final KubernetesService kubernetesService; - - private final UserProvider userProvider; - - @Autowired - public OnboardingController(KubernetesService kubernetesService, UserProvider userProvider) { - this.kubernetesService = kubernetesService; - 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 = - "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", - description = - "The region used by the user, if not provided default to the first region configured.", - in = ParameterIn.HEADER, - schema = @Schema(name = "ONYXIA-REGION", type = "string")) - }) - @PostMapping - public void onboard( - @Parameter(hidden = true) Region region, @RequestBody OnboardingRequest request) { - if (region.getServices().isSingleNamespace()) { - throw new NamespaceAlreadyExistException(); - } - if (!region.getServices().isAllowNamespaceCreation()) { - throw new OnboardingDisabledException(); - } - - checkPermissions(region, request); - final KubernetesService.Owner owner = new KubernetesService.Owner(); - final User user = userProvider.getUser(region); - if (request.getGroup() != null) { - owner.setId(request.getGroup()); - owner.setType(KubernetesService.Owner.OwnerType.GROUP); - } else { - owner.setId(user.getIdep()); - owner.setType(KubernetesService.Owner.OwnerType.USER); - } - kubernetesService.createOrUpdateNamespace(region, owner, user); - } - - private void checkPermissions(Region region, OnboardingRequest request) - throws AccessDeniedException { - if (request.getGroup() != null - && !userProvider.getUser(region).getGroups().contains(request.getGroup())) { - throw new AccessDeniedException("User does not belong to group " + request.getGroup()); - } - } - - @Schema( - description = - "Specification on which namespace to create. If group is provided, create a group namespace, otherwise create the user namespace.") - public static class OnboardingRequest { - - @Schema private String group; - - public String getGroup() { - return group; - } - - public void setGroup(String group) { - this.group = group; - } - } -} diff --git a/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/exception/NamespaceAlreadyExistException.java b/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/exception/NamespaceAlreadyExistException.java deleted file mode 100644 index e155dedb..00000000 --- a/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/exception/NamespaceAlreadyExistException.java +++ /dev/null @@ -1,11 +0,0 @@ -package fr.insee.onyxia.api.controller.exception; - -import org.springframework.http.HttpStatus; -import org.springframework.web.bind.annotation.ResponseStatus; - -@ResponseStatus(value = HttpStatus.CONFLICT) -public class NamespaceAlreadyExistException extends RuntimeException { - public NamespaceAlreadyExistException() { - super("Namespace already exists"); - } -} diff --git a/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/exception/OnboardingDisabledException.java b/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/exception/OnboardingDisabledException.java deleted file mode 100644 index ec4491bf..00000000 --- a/onyxia-api/src/main/java/fr/insee/onyxia/api/controller/exception/OnboardingDisabledException.java +++ /dev/null @@ -1,11 +0,0 @@ -package fr.insee.onyxia.api.controller.exception; - -import org.springframework.http.HttpStatus; -import org.springframework.web.bind.annotation.ResponseStatus; - -@ResponseStatus(value = HttpStatus.BAD_REQUEST) -public class OnboardingDisabledException extends RuntimeException { - public OnboardingDisabledException() { - super("Onboarding is disabled on this onyxia instance"); - } -} diff --git a/onyxia-api/src/main/java/fr/insee/onyxia/api/events/InitNamespaceEvent.java b/onyxia-api/src/main/java/fr/insee/onyxia/api/events/InitNamespaceEvent.java deleted file mode 100644 index 01b0b02f..00000000 --- a/onyxia-api/src/main/java/fr/insee/onyxia/api/events/InitNamespaceEvent.java +++ /dev/null @@ -1,46 +0,0 @@ -package fr.insee.onyxia.api.events; - -public class InitNamespaceEvent extends OnyxiaEvent { - - public static final String TYPE = "onboarding.createNamespace"; - String region; - String namespace; - String username; - - public InitNamespaceEvent() {} - - public InitNamespaceEvent(String region, String namespace, String username) { - this.region = region; - this.namespace = namespace; - this.username = username; - } - - public String getRegion() { - return region; - } - - public void setRegion(String region) { - this.region = region; - } - - public String getNamespace() { - return namespace; - } - - public void setNamespace(String namespace) { - this.namespace = namespace; - } - - public String getUsername() { - return username; - } - - public void setUsername(String username) { - this.username = username; - } - - @Override - public String getType() { - return TYPE; - } -} 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..2a1e9f9a 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 @@ -112,8 +112,7 @@ public Collection installApp( File values = File.createTempFile("values", ".yaml"); mapperHelm.writeValue(values, fusion); - String namespaceId = - kubernetesService.determineNamespaceAndCreateIfNeeded(region, project, user); + String namespaceId = kubernetesService.determineNamespace(region, project, user); try { HelmInstaller res = getHelmInstallService() @@ -146,7 +145,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()); @@ -205,8 +204,7 @@ public CompletableFuture getUserServices( @Override public String getLogs( Region region, Project project, User user, String serviceId, String taskId) { - String namespace = - kubernetesService.determineNamespaceAndCreateIfNeeded(region, project, user); + String namespace = kubernetesService.determineNamespace(region, project, user); KubernetesClient client = kubernetesClientProvider.getUserClient(region, user, namespace); return client.pods().inNamespace(namespace).withName(taskId).getLog(); } @@ -217,8 +215,7 @@ public Watch getEvents( Project project, User user, Watcher watcher) { - String namespace = - kubernetesService.determineNamespaceAndCreateIfNeeded(region, project, user); + String namespace = kubernetesService.determineNamespace(region, project, user); KubernetesClient client = kubernetesClientProvider.getUserClient(region, user, namespace); return client.v1().events().inNamespace(namespace).watch(watcher); } @@ -229,8 +226,7 @@ public Service getUserService(Region region, Project project, User user, String if (serviceId.startsWith("/")) { serviceId = serviceId.substring(1); } - String namespace = - kubernetesService.determineNamespaceAndCreateIfNeeded(region, project, user); + String namespace = kubernetesService.determineNamespace(region, project, user); HelmLs result = getHelmInstallService() .getAppById( @@ -244,8 +240,7 @@ public Service getUserService(Region region, Project project, User user, String public UninstallService destroyService( Region region, Project project, User user, final String path, boolean bulk) throws Exception { - final String namespace = - kubernetesService.determineNamespaceAndCreateIfNeeded(region, project, user); + final String namespace = kubernetesService.determineNamespace(region, project, user); UninstallService result = new UninstallService(); result.setPath(path); int status = 0; @@ -375,8 +370,7 @@ public void share(Region region, Project project, User user, String serviceId, b private void patchOnyxiaSecret( Region region, Project project, User user, String serviceId, Map data) { - String namespaceId = - kubernetesService.determineNamespaceAndCreateIfNeeded(region, project, user); + String namespaceId = kubernetesService.determineNamespace(region, project, user); KubernetesClient client = kubernetesClientProvider.getUserClient(region, user, namespaceId); Secret secret = client.secrets() @@ -400,7 +394,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); } } @@ -476,8 +470,7 @@ public void suspendOrResume( boolean dryRun, boolean suspend) throws IOException, InterruptedException, TimeoutException { - String namespaceId = - kubernetesService.determineNamespaceAndCreateIfNeeded(region, project, user); + String namespaceId = kubernetesService.determineNamespace(region, project, user); if (suspend) { getHelmInstallService() .suspend( 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..73d458fe 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 @@ -2,26 +2,16 @@ import fr.insee.onyxia.api.configuration.kubernetes.KubernetesClientProvider; import fr.insee.onyxia.api.controller.exception.NamespaceNotFoundException; -import fr.insee.onyxia.api.events.InitNamespaceEvent; -import fr.insee.onyxia.api.events.OnyxiaEventPublisher; import fr.insee.onyxia.api.services.impl.HelmAppsService; import fr.insee.onyxia.model.User; import fr.insee.onyxia.model.project.Project; import fr.insee.onyxia.model.region.Region; -import fr.insee.onyxia.model.service.quota.Quota; import io.fabric8.kubernetes.api.model.*; -import io.fabric8.kubernetes.api.model.rbac.RoleBinding; -import io.fabric8.kubernetes.api.model.rbac.RoleBindingBuilder; -import io.fabric8.kubernetes.api.model.rbac.SubjectBuilder; import io.fabric8.kubernetes.client.KubernetesClient; import io.fabric8.kubernetes.client.KubernetesClientException; -import io.fabric8.kubernetes.client.dsl.Resource; -import java.util.HashMap; import java.util.Map; import org.apache.commons.lang3.StringUtils; import org.jetbrains.annotations.NotNull; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -30,25 +20,19 @@ public class KubernetesService { private final KubernetesClientProvider kubernetesClientProvider; - final OnyxiaEventPublisher onyxiaEventPublisher; - private static final Logger LOGGER = LoggerFactory.getLogger(KubernetesService.class); public static final String ONYXIA_QUOTA = "onyxia-quota"; @Autowired - public KubernetesService( - KubernetesClientProvider kubernetesClientProvider, - OnyxiaEventPublisher onyxiaEventPublisher) { + public KubernetesService(KubernetesClientProvider kubernetesClientProvider) { this.kubernetesClientProvider = kubernetesClientProvider; - this.onyxiaEventPublisher = onyxiaEventPublisher; - } - - public String createOrUpdateNamespace(Region region, Owner owner, User user) { - final String namespaceId = getDefaultNamespace(region, owner); - return createNamespace(region, namespaceId, owner, user); } + /** + * Namespaces are created by the onboarding API: a project namespace that does not exist (or has + * not been onboarded yet) is reported as not found. + */ @NotNull - public String determineNamespaceAndCreateIfNeeded(Region region, Project project, User user) { + public String determineNamespace(Region region, Project project, User user) { if (region.getServices().isSingleNamespace()) { return getCurrentNamespace(region); } @@ -56,19 +40,7 @@ public String determineNamespaceAndCreateIfNeeded(Region region, Project project throw new NamespaceNotFoundException(); } if (!isNamespaceAlreadyExisting(region, user, project.getNamespace())) { - if (!region.getServices().isAllowNamespaceCreation()) { - throw new NamespaceNotFoundException(); - } else { - final KubernetesService.Owner owner = new KubernetesService.Owner(); - if (project.getGroup() != null) { - owner.setId(project.getGroup()); - owner.setType(Owner.OwnerType.GROUP); - } else { - owner.setId(user.getIdep()); - owner.setType(KubernetesService.Owner.OwnerType.USER); - } - createNamespace(region, project.getNamespace(), owner, user); - } + throw new NamespaceNotFoundException(); } return project.getNamespace(); } @@ -77,97 +49,6 @@ public String getCurrentNamespace(Region region) { return kubernetesClientProvider.getRootClient(region).getNamespace(); } - private String createNamespace(Region region, String namespaceId, Owner owner, User user) { - final String name = getNameFromOwner(region, owner); - - final KubernetesClient kubClient = kubernetesClientProvider.getRootClient(region); - - Map userMetadata = new HashMap<>(); - Region.Services.NamespaceAnnotationsDynamic namespaceAnnotationsDynamic = - region.getServices().getNamespaceAnnotationsDynamic(); - if (namespaceAnnotationsDynamic.isEnabled() && user != null) { - userMetadata.put( - "onyxia_last_login_timestamp", String.valueOf(System.currentTimeMillis())); - for (String claim : namespaceAnnotationsDynamic.getUserAttributes()) { - String claimValue = String.valueOf(user.getAttributes().getOrDefault(claim, "")); - userMetadata.put("onyxia_" + claim, claimValue); - } - } - - Resource namespace = - kubClient - .namespaces() - .resource( - new NamespaceBuilder() - .withNewMetadata() - .withName(namespaceId) - .withLabels(region.getServices().getNamespaceLabels()) - .addToLabels("onyxia_owner", owner.getId()) - .withAnnotations( - region.getServices().getNamespaceAnnotations()) - .addToAnnotations(userMetadata) - .endMetadata() - .build()); - boolean newNamespace = namespace.get() == null; - namespace.serverSideApply(); - - final RoleBinding bindingToCreate = - kubClient - .rbac() - .roleBindings() - .inNamespace(namespaceId) - .createOrReplace( - new RoleBindingBuilder() - .withNewMetadata() - .withLabels(Map.of("createdby", "onyxia")) - .withName("full_control_namespace") - .withNamespace(namespaceId) - .endMetadata() - .withSubjects( - new SubjectBuilder() - .withKind(getSubjectKind(owner)) - .withName(name) - .withApiGroup("rbac.authorization.k8s.io") - .withNamespace(namespaceId) - .build()) - .withNewRoleRef() - .withApiGroup("rbac.authorization.k8s.io") - .withKind("ClusterRole") - .withName("admin") - .endRoleRef() - .build()); - - final boolean userEnabled = - owner.getType() == Owner.OwnerType.USER - && region.getServices().getQuotas().isUserEnabled(); - final boolean groupEnabled = - owner.getType() == Owner.OwnerType.GROUP - && region.getServices().getQuotas().isGroupEnabled(); - - if (userEnabled) { - Quota quota = region.getServices().getQuotas().getUserQuota(); - for (String role : user.getRoles()) { - if (region.getServices().getQuotas().getRolesQuota().containsKey(role)) { - quota = region.getServices().getQuotas().getRolesQuota().get(role); - break; // take first role match - } - } - LOGGER.info("applying user enabled style quota"); - applyQuotas(namespaceId, kubClient, quota, true); - } else if (groupEnabled) { - final Quota quota = region.getServices().getQuotas().getGroupQuota(); - LOGGER.info("applying group enabled style quota"); - applyQuotas(namespaceId, kubClient, quota, true); - } - if (newNamespace) { - InitNamespaceEvent initNamespaceEvent = - new InitNamespaceEvent(region.getName(), namespaceId, owner.getId()); - onyxiaEventPublisher.publishEvent(initNamespaceEvent); - } - - return 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 @@ -210,61 +91,14 @@ private KubernetesClient getNamespaceClient(Region region, User user, String nam return kubernetesClientProvider.getRootClient(region); } - private void applyQuotas( - String namespaceId, - KubernetesClient kubClient, - Quota inputQuota, - boolean overrideExisting) { - final ResourceQuotaBuilder resourceQuotaBuilder = new ResourceQuotaBuilder(); - resourceQuotaBuilder - .withNewMetadata() - .withLabels(Map.of("createdby", "onyxia")) - .withName(ONYXIA_QUOTA) - .withNamespace(namespaceId) - .endMetadata(); - - final Map quotasToApply = inputQuota.asMap(); - - if (quotasToApply.entrySet().stream().filter(e -> e.getValue() != null).count() == 0) { - return; - } - - var resourceQuotaBuilderSpecNested = resourceQuotaBuilder.withNewSpec(); - quotasToApply.entrySet().stream() - .filter(e -> e.getValue() != null) - .forEach( - e -> - resourceQuotaBuilderSpecNested.addToHard( - e.getKey(), Quantity.parse(e.getValue()))); - resourceQuotaBuilderSpecNested.endSpec(); - - final ResourceQuota quota = resourceQuotaBuilder.build(); - ResourceQuota resourceQuota = - kubClient.resourceQuotas().inNamespace(namespaceId).withName(ONYXIA_QUOTA).get(); - if (resourceQuota != null - && resourceQuota.getMetadata().getAnnotations().containsKey("onyxia_ignore")) { - // The annotation onyxia_ignore can be set to prevent Onyxia from managing this - // resourcequota - return; - } - if (overrideExisting) { - kubClient.resourceQuotas().inNamespace(namespaceId).createOrReplace(quota); - } else { - try { - kubClient.resourceQuotas().inNamespace(namespaceId).create(quota); - } catch (final KubernetesClientException e) { - if (e.getCode() != 409) { - // This is not a "quota already in place" error - throw e; - } - } - } - } - 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 @@ -298,47 +132,12 @@ public void createOnyxiaSecret( newSecret = kubClient.secrets().inNamespace(namespaceId).resource(newSecret).create(); } - private String getNameFromOwner(Region region, Owner owner) { - String username = owner.getId(); - if (owner.getType() == Owner.OwnerType.USER - && region.getServices().getUsernamePrefix() != null) { - username = region.getServices().getUsernamePrefix() + username; - } else if (owner.getType() == Owner.OwnerType.GROUP - && region.getServices().getGroupPrefix() != null) { - username = region.getServices().getGroupPrefix() + username; - } - return username; - } - - private String getDefaultNamespace(Region region, Owner owner) { - if (owner.getType() == Owner.OwnerType.USER) { - return region.getServices().getNamespacePrefix() + owner.getId(); - } else { - return region.getServices().getGroupNamespacePrefix() + owner.getId(); - } - } - - public void applyQuota(Region region, Project project, User user, Quota quota) { - 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 String namespace = determineNamespaceAndCreateIfNeeded(region, project, user); + final String namespace = determineNamespace(region, project, user); final KubernetesClient kubClient = getNamespaceClient(region, user, namespace); return kubClient.resourceQuotas().inNamespace(namespace).withName(ONYXIA_QUOTA).get(); } - private String getSubjectKind(Owner owner) { - if (owner.getType() == Owner.OwnerType.GROUP) { - return "Group"; - } else if (owner.getType() == Owner.OwnerType.USER) { - return "User"; - } - throw new IllegalArgumentException("Owner type must be one of : USER, GROUP"); - } - public static class Owner { private String id; private OwnerType type; diff --git a/onyxia-api/src/main/resources/regions.json b/onyxia-api/src/main/resources/regions.json index 89107074..c5b7b57c 100644 --- a/onyxia-api/src/main/resources/regions.json +++ b/onyxia-api/src/main/resources/regions.json @@ -14,38 +14,9 @@ "usernamePrefix": "oidc-", "groupNamespacePrefix": "projet-", "authenticationMode": "serviceAccount", - "namespaceAnnotationsDynamic": { - "enabled": true, - "userAttributes": ["email","locale","groups"] - }, "quotas": { - "enabled": false, - "default": { - "requests.memory": "10Gi", - "requests.cpu": "10", - "limits.memory": "10Gi", - "limits.cpu": "10", - "requests.storage": "100Gi", - "count/pods": "50" - }, "userEnabled": false, - "user": { - "requests.memory": "11Gi", - "requests.cpu": "11", - "limits.memory": "11Gi", - "limits.cpu": "11", - "requests.storage": "101Gi", - "count/pods": "51" - }, - "groupEnabled": false, - "group": { - "requests.memory": "12Gi", - "requests.cpu": "12", - "limits.memory": "12Gi", - "limits.cpu": "12", - "requests.storage": "102Gi", - "count/pods": "52" - } + "groupEnabled": false }, "expose": { "domain": "fakedomain.kub.example.com", diff --git a/onyxia-api/src/test/java/fr/insee/onyxia/api/controller/api/onboarding/OnboardingControllerTest.java b/onyxia-api/src/test/java/fr/insee/onyxia/api/controller/api/onboarding/OnboardingControllerTest.java deleted file mode 100644 index 4302b347..00000000 --- a/onyxia-api/src/test/java/fr/insee/onyxia/api/controller/api/onboarding/OnboardingControllerTest.java +++ /dev/null @@ -1,114 +0,0 @@ -package fr.insee.onyxia.api.controller.api.onboarding; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; -import static org.springframework.http.MediaType.APPLICATION_JSON; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import com.fasterxml.jackson.databind.ObjectMapper; -import fr.insee.onyxia.api.configuration.BaseTest; -import fr.insee.onyxia.api.configuration.SecurityConfig; -import fr.insee.onyxia.api.configuration.properties.RegionsConfiguration; -import fr.insee.onyxia.api.controller.exception.NamespaceAlreadyExistException; -import fr.insee.onyxia.api.services.UserProvider; -import fr.insee.onyxia.api.services.impl.kubernetes.KubernetesService; -import fr.insee.onyxia.api.services.utils.HttpRequestUtils; -import fr.insee.onyxia.api.user.OnyxiaUserProvider; -import fr.insee.onyxia.model.User; -import fr.insee.onyxia.model.region.Region; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.test.web.servlet.MockMvc; - -@WebMvcTest(OnboardingController.class) -class OnboardingControllerTest extends BaseTest { - - @Autowired private MockMvc mockMvc; - @Autowired private ObjectMapper mapper; - - @MockBean private UserProvider userProvider; - @MockBean private RegionsConfiguration regionsConfiguration; - - @MockBean private HttpRequestUtils httpRequestUtils; - @MockBean private SecurityConfig securityConfig; - @MockBean private OnyxiaUserProvider onyxiaUserProvider; - @MockBean private KubernetesService kubernetesService; - - @Test - public void should_not_create_namespace_when_single_project() throws Exception { - Region region = new Region(); - Region.Services servicesConfiguration = new Region.Services(); - servicesConfiguration.setSingleNamespace(true); - region.setServices(servicesConfiguration); - when(regionsConfiguration.getDefaultRegion()).thenReturn(region); - mockMvc.perform(post("/onboarding").content("{}").contentType(APPLICATION_JSON)) - .andExpect(status().isConflict()); - } - - @Test - public void should_not_create_namespace_when_allow_namespace_creation_is_false() - throws Exception { - Region region = new Region(); - Region.Services servicesConfiguration = new Region.Services(); - servicesConfiguration.setSingleNamespace(false); - servicesConfiguration.setAllowNamespaceCreation(false); - region.setServices(servicesConfiguration); - when(regionsConfiguration.getDefaultRegion()).thenReturn(region); - when(userProvider.getUser(any())).thenReturn(User.newInstance().setIdep("default").build()); - - mockMvc.perform(post("/onboarding").content("{}").contentType(APPLICATION_JSON)) - .andExpect(status().isBadRequest()); - } - - @Test - public void should_not_create_namespace_when_user_is_not_member_of_group() throws Exception { - Region region = new Region(); - Region.Services servicesConfiguration = new Region.Services(); - servicesConfiguration.setSingleNamespace(false); - servicesConfiguration.setAllowNamespaceCreation(true); - region.setServices(servicesConfiguration); - - when(regionsConfiguration.getDefaultRegion()).thenReturn(region); - when(userProvider.getUser(any())).thenReturn(User.newInstance().setIdep("default").build()); - - var onboardingRequest = new OnboardingController.OnboardingRequest(); - onboardingRequest.setGroup("some-group"); - mockMvc.perform( - post("/onboarding") - .content(mapper.writeValueAsString(onboardingRequest)) - .contentType(APPLICATION_JSON)) - .andExpect(status().isForbidden()); - } - - @Test - public void should_not_create_namespace_when_already_exist() throws Exception { - Region region = new Region(); - Region.Services servicesConfiguration = new Region.Services(); - servicesConfiguration.setSingleNamespace(false); - servicesConfiguration.setAllowNamespaceCreation(true); - region.setServices(servicesConfiguration); - when(regionsConfiguration.getDefaultRegion()).thenReturn(region); - when(userProvider.getUser(any())).thenReturn(User.newInstance().setIdep("default").build()); - when(kubernetesService.createOrUpdateNamespace(any(), any(), any())) - .thenThrow(new NamespaceAlreadyExistException()); - - mockMvc.perform(post("/onboarding").content("{}").contentType(APPLICATION_JSON)) - .andExpect(status().isConflict()); - } - - @Test - public void should_create_namespace() throws Exception { - Region region = new Region(); - Region.Services servicesConfiguration = new Region.Services(); - servicesConfiguration.setSingleNamespace(false); - servicesConfiguration.setAllowNamespaceCreation(true); - region.setServices(servicesConfiguration); - when(regionsConfiguration.getDefaultRegion()).thenReturn(region); - when(userProvider.getUser(any())).thenReturn(User.newInstance().setIdep("default").build()); - mockMvc.perform(post("/onboarding").content("{}").contentType(APPLICATION_JSON)) - .andExpect(status().isOk()); - } -} 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 397c0edb..e7dcad3e 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 @@ -3,7 +3,6 @@ import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; -import fr.insee.onyxia.model.service.quota.Quota; import io.swagger.v3.oas.annotations.media.Schema; import java.util.ArrayList; import java.util.HashMap; @@ -174,9 +173,6 @@ public void setOnyxiaAPI(OnyxiaAPI onyxiaAPI) { public static class Services { private boolean singleNamespace = true; - private boolean allowNamespaceCreation = true; - private Map namespaceLabels = new HashMap<>(); - private Map namespaceAnnotations = new HashMap<>(); private boolean userNamespace = true; private String namespacePrefix = "user-"; private String groupNamespacePrefix = "projet-"; @@ -199,9 +195,6 @@ public static class Services { private K8sPublicEndpoint k8sPublicEndpoint = new K8sPublicEndpoint(); - private NamespaceAnnotationsDynamic namespaceAnnotationsDynamic = - new NamespaceAnnotationsDynamic(); - public boolean isSingleNamespace() { return singleNamespace; } @@ -218,39 +211,6 @@ public Object getDefaultConfiguration() { return defaultConfiguration; } - public boolean isAllowNamespaceCreation() { - return allowNamespaceCreation; - } - - public void setAllowNamespaceCreation(boolean allowNamespaceCreation) { - this.allowNamespaceCreation = allowNamespaceCreation; - } - - public NamespaceAnnotationsDynamic getNamespaceAnnotationsDynamic() { - return namespaceAnnotationsDynamic; - } - - public void setNamespaceAnnotationsDynamic( - NamespaceAnnotationsDynamic namespaceAnnotationsDynamic) { - this.namespaceAnnotationsDynamic = namespaceAnnotationsDynamic; - } - - public Map getNamespaceLabels() { - return namespaceLabels; - } - - public void getNamespaceLabels(Map namespaceLabels) { - this.namespaceLabels = namespaceLabels; - } - - public Map getNamespaceAnnotations() { - return namespaceAnnotations; - } - - public void getNamespaceAnnotations(Map namespaceAnnotations) { - this.namespaceAnnotations = namespaceAnnotations; - } - public boolean isUserNamespace() { return userNamespace; } @@ -367,42 +327,11 @@ public static enum AuthenticationMode { TOKEN_PASSTHROUGH } - public static class NamespaceAnnotationsDynamic { - private boolean enabled = true; - - private List userAttributes = new ArrayList<>(); - - public boolean isEnabled() { - return enabled; - } - - public void setEnabled(boolean enabled) { - this.enabled = enabled; - } - - public List getUserAttributes() { - return userAttributes; - } - - public void setUserAttributes(List userAttributes) { - this.userAttributes = userAttributes; - } - } - public static class Quotas { private boolean userEnabled = false; private boolean groupEnabled = false; - @JsonProperty("user") - private Quota userQuota; - - @JsonProperty("group") - private Quota groupQuota; - - @JsonProperty("roles") - private Map rolesQuota = new HashMap<>(); - public boolean isUserEnabled() { return userEnabled; } @@ -418,30 +347,6 @@ public boolean isGroupEnabled() { public void setGroupEnabled(boolean groupEnabled) { this.groupEnabled = groupEnabled; } - - public Quota getUserQuota() { - return userQuota; - } - - public void setUserQuota(Quota userQuota) { - this.userQuota = userQuota; - } - - public Quota getGroupQuota() { - return groupQuota; - } - - public void setGroupQuota(Quota groupQuota) { - this.groupQuota = groupQuota; - } - - public void setRolesQuota(Map rolesQuota) { - this.rolesQuota = rolesQuota; - } - - public Map getRolesQuota() { - return rolesQuota; - } } }