Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ protected void configure() {
scmFileResolverResolverMultibinder.addBinding().to(GitSshScmFileResolver.class);

install(new org.eclipse.che.api.factory.server.scm.KubernetesScmModule());
install(new org.eclipse.che.security.oauth.KubernetesOAuthModule());
install(new org.eclipse.che.api.factory.server.bitbucket.BitbucketServerModule());
install(new org.eclipse.che.api.factory.server.gitlab.GitlabModule());
install(new org.eclipse.che.api.factory.server.github.GithubModule());
Expand Down
4 changes: 4 additions & 0 deletions infrastructures/infrastructure-factory/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@
<groupId>jakarta.ws.rs</groupId>
<artifactId>jakarta.ws.rs-api</artifactId>
</dependency>
<dependency>
<groupId>org.eclipse.che.core</groupId>
<artifactId>che-core-api-auth</artifactId>
</dependency>
<dependency>
<groupId>org.eclipse.che.core</groupId>
<artifactId>che-core-api-core</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2012-2026 Red Hat, Inc.
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Red Hat, Inc. - initial API and implementation
*/
package org.eclipse.che.security.oauth;

import com.google.inject.AbstractModule;
import org.eclipse.che.security.oauth.kubernetes.KubernetesUserWorkspaceUrlProvider;

/** Binds the Kubernetes backed implementations of the OAuth SPI. */
public class KubernetesOAuthModule extends AbstractModule {
@Override
protected void configure() {
bind(UserWorkspaceUrlProvider.class).to(KubernetesUserWorkspaceUrlProvider.class);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/*
* Copyright (c) 2012-2026 Red Hat, Inc.
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Red Hat, Inc. - initial API and implementation
*/
package org.eclipse.che.security.oauth.kubernetes;

import io.fabric8.kubernetes.api.model.GenericKubernetesResource;
import io.fabric8.kubernetes.client.KubernetesClientException;
import io.fabric8.kubernetes.client.dsl.base.ResourceDefinitionContext;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import javax.inject.Inject;
import javax.inject.Singleton;
import org.eclipse.che.api.core.ServerException;
import org.eclipse.che.api.workspace.server.spi.InfrastructureException;
import org.eclipse.che.api.workspace.server.spi.NamespaceResolutionContext;
import org.eclipse.che.commons.env.EnvironmentContext;
import org.eclipse.che.security.oauth.UserWorkspaceUrlProvider;
import org.eclipse.che.workspace.infrastructure.kubernetes.CheServerKubernetesClientFactory;
import org.eclipse.che.workspace.infrastructure.kubernetes.namespace.KubernetesNamespaceFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* Reads the main URLs of the current user's workspaces from the {@code DevWorkspace} custom
* resources living in the namespace of that user.
*
* <p>{@code status.mainUrl} is published by the DevWorkspace Operator and holds the URL of the
* endpoint marked with the {@code type: main} attribute, which for a browser IDE is the URL the
* workbench itself is served from. Comparing against it, rather than reconstructing the URL layout
* that the Che operator generates, keeps this check correct for both the {@code
* /<username>/<workspace-name>/<port>/} and the legacy {@code /<workspace-id>/<component>/<port>/}
* path strategies.
*
* <p>Both of those are gateway routed, which is what every editor definition shipped with the Che
* operator asks for by declaring {@code urlRewriteSupported: true} on its {@code type: main}
* endpoint. The Che operator publishes gateway routed endpoints as {@code https} and under a path
* that names either the user or the workspace, which is what makes the main URL usable as an
* authorization boundary in the first place.
*
* <p>An editor definition that turns {@code urlRewriteSupported} off is exposed through a dedicated
* Route or Ingress instead. Its main URL then names a host of its own, carries only whatever the
* endpoint declares as its {@code path}, and is {@code https} only if the endpoint asks to be
* secure. The redirect is refused for such a workspace: {@code
* OAuthIdeRedirectManager#isLocatedUnder} rejects an empty path, because matching on the host alone
* would accept the workspace of any other user on the same host, and the callback URL is required
* to be {@code https}.
*/
@Singleton
public class KubernetesUserWorkspaceUrlProvider implements UserWorkspaceUrlProvider {
private static final Logger LOG =
LoggerFactory.getLogger(KubernetesUserWorkspaceUrlProvider.class);

private static final ResourceDefinitionContext DEV_WORKSPACE_CONTEXT =
new ResourceDefinitionContext.Builder()
.withGroup("workspace.devfile.io")
.withVersion("v1alpha2")
.withKind("DevWorkspace")
.withPlural("devworkspaces")
.withNamespaced(true)
.build();

private final KubernetesNamespaceFactory namespaceFactory;
private final CheServerKubernetesClientFactory cheServerKubernetesClientFactory;

@Inject
public KubernetesUserWorkspaceUrlProvider(
KubernetesNamespaceFactory namespaceFactory,
CheServerKubernetesClientFactory cheServerKubernetesClientFactory) {
this.namespaceFactory = namespaceFactory;
this.cheServerKubernetesClientFactory = cheServerKubernetesClientFactory;
}

@Override
public Set<String> getWorkspaceUrls() throws ServerException {
Set<String> urls = new LinkedHashSet<>();
try {
String namespace =
namespaceFactory.evaluateNamespaceName(
new NamespaceResolutionContext(EnvironmentContext.getCurrent().getSubject()));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
List<GenericKubernetesResource> devWorkspaces =
cheServerKubernetesClientFactory
.create()
.genericKubernetesResources(DEV_WORKSPACE_CONTEXT)
.inNamespace(namespace)
.list()
.getItems();
for (GenericKubernetesResource devWorkspace : devWorkspaces) {
Object mainUrl = devWorkspace.get("status", "mainUrl");
if (mainUrl instanceof String && !((String) mainUrl).isBlank()) {
urls.add((String) mainUrl);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
} catch (InfrastructureException | KubernetesClientException e) {
// The message of a Kubernetes API failure names the service account and the namespaces it
// was denied, and the message of a ServerException is returned to the caller. Keep it here.
LOG.warn("Failed to read the workspaces of the current user: {}", e.getMessage(), e);
throw new ServerException("Failed to read the workspaces of the current user");
}
LOG.debug("Resolved {} workspace URL(s) for the current user", urls.size());
return urls;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
/*
* Copyright (c) 2012-2026 Red Hat, Inc.
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Red Hat, Inc. - initial API and implementation
*/
package org.eclipse.che.security.oauth.kubernetes;

import static java.util.Collections.emptyList;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertFalse;
import static org.testng.Assert.assertTrue;
import static org.testng.Assert.fail;

import io.fabric8.kubernetes.api.model.GenericKubernetesResource;
import io.fabric8.kubernetes.api.model.GenericKubernetesResourceList;
import io.fabric8.kubernetes.client.KubernetesClient;
import io.fabric8.kubernetes.client.KubernetesClientException;
import io.fabric8.kubernetes.client.dsl.MixedOperation;
import io.fabric8.kubernetes.client.dsl.NonNamespaceOperation;
import io.fabric8.kubernetes.client.dsl.Resource;
import io.fabric8.kubernetes.client.dsl.base.ResourceDefinitionContext;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.eclipse.che.api.core.ServerException;
import org.eclipse.che.api.workspace.server.spi.InfrastructureException;
import org.eclipse.che.api.workspace.server.spi.NamespaceResolutionContext;
import org.eclipse.che.commons.env.EnvironmentContext;
import org.eclipse.che.commons.subject.SubjectImpl;
import org.eclipse.che.workspace.infrastructure.kubernetes.CheServerKubernetesClientFactory;
import org.eclipse.che.workspace.infrastructure.kubernetes.namespace.KubernetesNamespaceFactory;
import org.mockito.Mock;
import org.mockito.testng.MockitoTestNGListener;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Listeners;
import org.testng.annotations.Test;

@Listeners(MockitoTestNGListener.class)
public class KubernetesUserWorkspaceUrlProviderTest {

private static final String NAMESPACE = "alice-che";

@Mock private KubernetesNamespaceFactory namespaceFactory;
@Mock private CheServerKubernetesClientFactory clientFactory;
@Mock private KubernetesClient kubeClient;

@Mock
private MixedOperation<
GenericKubernetesResource,
GenericKubernetesResourceList,
Resource<GenericKubernetesResource>>
devWorkspacesOperation;

private KubernetesUserWorkspaceUrlProvider provider;

@BeforeMethod
public void setUp() throws Exception {
provider = new KubernetesUserWorkspaceUrlProvider(namespaceFactory, clientFactory);
when(clientFactory.create()).thenReturn(kubeClient);
when(kubeClient.genericKubernetesResources(any(ResourceDefinitionContext.class)))
.thenReturn(devWorkspacesOperation);
when(namespaceFactory.evaluateNamespaceName(any(NamespaceResolutionContext.class)))
.thenReturn(NAMESPACE);

EnvironmentContext context = new EnvironmentContext();
context.setSubject(new SubjectImpl("alice", emptyList(), "alice-id", "token", false));
EnvironmentContext.setCurrent(context);
}

@AfterMethod
public void tearDown() {
EnvironmentContext.reset();
}

@Test
public void shouldReturnMainUrlsOfTheDevWorkspacesOfTheUser() throws Exception {
mockDevWorkspaces(
NAMESPACE,
devWorkspace("https://che.example.com/alice/first/3100/"),
devWorkspace("https://che.example.com/alice/second/3100/"));

Set<String> urls = provider.getWorkspaceUrls();

assertEquals(
urls,
Set.of(
"https://che.example.com/alice/first/3100/",
"https://che.example.com/alice/second/3100/"));
}

/** Only the namespace Che resolves for the current user may be read, and no other. */
@Test
public void shouldReadOnlyTheNamespaceResolvedForTheCurrentUser() throws Exception {
mockDevWorkspaces(NAMESPACE, devWorkspace("https://che.example.com/alice/first/3100/"));

provider.getWorkspaceUrls();

verify(devWorkspacesOperation).inNamespace(NAMESPACE);
verifyNoMoreInteractions(devWorkspacesOperation);
}

@Test
public void shouldSkipDevWorkspacesWithoutMainUrl() throws Exception {
mockDevWorkspaces(
NAMESPACE,
devWorkspaceWithoutStatus(),
devWorkspace(null),
devWorkspace(""),
devWorkspace(" "),
devWorkspace("https://che.example.com/alice/first/3100/"));

Set<String> urls = provider.getWorkspaceUrls();

assertEquals(urls, Set.of("https://che.example.com/alice/first/3100/"));
}

/** The CRD does not constrain us to a string here, so a non string value must not blow up. */
@Test
public void shouldSkipDevWorkspacesWithANonStringMainUrl() throws Exception {
GenericKubernetesResource devWorkspace = devWorkspace(null);
((Map<String, Object>) devWorkspace.getAdditionalProperties().get("status"))
.put("mainUrl", List.of("https://che.example.com/alice/first/3100/"));
mockDevWorkspaces(NAMESPACE, devWorkspace);

assertTrue(provider.getWorkspaceUrls().isEmpty());
}

@Test
public void shouldReturnEmptySetWhenTheUserHasNoDevWorkspaces() throws Exception {
mockDevWorkspaces(NAMESPACE);

assertTrue(provider.getWorkspaceUrls().isEmpty());
}

@Test(expectedExceptions = ServerException.class)
public void shouldFailWhenTheNamespaceCannotBeResolved() throws Exception {
when(namespaceFactory.evaluateNamespaceName(any(NamespaceResolutionContext.class)))
.thenThrow(new InfrastructureException("no namespace"));

provider.getWorkspaceUrls();
}

@Test(expectedExceptions = ServerException.class)
public void shouldFailWhenTheDevWorkspacesCannotBeRead() throws Exception {
mockUnreadableDevWorkspaces();

provider.getWorkspaceUrls();
}

/**
* The message of a {@link ServerException} is serialized into the response body, and a Kubernetes
* API failure names the service account and the namespaces it was denied.
*/
@Test
public void shouldNotLeakTheKubernetesFailureIntoTheExceptionMessage() throws Exception {
mockUnreadableDevWorkspaces();

try {
provider.getWorkspaceUrls();
fail("Expected a ServerException");
} catch (ServerException e) {
assertFalse(e.getMessage().contains("system:serviceaccount:eclipse-che:che"), e.getMessage());
assertFalse(e.getMessage().contains(NAMESPACE), e.getMessage());
}
}

private void mockUnreadableDevWorkspaces() {
NonNamespaceOperation<
GenericKubernetesResource,
GenericKubernetesResourceList,
Resource<GenericKubernetesResource>>
inNamespace = mock(NonNamespaceOperation.class);
when(devWorkspacesOperation.inNamespace(NAMESPACE)).thenReturn(inNamespace);
when(inNamespace.list())
.thenThrow(
new KubernetesClientException(
"devworkspaces.workspace.devfile.io is forbidden: User"
+ " \"system:serviceaccount:eclipse-che:che\" cannot list resource in namespace"
+ " \""
+ NAMESPACE
+ "\""));
}

private void mockDevWorkspaces(String namespace, GenericKubernetesResource... devWorkspaces) {
NonNamespaceOperation<
GenericKubernetesResource,
GenericKubernetesResourceList,
Resource<GenericKubernetesResource>>
inNamespace = mock(NonNamespaceOperation.class);
GenericKubernetesResourceList list = new GenericKubernetesResourceList();
list.setItems(List.of(devWorkspaces));
when(devWorkspacesOperation.inNamespace(namespace)).thenReturn(inNamespace);
when(inNamespace.list()).thenReturn(list);
}

private static GenericKubernetesResource devWorkspace(String mainUrl) {
GenericKubernetesResource devWorkspace = new GenericKubernetesResource();
devWorkspace.setApiVersion("workspace.devfile.io/v1alpha2");
devWorkspace.setKind("DevWorkspace");
Map<String, Object> status = new HashMap<>();
if (mainUrl != null) {
status.put("mainUrl", mainUrl);
}
devWorkspace.setAdditionalProperty("status", status);
return devWorkspace;
}

/** A DevWorkspace that has not been reconciled yet has no {@code status} at all. */
private static GenericKubernetesResource devWorkspaceWithoutStatus() {
GenericKubernetesResource devWorkspace = new GenericKubernetesResource();
devWorkspace.setApiVersion("workspace.devfile.io/v1alpha2");
devWorkspace.setKind("DevWorkspace");
return devWorkspace;
}
}
4 changes: 4 additions & 0 deletions wsmaster/che-core-api-auth/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@
<findbugs.failonerror>false</findbugs.failonerror>
</properties>
<dependencies>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
Expand Down
Loading
Loading