diff --git a/docs/region-configuration.md b/docs/region-configuration.md index e2a53cfc..bca56ab0 100644 --- a/docs/region-configuration.md +++ b/docs/region-configuration.md @@ -55,29 +55,30 @@ The Onyxia service platform is a Kubernetes cluster but Onyxia is meant to be ex Users can work on Onyxia as a User or as a Group to which they belong. Each user and group can have its own **namespace** which is an isolated space of Kubernetes. -| 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 | -| `namespaceLabels` | | Labels to add at namespace creation | {"zone":"prod"} | -| `namespaceAnnotations` | | Annotations to add at namespace creation | {"zone":"prod"} | -| `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) | | -| `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. | | -| `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"} | -| `initScript` | | Define where to fetch a script that will be launched on some service on startup. | "https://inseefrlab.github.io/onyxia/onyxia-init.sh" | -| `allowedURIPattern` | "^https://" | Init scripts set by the user have to respect this pattern. | | -| `server` | | Define the configuration of the services provider API server, this value is not served on the API as it contains credentials for the API. | See [Server properties](#server-properties) | -| `k8sPublicEndpoint` | | Define external access to Kubernetes API if available. It helps Onyxia users to directly connect to Kubernetes outside the datalab | See [K8sPublicEndpoint properties](#k8sPublicEndpoint-properties) | -| `quotas` | | Properties setting quotas on how many resources a user can get on the services provider. | See [Quotas properties](#quotas-properties) | -| `defaultConfiguration` | | Default configuration on services that a user can override. For client purposes only. | See [Default Configuration](#default-configuration-properties) | -| `customInitScript` | | This can be used to customize user environments using a regional script executed by some users' pods. | See [CustomInitScript properties](#custom-init-script-properties) | -| `customValues` | | This can be used to specify custom values that will be available for helm chart injection in the web app. Nested values are supported. | ` "customValues": {"myCustomKey": "myValue", "myNestedCustomKey": {"nestedKey": "nestedValue"} }` | +| 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 | +| `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) | | +| `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. | | +| `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"} | +| `initScript` | | Define where to fetch a script that will be launched on some service on startup. | "https://inseefrlab.github.io/onyxia/onyxia-init.sh" | +| `allowedURIPattern` | "^https://" | Init scripts set by the user have to respect this pattern. | | +| `server` | | Define the configuration of the services provider API server, this value is not served on the API as it contains credentials for the API. | See [Server properties](#server-properties) | +| `k8sPublicEndpoint` | | Define external access to Kubernetes API if available. It helps Onyxia users to directly connect to Kubernetes outside the datalab | See [K8sPublicEndpoint properties](#k8sPublicEndpoint-properties) | +| `quotas` | | Properties setting quotas on how many resources a user can get on the services provider. | See [Quotas properties](#quotas-properties) | +| `defaultConfiguration` | | Default configuration on services that a user can override. For client purposes only. | See [Default Configuration](#default-configuration-properties) | +| `customInitScript` | | This can be used to customize user environments using a regional script executed by some users' pods. | See [CustomInitScript properties](#custom-init-script-properties) | +| `customValues` | | This can be used to specify custom values that will be available for helm chart injection in the web app. Nested values are supported. | ` "customValues": {"myCustomKey": "myValue", "myNestedCustomKey": {"nestedKey": "nestedValue"} }` | ### CustomInitScript properties 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 db1284e4..ae615121 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 @@ -4,6 +4,7 @@ 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; @@ -58,14 +59,15 @@ public void onboard( 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(userProvider.getUser(region).getIdep()); + owner.setId(user.getIdep()); owner.setType(KubernetesService.Owner.OwnerType.USER); } - kubernetesService.createDefaultNamespace(region, owner); + kubernetesService.createOrUpdateNamespace(region, owner, user); } private void checkPermissions(Region region, OnboardingRequest request) 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 a30c5d9d..4e265928 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 @@ -1,7 +1,6 @@ package fr.insee.onyxia.api.services.impl.kubernetes; import fr.insee.onyxia.api.configuration.kubernetes.KubernetesClientProvider; -import fr.insee.onyxia.api.controller.exception.NamespaceAlreadyExistException; import fr.insee.onyxia.api.controller.exception.NamespaceNotFoundException; import fr.insee.onyxia.api.events.InitNamespaceEvent; import fr.insee.onyxia.api.events.OnyxiaEventPublisher; @@ -16,6 +15,8 @@ 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; @@ -40,12 +41,9 @@ public KubernetesService( this.onyxiaEventPublisher = onyxiaEventPublisher; } - public String createDefaultNamespace(Region region, Owner owner) { + public String createOrUpdateNamespace(Region region, Owner owner, User user) { final String namespaceId = getDefaultNamespace(region, owner); - if (isNamespaceAlreadyExisting(region, namespaceId)) { - throw new NamespaceAlreadyExistException(); - } - return createNamespace(region, namespaceId, owner); + return createNamespace(region, namespaceId, owner, user); } @NotNull @@ -68,7 +66,7 @@ public String determineNamespaceAndCreateIfNeeded(Region region, Project project owner.setId(user.getIdep()); owner.setType(KubernetesService.Owner.OwnerType.USER); } - createNamespace(region, project.getNamespace(), owner); + createNamespace(region, project.getNamespace(), owner, user); } } return project.getNamespace(); @@ -78,23 +76,39 @@ public String getCurrentNamespace(Region region) { return kubernetesClientProvider.getRootClient(region).getNamespace(); } - private String createNamespace(Region region, String namespaceId, Owner owner) { + private String createNamespace(Region region, String namespaceId, Owner owner, User user) { final String name = getNameFromOwner(region, owner); final KubernetesClient kubClient = kubernetesClientProvider.getRootClient(region); - kubClient - .namespaces() - .resource( - new NamespaceBuilder() - .withNewMetadata() - .withName(namespaceId) - .withLabels(region.getServices().getNamespaceLabels()) - .addToLabels("onyxia_owner", owner.getId()) - .withAnnotations(region.getServices().getNamespaceAnnotations()) - .endMetadata() - .build()) - .create(); + 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 @@ -158,9 +172,11 @@ private String createNamespace(Region region, String namespaceId, Owner owner) { quota, !region.getServices().getQuotas().isAllowUserModification()); } - InitNamespaceEvent initNamespaceEvent = - new InitNamespaceEvent(region.getName(), namespaceId, owner.getId()); - onyxiaEventPublisher.publishEvent(initNamespaceEvent); + if (newNamespace) { + InitNamespaceEvent initNamespaceEvent = + new InitNamespaceEvent(region.getName(), namespaceId, owner.getId()); + onyxiaEventPublisher.publishEvent(initNamespaceEvent); + } return namespaceId; } diff --git a/onyxia-api/src/main/resources/regions.json b/onyxia-api/src/main/resources/regions.json index aa4cde57..da9eca57 100644 --- a/onyxia-api/src/main/resources/regions.json +++ b/onyxia-api/src/main/resources/regions.json @@ -15,6 +15,10 @@ "usernamePrefix": "oidc-", "groupNamespacePrefix": "projet-", "authenticationMode": "serviceAccount", + "namespaceAnnotationsDynamic": { + "enabled": true, + "userAttributes": ["email","locale","groups"] + }, "quotas": { "allowUserModification": true, "enabled": false, 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 index 9a01665d..4302b347 100644 --- 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 @@ -92,7 +92,7 @@ public void should_not_create_namespace_when_already_exist() throws Exception { region.setServices(servicesConfiguration); when(regionsConfiguration.getDefaultRegion()).thenReturn(region); when(userProvider.getUser(any())).thenReturn(User.newInstance().setIdep("default").build()); - when(kubernetesService.createDefaultNamespace(any(), any())) + when(kubernetesService.createOrUpdateNamespace(any(), any(), any())) .thenThrow(new NamespaceAlreadyExistException()); mockMvc.perform(post("/onboarding").content("{}").contentType(APPLICATION_JSON)) 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 99a726fb..cceac313 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 @@ -208,6 +208,9 @@ public static class Services { private Map customValues = new HashMap<>(); + private NamespaceAnnotationsDynamic namespaceAnnotationsDynamic = + new NamespaceAnnotationsDynamic(); + public DefaultConfiguration getDefaultConfiguration() { return defaultConfiguration; } @@ -248,6 +251,15 @@ 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; } @@ -387,6 +399,28 @@ 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 DefaultConfiguration { private boolean IPProtection = false; private boolean networkPolicy = false;