diff --git a/docs/region-configuration.md b/docs/region-configuration.md index 8343e969..c97f5ea3 100644 --- a/docs/region-configuration.md +++ b/docs/region-configuration.md @@ -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/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..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 @@ -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; @@ -30,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); } }); @@ -114,6 +118,29 @@ 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 + 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())) { @@ -146,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]+", "--kube-token=[REDACTED]"); + } + public static void safeConcat(StringBuilder currentCommand, String toConcat) throws IllegalArgumentException { if (!safeToConcatenate.matcher(toConcat).matches()) { 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); + } +} 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..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 @@ -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,13 @@ public HelmConfiguration getConfiguration(Region region, User user) { helmConfiguration.setAsKubeUser(username); } + if (region.getServices().getAuthenticationMode() + == Region.Services.AuthenticationMode.SERVICEACCOUNTINTARGETNAMESPACE) { + helmConfiguration.setAsKubeUser( + KubernetesClientProvider.getTargetNamespaceServiceAccountUsername( + region, 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..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,7 +38,23 @@ 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() @@ -52,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 d0e69d5a..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 @@ -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(), @@ -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()); @@ -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()); @@ -204,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 @@ -219,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 @@ -234,13 +229,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 +253,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 +274,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 +289,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 = @@ -301,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()) @@ -377,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) @@ -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); } } @@ -481,7 +481,7 @@ public void suspendOrResume( if (suspend) { getHelmInstallService() .suspend( - getHelmConfiguration(region, user), + getHelmConfiguration(region, user, namespaceId), catalogId + "/" + chartName, namespaceId, serviceId, @@ -493,7 +493,7 @@ public void suspendOrResume( } else { getHelmInstallService() .resume( - getHelmConfiguration(region, user), + getHelmConfiguration(region, user, namespaceId), catalogId + "/" + chartName, namespaceId, serviceId, @@ -525,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..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 @@ -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, @@ -229,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 @@ -286,14 +323,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 8fb6f6ed..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; } @@ -352,7 +361,8 @@ public static enum AuthenticationMode { @JsonProperty("serviceAccount") @JsonAlias("admin") SERVICEACCOUNT, - + @JsonProperty("serviceAccountInTargetNamespace") + SERVICEACCOUNTINTARGETNAMESPACE, @JsonProperty("tokenPassthrough") TOKEN_PASSTHROUGH }