Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

service: Add admin user concept and app definition scaling endpoint #400

Merged
merged 6 commits into from
Feb 12, 2025
Merged
Show file tree
Hide file tree
Changes from 4 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
4 changes: 2 additions & 2 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
"-Dtheia.cloud.app.id=asdfghjkl",
"-Dquarkus.http.port=8081",
"-Dtheia.cloud.use.keycloak=true",
"-Dquarkus.oidc.auth-server-url=${input:keycloakURL}",
"-Dquarkus.oidc.auth-server-url=${input:keycloakURL}/realms/TheiaCloud",
"-Dquarkus.oidc.client-id=theia-cloud",
"-Dquarkus.oidc.credentials.secret=publicbutoauth2proxywantsasecret"
],
Expand Down Expand Up @@ -101,7 +101,7 @@
"type": "promptString",
"id": "keycloakURL",
"description": "Provide the keycloak url",
"default": "https://192.168.59.101.nip.io/keycloak/"
"default": "https://192.168.59.101.nip.io/keycloak"
}
]
}
2 changes: 2 additions & 0 deletions dockerfiles/service/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@ ENV KEYCLOAK_ENABLE true
ENV KEYCLOAK_SERVERURL https://keycloak.url/auth/realms/TheiaCloud
ENV KEYCLOAK_CLIENTID theia-cloud
ENV KEYCLOAK_CLIENTSECRET publicbutoauth2proxywantsasecret
ENV KEYCLOAK_ADMIN_GROUP theia-cloud/admin

ENTRYPOINT java -Dtheia.cloud.app.id=${APPID} \
-Dquarkus.http.port=${SERVICE_PORT} \
-Dtheia.cloud.auth.admin.group=${KEYCLOAK_ADMIN_GROUP} \
-Dtheia.cloud.use.keycloak=${KEYCLOAK_ENABLE} \
-Dquarkus.oidc.auth-server-url=${KEYCLOAK_SERVERURL} \
-Dquarkus.oidc.client-id=${KEYCLOAK_CLIENTID} \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,10 +164,18 @@ public int getMinInstances() {
return minInstances;
}

public void setMinInstances(int minInstances) {
this.minInstances = minInstances;
}

public Integer getMaxInstances() {
return maxInstances;
}

public void setMaxInstances(Integer maxInstances) {
this.maxInstances = maxInstances;
}

public Integer getTimeout() {
return timeout;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/********************************************************************************
* Copyright (C) 2025 EclipseSource and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/
package org.eclipse.theia.cloud.service;

import jakarta.ws.rs.NameBinding;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.*;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

import java.lang.annotation.Inherited;

/**
* <p>
* Annotation to mark a resource or method as only accessible to admin users.
* </p>
* <p>
* Can be applied to a method or a resource class. In the latter case, the behavior applies to all its methods.
* </p>
*
* @see AdminOnlyFilter
* @see TheiaCloudUserProducer
*/
@Inherited
@NameBinding
@Retention(RUNTIME)
@Target({ TYPE, METHOD })
public @interface AdminOnly {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/********************************************************************************
* Copyright (C) 2025 EclipseSource and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/
package org.eclipse.theia.cloud.service;

import org.jboss.logging.Logger;

import jakarta.annotation.Priority;
import jakarta.inject.Inject;
import jakarta.ws.rs.Priorities;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ContainerRequestFilter;
import jakarta.ws.rs.ext.Provider;
import jakarta.ws.rs.core.Response;

/**
* ContainerRequestFilter aborting all requests from non admin users to resources annotated with {@link AdminOnly}.
*/
@Provider
@AdminOnly
@Priority(Priorities.AUTHORIZATION)
public class AdminOnlyFilter implements ContainerRequestFilter {

@Inject
Logger logger;

@Inject
TheiaCloudUser theiaCloudUser;

@Override
public void filter(ContainerRequestContext requestContext) {
if (!theiaCloudUser.isAdmin()) {
logger.infov("Blocked access to {0} {1} for non-admin user.", requestContext.getMethod(),
requestContext.getUriInfo().getPath());
requestContext.abortWith(Response.status(Response.Status.FORBIDDEN)
.entity("Admin privileges required to access this resource.").build());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,20 @@ public class ApplicationProperties {

private static final String THEIACLOUD_APP_ID = "theia.cloud.app.id";
private static final String THEIACLOUD_USE_KEYCLOAK = "theia.cloud.use.keycloak";
private static final String THEIACLOUD_ADMIN_GROUP_NAME = "theia.cloud.auth.admin.group";

private static final String DEFAULT_ADMIN_GROUP_NAME = "theia-cloud/admin";

private final Logger logger;

private final boolean useKeycloak;
private final String appId;
private final String adminGroupName;

public ApplicationProperties() {
logger = Logger.getLogger(getClass());
appId = System.getProperty(THEIACLOUD_APP_ID, "asdfghjkl");
adminGroupName = System.getProperty(THEIACLOUD_ADMIN_GROUP_NAME, DEFAULT_ADMIN_GROUP_NAME);
// Only disable keycloak if the value was explicitly set to exactly "false".
useKeycloak = !"false".equals(System.getProperty(THEIACLOUD_USE_KEYCLOAK));
if (!useKeycloak) {
Expand All @@ -56,4 +61,11 @@ public String getAppId() {
public boolean isUseKeycloak() {
return useKeycloak;
}

/**
* @return the group name that identifies admin users in the MicroProfile JWT token's groups claim
*/
public String getAdminGroupName() {
return adminGroupName;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ public BaseResource(ApplicationProperties applicationProperties) {
logger = Logger.getLogger(getClass().getSuperclass());
}

/** @return The correlation id for this request. */
protected String evaluateRequest(ServiceRequest request) {
return basicEvaluateRequest(request);
}
Expand Down Expand Up @@ -81,7 +82,7 @@ protected EvaluatedRequest evaluateRequest(UserScopedServiceRequest request) {
// (this might change if the concept of admin users is introduced later)
if (theiaCloudUser.isAnonymous()) {
info(correlationId,
"User is unexpectetly considered anonymous and, thus, must not access user scoped resources.");
"User is unexpectedly considered anonymous and, thus, must not access user scoped resources.");
throw new TheiaCloudWebException(Status.UNAUTHORIZED);
} else if (request.user == null || request.user.equals(theiaCloudUser.getIdentifier())) {
return new EvaluatedRequest(correlationId, theiaCloudUser.getIdentifier());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.stream.Collectors;

import org.eclipse.theia.cloud.common.k8s.client.DefaultTheiaCloudClient;
Expand Down Expand Up @@ -226,6 +227,11 @@ public boolean hasAppDefinition(String appDefinition) {
return CLIENT.appDefinitions().get(appDefinition).isPresent();
}

public AppDefinition editAppDefinition(String correlationId, String appDefinition,
Consumer<AppDefinition> consumer) {
return CLIENT.appDefinitions().edit(correlationId, appDefinition, consumer);
}

public boolean isMaxInstancesReached(String appDefString) {
Optional<AppDefinition> optAppDef = CLIENT.appDefinitions().get(appDefString);
if (!optAppDef.isPresent()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/********************************************************************************
* Copyright (C) 2022-2023 EclipseSource, STMicroelectronics and others.
* Copyright (C) 2022-2025 EclipseSource, STMicroelectronics and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
Expand All @@ -22,12 +22,29 @@
*/
public class TheiaCloudUser {

public static TheiaCloudUser ANONYMOUS = new TheiaCloudUser(null);
public static final TheiaCloudUser ANONYMOUS = new TheiaCloudUser(null, false);

private String identifier;
private final String identifier;
private final boolean admin;

/**
* Creates a non-admin user.
*
* @param identifier the user identifier
*/
public TheiaCloudUser(String identifier) {
this(identifier, false);
}

/**
* Creates a user with the specified admin flag.
*
* @param identifier the user identifier
* @param admin {@code true} if the user is an admin
*/
public TheiaCloudUser(String identifier, boolean admin) {
this.identifier = identifier;
this.admin = admin;
}

/**
Expand All @@ -41,13 +58,27 @@ public String getIdentifier() {
return identifier;
}

/**
* Returns whether the user is an admin.
*
* @return {@code true} if admin and not anonymous, {@code false} otherwise
*/
public boolean isAdmin() {
return !isAnonymous() && admin;
}

/**
* Determines if the user is anonymous.
*
* @return {@code true} if the user is anonymous
*/
public boolean isAnonymous() {
return identifier == null || identifier.isBlank();
}

@Override
public int hashCode() {
return Objects.hash(identifier);
return Objects.hash(identifier, admin);
}

@Override
Expand All @@ -59,12 +90,11 @@ public boolean equals(Object obj) {
if (getClass() != obj.getClass())
return false;
TheiaCloudUser other = (TheiaCloudUser) obj;
return Objects.equals(identifier, other.identifier);
return Objects.equals(identifier, other.identifier) && admin == other.admin;
}

@Override
public String toString() {
return "TheiaCloudUser [identifier=" + identifier + "]";
return "TheiaCloudUser [identifier=" + identifier + ", admin=" + admin + "]";
}

}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/********************************************************************************
* Copyright (C) 2022-2023 EclipseSource, STMicroelectronics and others.
* Copyright (C) 2022-2025 EclipseSource, STMicroelectronics and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
Expand All @@ -15,6 +15,8 @@
********************************************************************************/
package org.eclipse.theia.cloud.service;

import java.util.Set;

import org.eclipse.microprofile.jwt.Claims;
import org.eclipse.microprofile.jwt.JsonWebToken;
import org.jboss.logging.Logger;
Expand All @@ -32,6 +34,13 @@
* <p>
* With this, the {@link TheiaCloudUser} can directly be injected into any resource.
* </p>
* <p>
* This producer derives the user identity from an OIDC token. Thereby, the following claims from the MicroProfile JWT
* specification are used:
* <ul>
* <li>{@link Claims#email email} - The user's email address as their unique identifier</li>
* <li>{@link Claims#groups groups} - The user's groups to determine additional permissions, i.e. admin users</li>
* </ul>
*/
@RequestScoped
public class TheiaCloudUserProducer {
Expand All @@ -41,6 +50,9 @@ public class TheiaCloudUserProducer {
@Inject
private SecurityIdentity identity;

@Inject
private ApplicationProperties applicationProperties;

@Produces
@RequestScoped
TheiaCloudUser getTheiaCloudUser() {
Expand All @@ -56,7 +68,19 @@ TheiaCloudUser getTheiaCloudUser() {
logger.error("Cannot create user identity: The email claim is not available. Treat user as anonymous.");
return TheiaCloudUser.ANONYMOUS;
}
return new TheiaCloudUser(email);

// Check if the user is an admin by looking for the configured admin group name in the groups claim.
boolean isAdmin = false;
Set<String> groupsClaim = jwt.getClaim(Claims.groups);
if (groupsClaim != null) {
if (groupsClaim.contains(applicationProperties.getAdminGroupName())) {
isAdmin = true;
}
} else {
logger.warn("JWT groups claim is null. Cannot grant additional user privileges.");
}

return new TheiaCloudUser(email, isAdmin);
} else if (identity.getPrincipal() instanceof AnonymousPrincipal) {
// When keycloak is disabled, the security identity is authenticated, i.e. not
// anonymous. However, the user still has no identity and, thus, is regarded as
Expand Down
Loading