diff --git a/alpine/alpine-infra/src/main/java/alpine/persistence/AlpineQueryManager.java b/alpine/alpine-infra/src/main/java/alpine/persistence/AlpineQueryManager.java
index 34d2bcc9cd..d4deee7008 100644
--- a/alpine/alpine-infra/src/main/java/alpine/persistence/AlpineQueryManager.java
+++ b/alpine/alpine-infra/src/main/java/alpine/persistence/AlpineQueryManager.java
@@ -39,6 +39,7 @@
import alpine.security.ApiKeyGenerator;
import org.datanucleus.store.rdbms.query.JDOQLQuery;
+import javax.jdo.Extent;
import javax.jdo.PersistenceManager;
import javax.jdo.Query;
import java.security.Principal;
@@ -343,6 +344,20 @@ public OidcUser addUserToTeams(final OidcUser user, final List teamNames
});
}
+ /**
+ * Returns a complete list of all subclasses extending User.class, in ascending order by username.
+ * @return a list of all Users
+ * @since 1.0.0
+ */
+ public PaginatedResult getAllUsers() {
+ final Query query = pm.newQuery(User.class).orderBy("username ASC");
+ final PaginatedResult result = execute(query);
+
+ pm.refreshAll(result.getObjects());
+
+ return result;
+ }
+
/**
* Retrieves an LdapUser containing the specified username. If the username
* does not exist, returns null.
@@ -546,6 +561,22 @@ public User getUser(String username) {
return executeAndCloseUnique(query);
}
+ /**
+ * Resolves a type of User.
+ * @param cls the class of the principal to retrieve
+ * @param username the username of the principal to retrieve
+ * @return a User if found, null if not found
+ * @since 1.0.0
+ */
+ public T getUser(String username, Class cls) {
+ final Query query = pm.newQuery(cls)
+ .filter("username == :username")
+ .setNamedParameters(Map.of("username", username))
+ .extension(JDOQLQuery.EXTENSION_CANDIDATE_DONT_RESTRICT_DISCRIMINATOR, true);
+
+ return (T) executeAndCloseUnique(query);
+ }
+
/**
* Creates a new Team with the specified name. If createApiKey is true,
* then {@link #createApiKey} is invoked and a cryptographically secure
@@ -561,7 +592,7 @@ public Team createTeam(final String name, final boolean createApiKey) {
}
/**
- * Creates a new Team with the specified name.
+ * Creates a new {@link Team} with the specified name.
* @param name The name of the team
* @return a Team
* @since 3.2.0
@@ -570,7 +601,22 @@ public Team createTeam(final String name) {
return callInTransaction(() -> {
final var team = new Team();
team.setName(name);
- //todo assign permissions
+ pm.makePersistent(team);
+ return team;
+ });
+ }
+
+ /**
+ * Creates a new {@link Team} with the specified name and initial {@link Permission}s.
+ * @param name The name of the team
+ * @return a Team
+ * @since 5.6.0
+ */
+ public Team createTeam(final String name, final List permissions) {
+ return callInTransaction(() -> {
+ final var team = new Team();
+ team.setName(name);
+ team.setPermissions(permissions);
pm.makePersistent(team);
return team;
});
diff --git a/alpine/alpine-model/src/main/java/alpine/model/LdapUser.java b/alpine/alpine-model/src/main/java/alpine/model/LdapUser.java
index 067094202b..a994390377 100644
--- a/alpine/alpine-model/src/main/java/alpine/model/LdapUser.java
+++ b/alpine/alpine-model/src/main/java/alpine/model/LdapUser.java
@@ -39,9 +39,9 @@
*/
@PersistenceCapable
@Inheritance(strategy = InheritanceStrategy.SUPERCLASS_TABLE)
-@Discriminator(value = "LDAP")
+@Discriminator("LDAP")
@JsonInclude(JsonInclude.Include.NON_NULL)
-@JsonPropertyOrder(value = { "username", "dn", "email", "teams", "permissions" })
+@JsonPropertyOrder({ "username", "dn", "email", "teams", "permissions" })
public class LdapUser extends User {
private static final long serialVersionUID = 261924579887470488L;
diff --git a/alpine/alpine-model/src/main/java/alpine/model/ManagedUser.java b/alpine/alpine-model/src/main/java/alpine/model/ManagedUser.java
index 105b30b968..61f40318c9 100644
--- a/alpine/alpine-model/src/main/java/alpine/model/ManagedUser.java
+++ b/alpine/alpine-model/src/main/java/alpine/model/ManagedUser.java
@@ -42,9 +42,9 @@
*/
@PersistenceCapable
@Inheritance(strategy = InheritanceStrategy.SUPERCLASS_TABLE)
-@Discriminator(value = "MANAGED")
+@Discriminator("MANAGED")
@JsonInclude(JsonInclude.Include.NON_NULL)
-@JsonPropertyOrder(value = {
+@JsonPropertyOrder({
"username",
"lastPasswordChange",
"fullname",
diff --git a/alpine/alpine-model/src/main/java/alpine/model/OidcUser.java b/alpine/alpine-model/src/main/java/alpine/model/OidcUser.java
index b382f5bd28..ea7a4c8e8c 100644
--- a/alpine/alpine-model/src/main/java/alpine/model/OidcUser.java
+++ b/alpine/alpine-model/src/main/java/alpine/model/OidcUser.java
@@ -38,9 +38,9 @@
*/
@PersistenceCapable
@Inheritance(strategy = InheritanceStrategy.SUPERCLASS_TABLE)
-@Discriminator(value = "OIDC")
+@Discriminator("OIDC")
@JsonInclude(JsonInclude.Include.NON_NULL)
-@JsonPropertyOrder(value = { "username", "subjectIdentifier", "email", "teams", "permissions" })
+@JsonPropertyOrder({ "username", "subjectIdentifier", "email", "teams", "permissions" })
public class OidcUser extends User {
private static final long serialVersionUID = -6852825148699565269L;
diff --git a/apiserver/src/main/java/org/dependencytrack/event/EventSubsystemInitializer.java b/apiserver/src/main/java/org/dependencytrack/event/EventSubsystemInitializer.java
index 4fae1bf1ee..41cc7783fd 100644
--- a/apiserver/src/main/java/org/dependencytrack/event/EventSubsystemInitializer.java
+++ b/apiserver/src/main/java/org/dependencytrack/event/EventSubsystemInitializer.java
@@ -40,6 +40,8 @@
import org.dependencytrack.tasks.EpssMirrorTask;
import org.dependencytrack.tasks.FortifySscUploadTask;
import org.dependencytrack.tasks.GitHubAdvisoryMirrorTask;
+import org.dependencytrack.tasks.GitLabIntegrationStateTask;
+import org.dependencytrack.tasks.GitLabSyncTask;
import org.dependencytrack.tasks.IntegrityAnalysisTask;
import org.dependencytrack.tasks.IntegrityMetaInitializerTask;
import org.dependencytrack.tasks.InternalComponentIdentificationTask;
@@ -96,6 +98,8 @@ public void contextInitialized(final ServletContextEvent event) {
EVENT_SERVICE.subscribe(VexUploadEvent.class, VexUploadProcessingTask.class);
EVENT_SERVICE.subscribe(LdapSyncEvent.class, LdapSyncTaskWrapper.class);
EVENT_SERVICE.subscribe(GitHubAdvisoryMirrorEvent.class, GitHubAdvisoryMirrorTask.class);
+ EVENT_SERVICE.subscribe(GitLabIntegrationStateEvent.class, GitLabIntegrationStateTask.class);
+ EVENT_SERVICE.subscribe(GitLabSyncEvent.class, GitLabSyncTask.class);
EVENT_SERVICE.subscribe(OsvMirrorEvent.class, OsvMirrorTask.class);
EVENT_SERVICE.subscribe(ProjectVulnerabilityAnalysisEvent.class, VulnerabilityAnalysisTask.class);
EVENT_SERVICE.subscribe(PortfolioVulnerabilityAnalysisEvent.class, VulnerabilityAnalysisTask.class);
@@ -143,6 +147,8 @@ public void contextDestroyed(final ServletContextEvent event) {
EVENT_SERVICE.unsubscribe(VexUploadProcessingTask.class);
EVENT_SERVICE.unsubscribe(LdapSyncTaskWrapper.class);
EVENT_SERVICE.unsubscribe(GitHubAdvisoryMirrorTask.class);
+ EVENT_SERVICE.unsubscribe(GitLabIntegrationStateTask.class);
+ EVENT_SERVICE.unsubscribe(GitLabSyncTask.class);
EVENT_SERVICE.unsubscribe(OsvMirrorTask.class);
EVENT_SERVICE.unsubscribe(VulnerabilityAnalysisTask.class);
EVENT_SERVICE.unsubscribe(RepositoryMetaAnalysisTask.class);
diff --git a/apiserver/src/main/java/org/dependencytrack/event/GitLabIntegrationStateEvent.java b/apiserver/src/main/java/org/dependencytrack/event/GitLabIntegrationStateEvent.java
new file mode 100644
index 0000000000..59de3a8a63
--- /dev/null
+++ b/apiserver/src/main/java/org/dependencytrack/event/GitLabIntegrationStateEvent.java
@@ -0,0 +1,29 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.event;
+
+import alpine.event.framework.Event;
+
+/**
+ * Defines an event used to start a state change task for the GitLab Integration.
+ *
+ * @author Allen Shearin
+ */
+public class GitLabIntegrationStateEvent implements Event {
+}
diff --git a/apiserver/src/main/java/org/dependencytrack/event/GitLabSyncEvent.java b/apiserver/src/main/java/org/dependencytrack/event/GitLabSyncEvent.java
new file mode 100644
index 0000000000..32e649a37c
--- /dev/null
+++ b/apiserver/src/main/java/org/dependencytrack/event/GitLabSyncEvent.java
@@ -0,0 +1,64 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.event;
+
+import alpine.event.framework.Event;
+import alpine.model.OidcUser;
+
+/**
+ * Defines an event used to start a sync task of current user's GitLab groups.
+ *
+ * @author Jonathan Howard
+ */
+public class GitLabSyncEvent implements Event {
+
+ private String accessToken;
+ private OidcUser user;
+
+ public GitLabSyncEvent() {
+
+ }
+
+ public GitLabSyncEvent(final String accessToken, final OidcUser user) {
+ this.accessToken = accessToken;
+ this.user = user;
+ }
+
+ public String getAccessToken() {
+ return accessToken;
+ }
+
+ public void setAccessToken(final String accessToken) {
+ this.accessToken = accessToken;
+ }
+
+ public OidcUser getUser() {
+ return user;
+ }
+
+ public void setUser(OidcUser user) {
+ this.user = user;
+ }
+
+ @Override
+ public String toString() {
+ return "%s{accessToken=%s, user=%s}".formatted(getClass().getSimpleName(), accessToken, user);
+ }
+
+}
diff --git a/apiserver/src/main/java/org/dependencytrack/integrations/PermissionsSyncer.java b/apiserver/src/main/java/org/dependencytrack/integrations/PermissionsSyncer.java
new file mode 100644
index 0000000000..1c00502d4c
--- /dev/null
+++ b/apiserver/src/main/java/org/dependencytrack/integrations/PermissionsSyncer.java
@@ -0,0 +1,31 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.integrations;
+
+import org.dependencytrack.persistence.QueryManager;
+
+public interface PermissionsSyncer extends IntegrationPoint {
+
+ boolean isEnabled();
+
+ void setQueryManager(QueryManager qm);
+
+ void synchronize();
+
+}
diff --git a/apiserver/src/main/java/org/dependencytrack/integrations/gitlab/GitLabAuthenticationCustomizer.java b/apiserver/src/main/java/org/dependencytrack/integrations/gitlab/GitLabAuthenticationCustomizer.java
new file mode 100644
index 0000000000..f1d7c85f7f
--- /dev/null
+++ b/apiserver/src/main/java/org/dependencytrack/integrations/gitlab/GitLabAuthenticationCustomizer.java
@@ -0,0 +1,82 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.integrations.gitlab;
+
+import alpine.Config;
+import alpine.event.framework.Event;
+import alpine.model.OidcUser;
+import alpine.server.auth.DefaultOidcAuthenticationCustomizer;
+import alpine.server.auth.OidcProfile;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+import org.dependencytrack.event.GitLabSyncEvent;
+import org.dependencytrack.persistence.QueryManager;
+
+import com.nimbusds.openid.connect.sdk.claims.ClaimsSet;
+import com.nimbusds.openid.connect.sdk.claims.UserInfo;
+import net.minidev.json.JSONObject;
+
+public class GitLabAuthenticationCustomizer extends DefaultOidcAuthenticationCustomizer {
+
+ @Override
+ public OidcProfile createProfile(ClaimsSet claimsSet) {
+ final String teamsClaimName = Config.getInstance().getProperty(Config.AlpineKey.OIDC_TEAMS_CLAIM);
+ String usernameClaimName = Config.getInstance().getProperty(Config.AlpineKey.OIDC_USERNAME_CLAIM);
+ final var profile = new OidcProfile();
+
+ if (claimsSet.getStringClaim("user_login") != null)
+ usernameClaimName = "user_login";
+
+ profile.setSubject(Objects.requireNonNullElse(claimsSet.getStringClaim("user_id"),
+ claimsSet.getStringClaim(UserInfo.SUB_CLAIM_NAME)));
+ profile.setUsername(claimsSet.getStringClaim(usernameClaimName));
+ profile.setEmail(Objects.requireNonNullElse(claimsSet.getStringClaim("user_email"),
+ claimsSet.getStringClaim(UserInfo.EMAIL_CLAIM_NAME)));
+
+ JSONObject claimsObj = claimsSet.toJSONObject();
+ claimsObj.remove(UserInfo.EMAIL_CLAIM_NAME);
+ claimsObj.remove(UserInfo.SUB_CLAIM_NAME);
+ claimsObj.remove(teamsClaimName);
+ claimsObj.remove(usernameClaimName);
+
+ profile.setCustomValues(claimsObj);
+
+ return profile;
+ }
+
+ @Override
+ public OidcUser onAuthenticationSuccess(OidcUser user, OidcProfile profile, String idToken, String accessToken) {
+ try (final QueryManager qm = new QueryManager()) {
+ final List groups = Objects.requireNonNullElse(profile.getGroups(), Collections.emptyList());
+
+ groups.stream()
+ .filter(Objects::nonNull)
+ .filter(group -> qm.getOidcGroup(group) == null)
+ .forEach(qm::createOidcGroup);
+ }
+
+ Event.dispatch(new GitLabSyncEvent(accessToken, user));
+
+ return user;
+ }
+
+}
diff --git a/apiserver/src/main/java/org/dependencytrack/integrations/gitlab/GitLabClient.java b/apiserver/src/main/java/org/dependencytrack/integrations/gitlab/GitLabClient.java
new file mode 100644
index 0000000000..609099d434
--- /dev/null
+++ b/apiserver/src/main/java/org/dependencytrack/integrations/gitlab/GitLabClient.java
@@ -0,0 +1,224 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+
+package org.dependencytrack.integrations.gitlab;
+
+import java.io.IOException;
+import java.math.BigInteger;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyFactory;
+import java.security.PublicKey;
+import java.security.spec.RSAPublicKeySpec;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.http.HttpEntity;
+import org.apache.http.HttpStatus;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.client.utils.URIBuilder;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.util.EntityUtils;
+import org.dependencytrack.common.HttpClientPool;
+
+import alpine.Config;
+import alpine.common.logging.Logger;
+
+import net.minidev.json.JSONArray;
+import net.minidev.json.JSONObject;
+import net.minidev.json.JSONValue;
+
+public class GitLabClient {
+
+ private static final Logger LOGGER = Logger.getLogger(GitLabClient.class);
+ private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd");
+ private static final String GRAPHQL_ENDPOINT = "/api/graphql";
+
+ private final String accessToken;
+ private final URI baseURL;
+ private final Config config;
+ private final List topics;
+ private final boolean includeArchived;
+
+ public static final String PROJECT_PATH_CLAIM = "project_path";
+ public static final String REF_PATH_CLAIM = "ref_path";
+ public static final String REF_TYPE_CLAIM = "ref_type";
+ public static final String USER_ACCESS_LEVEL_CLAIM = "user_access_level";
+
+ public GitLabClient(final String accessToken) {
+ this(accessToken, Config.getInstance(), null, false);
+ }
+
+ public GitLabClient(final String accessToken, final List topics, final boolean includeArchived) {
+ this(accessToken, Config.getInstance(), topics, includeArchived);
+ }
+
+ public GitLabClient(final String accessToken, final Config config, final List topics,
+ final boolean includeArchived) {
+ this.accessToken = accessToken;
+ this.baseURL = URI.create(config.getProperty(Config.AlpineKey.OIDC_ISSUER));
+ this.config = config;
+ this.includeArchived = includeArchived;
+ this.topics = topics;
+ }
+
+ public List getGitLabProjects() throws IOException, URISyntaxException {
+ List projects = new ArrayList<>();
+
+ JSONObject variables = new JSONObject();
+ JSONObject queryObject = new JSONObject();
+
+ // Set the default values for the GraphQL query
+ variables.put("includeTopics", false);
+ variables.put("archived", includeArchived ? "INCLUDE" : "EXCLUDE");
+
+ if (topics != null && !topics.isEmpty()) {
+ variables.put("includeTopics", true);
+ variables.put("topics", topics);
+ }
+
+ queryObject.put("query", IOUtils.resourceToString("/graphql/gitlab-projects.graphql", StandardCharsets.UTF_8));
+
+ URIBuilder builder = new URIBuilder(baseURL.toString()).setPath(GRAPHQL_ENDPOINT);
+
+ HttpPost request = new HttpPost(builder.build());
+ request.setHeader("Authorization", "Bearer " + accessToken);
+ request.setHeader("Content-Type", "application/json");
+
+ while (true) {
+ queryObject.put("variables", variables);
+
+ StringEntity entity = new StringEntity(queryObject.toString(), StandardCharsets.UTF_8);
+ request.setEntity(entity);
+
+ try (CloseableHttpResponse response = HttpClientPool.getClient().execute(request)) {
+ int statusCode = response.getStatusLine().getStatusCode();
+ if (statusCode < 200 || statusCode >= 300) {
+ LOGGER.warn("GitLab GraphQL query failed with status code: " + statusCode);
+ break;
+ }
+
+ HttpEntity responseEntity = response.getEntity();
+
+ if (responseEntity == null)
+ break;
+
+ String responseBody = EntityUtils.toString(responseEntity);
+ JSONObject responseData = JSONValue.parse(responseBody, JSONObject.class);
+
+ // Check for GraphQL errors
+ if (responseData.containsKey("errors")) {
+ LOGGER.warn("GitLab GraphQL query returned errors: " + responseData.get("errors"));
+ break;
+ }
+
+ JSONObject dataObject = (JSONObject) responseData.getOrDefault("data", new JSONObject());
+ JSONObject projectsObject = (JSONObject) dataObject.getOrDefault("withoutTopics",
+ dataObject.getOrDefault("withTopics", new JSONObject()));
+ JSONArray nodes = (JSONArray) projectsObject.getOrDefault("nodes", new JSONArray());
+
+ for (Object nodeObject : nodes) {
+ JSONObject node = (JSONObject) nodeObject;
+ projects.add(GitLabProject.parse(node.toJSONString()));
+ }
+
+ JSONObject pageInfo = (JSONObject) projectsObject.getOrDefault("pageInfo", new JSONObject());
+
+ if (!(boolean) pageInfo.get("hasNextPage"))
+ break;
+
+ variables.put("cursor", pageInfo.getAsString("endCursor"));
+ }
+ }
+
+ return projects;
+ }
+
+ private static JSONObject getJwks(String jwksUrl) throws IOException, InterruptedException, URISyntaxException {
+ URIBuilder builder = new URIBuilder(jwksUrl);
+ HttpGet request = new HttpGet(builder.build());
+ request.setHeader("Accept", "application/json");
+
+ try (CloseableHttpResponse response = HttpClientPool.getClient().execute(request)) {
+ String jsonResponse = EntityUtils.toString(response.getEntity());
+ if (response.getStatusLine().getStatusCode() != HttpStatus.SC_OK)
+ throw new IOException("Failed to fetch JWKS from URL: %s. Status code: %d".formatted(jwksUrl, response.getStatusLine().getStatusCode()));
+
+ if (!jsonResponse.trim().startsWith("{"))
+ throw new IOException("Unexpected response: " + response.getEntity());
+
+ return JSONValue.parse(jsonResponse, JSONObject.class);
+ }
+ }
+
+ public static PublicKey getPublicKeyFromJwks(String baseUrl, String jwksPath, String kid) throws Exception {
+ String gitLabJwksUrl = baseUrl + jwksPath;
+ Object keysObject = getJwks(gitLabJwksUrl).getOrDefault("keys", new JSONArray());
+ if (!(keysObject instanceof List))
+ throw new IllegalArgumentException("Invalid JWKS format: 'keys' is not a list");
+
+ @SuppressWarnings("unchecked")
+ List
@@ -369,18 +389,7 @@ public Response uploadBom(@Parameter(required = true) BomSubmitRequest request)
}
requireAccess(qm, parent, "Access to the specified parent project is forbidden");
}
- final String trimmedProjectName = StringUtils.trimToNull(request.getProjectName());
- if (request.isLatestProjectVersion()) {
- final Project oldLatest = qm.getLatestProjectVersion(trimmedProjectName);
- if (oldLatest != null) {
- requireAccess(qm, oldLatest, "Access to the previous latest project version is forbidden");
- }
- }
- project = qm.createProject(trimmedProjectName, null,
- StringUtils.trimToNull(request.getProjectVersion()), request.getProjectTags(), parent,
- null, null, request.isLatestProjectVersion(), true);
- Principal principal = getPrincipal();
- qm.updateNewProjectACL(project, principal);
+ createNewProject(request.getProjectName(), request.getProjectVersion(), request.getProjectTags(), parent, request.isLatestProjectVersion(), null);
} else {
final var response = Response.status(Response.Status.UNAUTHORIZED).entity("The principal does not have permission to create project.").build();
return new ProcessingResult(response, null);
@@ -398,6 +407,123 @@ public Response uploadBom(@Parameter(required = true) BomSubmitRequest request)
return processingResult.response();
}
+ @POST
+ @Path("/gitlab")
+ @Consumes(MediaType.MULTIPART_FORM_DATA)
+ @Produces(MediaType.APPLICATION_JSON)
+ @Operation(summary = "Upload a supported bill of material from GitLab", description = "This endpoint processes input and delegates the request to the uploadBom method.")
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "Token to be used for checking BOM processing progress", content = @Content(schema = @Schema(implementation = BomUploadResponse.class))),
+ @ApiResponse(responseCode = "400", description = "Invalid input"),
+ @ApiResponse(responseCode = "401", description = "Unauthorized"),
+ @ApiResponse(responseCode = "404", description = "The project could not be found")
+ })
+ @PermissionRequired(Permissions.Constants.BOM_UPLOAD)
+ @ResourceAccessRequired
+ public Response uploadBomGitLab(
+ @FormDataParam("gitLabToken") String idToken,
+ @FormDataParam("bom") String bom,
+ @FormDataParam("isLatest") @DefaultValue("false") boolean isLatest) {
+
+ try (QueryManager qm = new QueryManager()) {
+ Function propertyGetter = cpc -> qm.getConfigProperty(
+ cpc.getGroupName(),
+ cpc.getPropertyName());
+
+ if (qm.isEnabled(GITLAB_ENABLED))
+ return Response.notModified("GitLab integration not enabled").build();
+
+ if (qm.isEnabled(GITLAB_SBOM_PUSH_ENABLED))
+ return Response.notModified("GitLab SBOM push functionality not enabled").build();
+
+ Boolean autoCreateProject = Boolean
+ .parseBoolean(propertyGetter.apply(GITLAB_AUTOCREATE_PROJECTS).getPropertyValue());
+
+ if (idToken == null || !idToken.matches("^[\\w-]+\\.[\\w-]+\\.[\\w-]+$"))
+ return Response.status(Response.Status.UNAUTHORIZED).entity("Invalid or missing GitLab idToken")
+ .build();
+
+ ConfigProperty gitLabUrlProperty = propertyGetter.apply(GITLAB_URL);
+ String alpineIssuerProperty = Config.getInstance().getProperty(Config.AlpineKey.OIDC_ISSUER);
+ String gitlabUrl = StringUtils.defaultIfBlank(alpineIssuerProperty, gitLabUrlProperty.getPropertyValue());
+ ConfigProperty gitLabJwksPathProperty = propertyGetter.apply(GITLAB_JWKS_PATH);
+
+ // Get the key id (kid) from the JWT header
+ String headerJson = new String(Base64.getUrlDecoder().decode(idToken.split("\\.")[0]));
+ String kid = (String) new ObjectMapper().readValue(headerJson, Map.class).get("kid");
+
+ Claims claims = Jwts.parser()
+ .verifyWith(GitLabClient.getPublicKeyFromJwks(gitlabUrl, gitLabJwksPathProperty.getPropertyValue(), kid))
+ .build()
+ .parseSignedClaims(idToken)
+ .getPayload();
+
+ // If autoCreate is enabled and the project doesn't exist, create the project
+ final String projectName = List.of(claims.get(GitLabClient.PROJECT_PATH_CLAIM, String.class).split("/"))
+ .getLast();
+ final String projectVersion = claims
+ .get(claims.get(GitLabClient.REF_TYPE_CLAIM, String.class).equals("tag") ? "ref"
+ : GitLabClient.REF_PATH_CLAIM, String.class);
+ Project project = qm.getProject(projectName, projectVersion);
+
+ String accessLevel = claims.get(GitLabClient.USER_ACCESS_LEVEL_CLAIM, String.class);
+ if (accessLevel == null) {
+ return Response.status(Response.Status.BAD_REQUEST)
+ .entity("Missing user_access_level claim in token").build();
+ }
+
+ final GitLabRole gitLabRole = GitLabRole.valueOf(accessLevel.toUpperCase());
+ Role role = (gitLabRole != null)
+ ? qm.getRoleByName(gitLabRole.getDescription())
+ : null;
+
+ if (project == null) {
+ if (autoCreateProject
+ && Set.of("owner", "maintainer")
+ .contains(claims.get(GitLabClient.USER_ACCESS_LEVEL_CLAIM, String.class)))
+ createNewProject(projectName, projectVersion, null, null, isLatest, role);
+ else
+ return Response.status(Response.Status.UNAUTHORIZED)
+ .entity("The principal does not have permission to create project.").build();
+ }
+
+ if (claims.get(GitLabClient.PROJECT_PATH_CLAIM, String.class) == null)
+ return Response.status(Response.Status.BAD_REQUEST).entity("Missing project_path claim").build();
+
+ if (!claims.get(GitLabClient.REF_TYPE_CLAIM, String.class).equals("tag")
+ && claims.get(GitLabClient.REF_PATH_CLAIM, String.class) == null)
+ return Response.status(Response.Status.BAD_REQUEST).entity("Invalid ref_type or missing ref_path claim")
+ .build();
+
+ BomSubmitRequest bomSubmitRequest = new BomSubmitRequest(
+ null,
+ projectName,
+ projectVersion,
+ null,
+ autoCreateProject,
+ isLatest,
+ bom);
+
+ return uploadBom(bomSubmitRequest);
+ } catch (SignatureException e) {
+ return Response.status(Response.Status.BAD_REQUEST)
+ .entity("Received token that did not pass signature verification").build();
+ } catch (ExpiredJwtException e) {
+ return Response.status(Response.Status.BAD_REQUEST).entity("Received expired token").build();
+ } catch (MalformedJwtException e) {
+ return Response.status(Response.Status.BAD_REQUEST).entity("Received malformed token").build();
+ } catch (UnsupportedJwtException | IllegalArgumentException e) {
+ LOGGER.error(SecurityMarkers.SECURITY_FAILURE, e.getMessage());
+ return Response.status(Response.Status.BAD_REQUEST).entity("Received unsupported JWT").build();
+ } catch (IOException e) {
+ LOGGER.error(SecurityMarkers.EVENT_FAILURE, "Error reading or parsing the JWT header or JWKS: " + e.getMessage());
+ return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
+ } catch (Exception e) {
+ LOGGER.error(SecurityMarkers.EVENT_FAILURE, "An error occured in uploadBomGitLab: " + e.getMessage());
+ return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
+ }
+ }
+
@POST
@Consumes(MediaType.MULTIPART_FORM_DATA)
@Produces(MediaType.APPLICATION_JSON)
@@ -409,7 +535,7 @@ public Response uploadBom(@Parameter(required = true) BomSubmitRequest request)
then the projectName and projectVersion must be specified.
Optionally, if autoCreate is specified and true and the project does not exist,
the project will be created. In this scenario, the principal making the request will
- additionally need the PORTFOLIO_MANAGEMENT, PORTFOLIO_MANAGEMENT_CREATE,
+ additionally need the PORTFOLIO_MANAGEMENT, PORTFOLIO_MANAGEMENT_CREATE,
or PROJECT_CREATION_UPLOAD permission.
@@ -489,18 +615,10 @@ public Response uploadBom(
}
requireAccess(qm, parent, "Access to the specified parent project is forbidden");
}
- if (isLatest) {
- final Project oldLatest = qm.getLatestProjectVersion(trimmedProjectName);
- if (oldLatest != null) {
- requireAccess(qm, oldLatest, "Access to the previous latest project version is forbidden");
- }
- }
final List tags = (projectTags != null && !projectTags.isBlank())
? Arrays.stream(projectTags.split(",")).map(String::trim).filter(not(String::isEmpty)).map(org.dependencytrack.model.Tag::new).toList()
: null;
- project = qm.createProject(trimmedProjectName, null, trimmedProjectVersion, tags, parent, null, null, isLatest, true);
- Principal principal = getPrincipal();
- qm.updateNewProjectACL(project, principal);
+ createNewProject(projectName, projectVersion, tags, parent, isLatest, null);
} else {
final var response = Response.status(Response.Status.UNAUTHORIZED).entity("The principal does not have permission to create project.").build();
return new ProcessingResult(response, null);
@@ -710,4 +828,25 @@ private static boolean shouldValidate(final Project project) {
|| (validationMode == BomValidationMode.DISABLED_FOR_TAGS && !doTagsMatch);
}
}
+
+ private void createNewProject(String name, String version,
+ List tags, Project parent,
+ boolean isLatest, Role role) {
+ try (QueryManager qm = new QueryManager()) {
+ final String trimmedProjectName = StringUtils.trimToNull(name);
+ final String trimmedProjectVersion = StringUtils.trimToNull(version);
+
+ if (isLatest) {
+ final Project oldLatest = qm.getLatestProjectVersion(trimmedProjectName);
+ if (oldLatest != null) {
+ requireAccess(qm, oldLatest, "Access to the previous latest project version is forbidden");
+ }
+ }
+ Project project = qm.createProject(trimmedProjectName, null,
+ trimmedProjectVersion, tags, parent,
+ null, null, isLatest, true);
+ Principal principal = getPrincipal();
+ qm.updateNewProjectACL(project, principal, role);
+ }
+ }
}
diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v1/IntegrationResource.java b/apiserver/src/main/java/org/dependencytrack/resources/v1/IntegrationResource.java
index f378b16091..af93bb2a11 100644
--- a/apiserver/src/main/java/org/dependencytrack/resources/v1/IntegrationResource.java
+++ b/apiserver/src/main/java/org/dependencytrack/resources/v1/IntegrationResource.java
@@ -18,9 +18,12 @@
*/
package org.dependencytrack.resources.v1;
+import alpine.event.framework.Event;
+import alpine.model.ConfigProperty;
import alpine.server.auth.PermissionRequired;
import alpine.server.resources.AlpineResource;
import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
@@ -30,13 +33,19 @@
import io.swagger.v3.oas.annotations.security.SecurityRequirements;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.GET;
+import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
+import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.dependencytrack.auth.Permissions;
+import org.dependencytrack.event.GitLabIntegrationStateEvent;
+import org.dependencytrack.persistence.QueryManager;
import org.dependencytrack.tasks.OsvMirrorTask;
+import static org.dependencytrack.model.ConfigPropertyConstants.GITLAB_ENABLED;
+
import java.util.List;
import java.util.stream.Collectors;
@@ -94,4 +103,41 @@ public Response getInactiveEcosystems() {
.collect(Collectors.toList());
return Response.ok(ecosystems).build();
}
+
+ @POST
+ @Path("gitlab/{state}")
+ @Produces(MediaType.APPLICATION_JSON)
+ @Operation(summary = "Enable or disable GitLab integration", description = "Requires permission SYSTEM_CONFIGURATION or SYSTEM_CONFIGURATION_CREATE
")
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "GitLab state set successfully"),
+ @ApiResponse(responseCode = "304", description = "The GitLab integration is already in the desired state"),
+ @ApiResponse(responseCode = "401", description = "Unauthorized")
+ })
+ @PermissionRequired({ Permissions.Constants.SYSTEM_CONFIGURATION,
+ Permissions.Constants.SYSTEM_CONFIGURATION_CREATE }) // Require admin privileges due to system impact
+ public Response setGitLabEnabledState(
+ @Parameter(description = "A valid boolean", required = true) @PathParam("state") String state) {
+ try (final QueryManager qm = new QueryManager()) {
+ final Response response = qm.callInTransaction(() -> {
+ final ConfigProperty property = qm.getConfigProperty(GITLAB_ENABLED.getGroupName(),
+ GITLAB_ENABLED.getPropertyName());
+
+ if (property.getPropertyValue().equals(state))
+ return Response.notModified().build();
+
+ if (!state.equalsIgnoreCase("true") && !state.equalsIgnoreCase("false"))
+ return Response.status(Response.Status.BAD_REQUEST).build();
+
+ property.setPropertyValue(state);
+
+ return Response.ok().entity(qm.persist(property)).build();
+ });
+
+ if (response.getStatus() == Response.Status.OK.getStatusCode())
+ Event.dispatch(new GitLabIntegrationStateEvent());
+
+ return response;
+ }
+ }
+
}
diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v1/PermissionResource.java b/apiserver/src/main/java/org/dependencytrack/resources/v1/PermissionResource.java
index 8e85698250..43f4ef15eb 100644
--- a/apiserver/src/main/java/org/dependencytrack/resources/v1/PermissionResource.java
+++ b/apiserver/src/main/java/org/dependencytrack/resources/v1/PermissionResource.java
@@ -33,6 +33,16 @@
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.security.SecurityRequirements;
import io.swagger.v3.oas.annotations.tags.Tag;
+
+import org.dependencytrack.auth.Permissions;
+import org.dependencytrack.model.Role;
+import org.dependencytrack.model.validation.ValidUuid;
+import org.dependencytrack.persistence.QueryManager;
+import org.dependencytrack.resources.v1.vo.RolePermissionsSetRequest;
+import org.dependencytrack.resources.v1.vo.TeamPermissionsSetRequest;
+import org.dependencytrack.resources.v1.vo.UserPermissionsSetRequest;
+import org.owasp.security.logging.SecurityMarkers;
+
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
@@ -42,19 +52,10 @@
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
-import org.dependencytrack.auth.Permissions;
-import org.dependencytrack.model.Role;
-import org.dependencytrack.model.validation.ValidUuid;
-import org.dependencytrack.persistence.QueryManager;
-import org.dependencytrack.resources.v1.vo.TeamPermissionsSetRequest;
-import org.dependencytrack.resources.v1.vo.UserPermissionsSetRequest;
-import org.owasp.security.logging.SecurityMarkers;
-
-import javax.jdo.Query;
import java.util.List;
-import java.util.Map;
import java.util.Set;
/**
@@ -121,21 +122,21 @@ public Response addPermissionToUser(
@PathParam("permission") String permissionName) {
try (QueryManager qm = new QueryManager()) {
return qm.callInTransaction(() -> {
- User principal = qm.getUser(username);
- if (principal == null) {
+ User user = qm.getUser(username);
+ if (user == null) {
return Response.status(Response.Status.NOT_FOUND).entity("The user could not be found.").build();
}
final Permission permission = qm.getPermission(permissionName);
if (permission == null) {
return Response.status(Response.Status.NOT_FOUND).entity("The permission could not be found.").build();
}
- final List permissions = principal.getPermissions();
+ final List permissions = user.getPermissions();
if (permissions != null && !permissions.contains(permission)) {
permissions.add(permission);
- principal.setPermissions(permissions);
- principal = qm.persist(principal);
- super.logSecurityEvent(LOGGER, SecurityMarkers.SECURITY_AUDIT, "Added permission for user: " + principal.getName() + " / permission: " + permission.getName());
- return Response.ok(principal).build();
+ user.setPermissions(permissions);
+ user = qm.persist(user);
+ super.logSecurityEvent(LOGGER, SecurityMarkers.SECURITY_AUDIT, "Added permission for user: " + user.getName() + " / permission: " + permission.getName());
+ return Response.ok(user).build();
}
return Response.status(Response.Status.NOT_MODIFIED).build();
});
@@ -165,25 +166,31 @@ public Response removePermissionFromUser(
@Parameter(description = "A valid username", required = true)
@PathParam("username") String username,
@Parameter(description = "A valid permission", required = true)
+ @QueryParam("userType") String type,
@PathParam("permission") String permissionName) {
try (QueryManager qm = new QueryManager()) {
return qm.callInTransaction(() -> {
- User principal = qm.getUser(username);
- if (principal == null) {
+ User user = qm.getUser(username);
+ if (user == null) {
return Response.status(Response.Status.NOT_FOUND).entity("The user could not be found.").build();
}
+
final Permission permission = qm.getPermission(permissionName);
if (permission == null) {
return Response.status(Response.Status.NOT_FOUND).entity("The permission could not be found.").build();
}
- final List permissions = principal.getPermissions();
+
+ final List permissions = user.getPermissions();
if (permissions != null && permissions.contains(permission)) {
permissions.remove(permission);
- principal.setPermissions(permissions);
- principal = qm.persist(principal);
- super.logSecurityEvent(LOGGER, SecurityMarkers.SECURITY_AUDIT, "Removed permission for user: " + principal.getName() + " / permission: " + permission.getName());
- return Response.ok(principal).build();
+ user.setPermissions(permissions);
+ user = qm.persist(user);
+ super.logSecurityEvent(LOGGER, SecurityMarkers.SECURITY_AUDIT,
+ "Removed permission for user: " + user.getUsername() + " / permission: "
+ + permission.getName());
+ return Response.ok(user).build();
}
+
return Response.status(Response.Status.NOT_MODIFIED).build();
});
}
@@ -218,10 +225,12 @@ public Response addPermissionToTeam(
if (team == null) {
return Response.status(Response.Status.NOT_FOUND).entity("The team could not be found.").build();
}
+
final Permission permission = qm.getPermission(permissionName);
if (permission == null) {
return Response.status(Response.Status.NOT_FOUND).entity("The permission could not be found.").build();
}
+
final List permissions = team.getPermissions();
if (permissions != null && !permissions.contains(permission)) {
permissions.add(permission);
@@ -230,6 +239,7 @@ public Response addPermissionToTeam(
super.logSecurityEvent(LOGGER, SecurityMarkers.SECURITY_AUDIT, "Added permission for team: " + team.getName() + " / permission: " + permission.getName());
return Response.ok(team).build();
}
+
return Response.status(Response.Status.NOT_MODIFIED).build();
});
}
@@ -378,29 +388,19 @@ public Response removePermissionFromTeam(
})
@PermissionRequired({ Permissions.Constants.ACCESS_MANAGEMENT, Permissions.Constants.ACCESS_MANAGEMENT_UPDATE })
public Response setUserPermissions(
- @Parameter(description = "A username and valid list permission") @Valid UserPermissionsSetRequest request) {
+ @Parameter(description = "A username and valid list permission") @Valid final UserPermissionsSetRequest request) {
try (QueryManager qm = new QueryManager()) {
return qm.callInTransaction(() -> {
User user = qm.getUser(request.username());
if (user == null)
return Response.status(Response.Status.NOT_FOUND).entity("The user could not be found.").build();
- List permissionNames = request.permissions()
+ final List permissionNames = request.permissions()
.stream()
.map(Permissions::name)
.toList();
- final Query query = qm.getPersistenceManager().newQuery(Permission.class)
- .filter(":permissions.contains(name)")
- .setNamedParameters(Map.of("permissions", permissionNames))
- .orderBy("name asc");
-
- final List requestedPermissions;
- try {
- requestedPermissions = List.copyOf(query.executeList());
- } finally {
- query.closeAll();
- }
+ final List requestedPermissions = qm.getPermissionsByName(permissionNames);
if (user.getPermissions().equals(requestedPermissions))
return Response.notModified()
@@ -434,29 +434,19 @@ public Response setUserPermissions(
@ApiResponse(responseCode = "404", description = "The team could not be found")
})
@PermissionRequired({ Permissions.Constants.ACCESS_MANAGEMENT, Permissions.Constants.ACCESS_MANAGEMENT_UPDATE })
- public Response setTeamPermissions(@Parameter(description = "Team UUID and requested permissions") @Valid TeamPermissionsSetRequest request) {
+ public Response setTeamPermissions(@Parameter(description = "Team UUID and requested permissions") @Valid final TeamPermissionsSetRequest request) {
try (QueryManager qm = new QueryManager()) {
return qm.callInTransaction(() -> {
Team team = qm.getObjectByUuid(Team.class, request.team(), Team.FetchGroup.ALL.name());
if (team == null)
return Response.status(Response.Status.NOT_FOUND).entity("The team could not be found.").build();
- List permissionNames = request.permissions()
+ final List permissionNames = request.permissions()
.stream()
.map(Permissions::name)
.toList();
- final Query query = qm.getPersistenceManager().newQuery(Permission.class)
- .filter(":permissions.contains(name)")
- .setNamedParameters(Map.of("permissions", permissionNames))
- .orderBy("name asc");
-
- final List requestedPermissions;
- try {
- requestedPermissions = List.copyOf(query.executeList());
- } finally {
- query.closeAll();
- }
+ final List requestedPermissions = qm.getPermissionsByName(permissionNames);
if (team.getPermissions().equals(requestedPermissions))
return Response.notModified().entity("Team already has selected permission(s).").build();
@@ -471,4 +461,50 @@ public Response setTeamPermissions(@Parameter(description = "Team UUID and reque
});
}
}
+
+ @PUT
+ @Path("/role")
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.APPLICATION_JSON)
+ @Operation(
+ summary = "Replaces a role's permissions with the specified list",
+ description = "Requires permission ACCESS_MANAGEMENT or ACCESS_MANAGEMENT_UPDATE
"
+ )
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "The updated role", content = @Content(schema = @Schema(implementation = Role.class))),
+ @ApiResponse(responseCode = "304", description = "The role already has the specified permission(s)"),
+ @ApiResponse(responseCode = "400", description = "Bad request"),
+ @ApiResponse(responseCode = "401", description = "Unauthorized"),
+ @ApiResponse(responseCode = "404", description = "The role could not be found")
+ })
+ @PermissionRequired({ Permissions.Constants.ACCESS_MANAGEMENT, Permissions.Constants.ACCESS_MANAGEMENT_UPDATE })
+ public Response setRolePermissions(@Parameter(description = "Role UUID and requested permissions") @Valid final RolePermissionsSetRequest request) {
+ try (QueryManager qm = new QueryManager()) {
+ return qm.callInTransaction(() -> {
+ Role role = qm.getObjectByUuid(Role.class, request.role(), Role.FetchGroup.ALL.name());
+ if (role == null)
+ return Response.status(Response.Status.NOT_FOUND).entity("The role could not be found.").build();
+
+ final List permissionNames = request.permissions()
+ .stream()
+ .map(Permissions::name)
+ .toList();
+
+ final Set requestedPermissions = Set.copyOf(qm.getPermissionsByName(permissionNames));
+
+ if (role.getPermissions().equals(requestedPermissions))
+ return Response.notModified().entity("Role already has selected permission(s).").build();
+
+ role.setPermissions(requestedPermissions);
+ role = qm.persist(role);
+
+ super.logSecurityEvent(LOGGER, SecurityMarkers.SECURITY_AUDIT,
+ "Set permissions for role: %s / permissions: %s"
+ .formatted(role.getName(), permissionNames));
+
+ return Response.ok(role).build();
+ });
+ }
+ }
+
}
diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v1/ProjectResource.java b/apiserver/src/main/java/org/dependencytrack/resources/v1/ProjectResource.java
index dd8a93f556..09f922e60f 100644
--- a/apiserver/src/main/java/org/dependencytrack/resources/v1/ProjectResource.java
+++ b/apiserver/src/main/java/org/dependencytrack/resources/v1/ProjectResource.java
@@ -37,21 +37,6 @@
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.security.SecurityRequirements;
-import jakarta.validation.Validator;
-import jakarta.ws.rs.ClientErrorException;
-import jakarta.ws.rs.Consumes;
-import jakarta.ws.rs.DELETE;
-import jakarta.ws.rs.GET;
-import jakarta.ws.rs.PATCH;
-import jakarta.ws.rs.POST;
-import jakarta.ws.rs.PUT;
-import jakarta.ws.rs.Path;
-import jakarta.ws.rs.PathParam;
-import jakarta.ws.rs.Produces;
-import jakarta.ws.rs.QueryParam;
-import jakarta.ws.rs.ServerErrorException;
-import jakarta.ws.rs.core.MediaType;
-import jakarta.ws.rs.core.Response;
import org.apache.commons.lang3.StringUtils;
import org.dependencytrack.auth.Permissions;
import org.dependencytrack.event.CloneProjectEvent;
@@ -73,6 +58,21 @@
import org.dependencytrack.resources.v1.vo.ConciseProject;
import org.jdbi.v3.core.Handle;
+import jakarta.validation.Validator;
+import jakarta.ws.rs.ClientErrorException;
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.DELETE;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.PATCH;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.PUT;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.PathParam;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.QueryParam;
+import jakarta.ws.rs.ServerErrorException;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
import javax.jdo.FetchGroup;
import java.security.Principal;
import java.util.Collection;
diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v1/UserResource.java b/apiserver/src/main/java/org/dependencytrack/resources/v1/UserResource.java
index aa2077d38f..e37570d1c2 100644
--- a/apiserver/src/main/java/org/dependencytrack/resources/v1/UserResource.java
+++ b/apiserver/src/main/java/org/dependencytrack/resources/v1/UserResource.java
@@ -28,6 +28,7 @@
import alpine.model.User;
import alpine.notification.Notification;
import alpine.notification.NotificationLevel;
+import alpine.persistence.PaginatedResult;
import alpine.security.crypto.KeyManager;
import alpine.server.auth.AlpineAuthenticationException;
import alpine.server.auth.AuthenticationNotRequired;
@@ -37,6 +38,7 @@
import alpine.server.auth.PasswordService;
import alpine.server.auth.PermissionRequired;
import alpine.server.resources.AlpineResource;
+
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.headers.Header;
@@ -58,6 +60,7 @@
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.apache.commons.lang3.StringUtils;
@@ -218,7 +221,7 @@ public Response validateOidcAccessToken(@Parameter(description = "An OAuth2 acce
})
@AuthenticationNotRequired
public Response forceChangePassword(@FormParam("username") String username, @FormParam("password") String password,
- @FormParam("newPassword") String newPassword, @FormParam("confirmPassword") String confirmPassword) {
+ @FormParam("newPassword") String newPassword, @FormParam("confirmPassword") String confirmPassword) {
final Authenticator auth = new Authenticator(username, password);
AtomicReference principal = new AtomicReference<>();
try (QueryManager qm = new QueryManager()) {
@@ -267,6 +270,70 @@ public Response forceChangePassword(@FormParam("username") String username, @For
}
}
+ @GET
+ @Produces(MediaType.APPLICATION_JSON)
+ @Operation(
+ summary = "Returns a list of users or a single user, optionally filtered by type and/or username.",
+ description = "Requires permission ACCESS_MANAGEMENT or ACCESS_MANAGEMENT_READ
"
+ + "Note: To retrieve additional subclass-specific information (such as ManagedUser fields like suspended, forcePasswordChange, etc.), "
+ + "you must specify the type query parameter (e.g., type=managed, type=ldap, or type=oidc). "
+ + "If type is omitted, only base User fields will be included in the response.
"
+ )
+ @ApiResponses(value = {
+ @ApiResponse(
+ responseCode = "200",
+ description = "A list of users or a single user (may be filtered by type and/or username)",
+ headers = @Header(name = TOTAL_COUNT_HEADER, description = "The total number of managed users", schema = @Schema(format = "integer")),
+ content = @Content(
+ schema = @Schema(
+ oneOf = {User.class, ManagedUser.class, LdapUser.class, OidcUser.class}
+ ),
+ array = @ArraySchema(
+ schema = @Schema(oneOf = {User.class, ManagedUser.class, LdapUser.class, OidcUser.class})
+ )
+ )
+ ),
+ @ApiResponse(responseCode = "401", description = "Unauthorized")
+ })
+ @PermissionRequired({ Permissions.Constants.ACCESS_MANAGEMENT, Permissions.Constants.ACCESS_MANAGEMENT_READ })
+ public Response getUsers(
+ @QueryParam("userType") String type,
+ @QueryParam("username") String username) {
+ try (QueryManager qm = new QueryManager(getAlpineRequest())) {
+ final PaginatedResult result;
+ if (type == null) {
+ result = qm.getAllUsers();
+ return Response.ok(result).header(TOTAL_COUNT_HEADER, result.getTotal()).build();
+ }
+
+ Query extends User> query = qm.getPersistenceManager()
+ .newQuery((Class extends User>) switch (StringUtils.defaultString(type).toLowerCase()) {
+ case "managed" -> ManagedUser.class;
+ case "ldap" -> LdapUser.class;
+ case "oidc" -> OidcUser.class;
+ default -> User.class;
+ });
+
+ if (username != null)
+ query.filter("username == :username").setParameters(username);
+
+ result = qm.execute(query);
+ final List users = result.getList(User.class);
+ final long totalCount = result.getTotal();
+
+ if (result == null || totalCount == 0)
+ return Response.status(Response.Status.NOT_FOUND)
+ .entity("No user(s) found for the given criteria [type=%s, username=%s]"
+ .formatted(type, username))
+ .build();
+
+ if (username != null && totalCount == 1)
+ return Response.ok(users.get(0)).build();
+
+ return Response.ok(users).header(TOTAL_COUNT_HEADER, totalCount).build();
+ }
+ }
+
@GET
@Path("managed")
@Produces(MediaType.APPLICATION_JSON)
@@ -731,16 +798,15 @@ public Response addTeamToUser(
if (team == null) {
return Response.status(Response.Status.NOT_FOUND).entity("The team could not be found.").build();
}
- User principal = qm.getUser(username);
- if (principal == null) {
+ User user = qm.getUser(username);
+ if (user == null) {
return Response.status(Response.Status.NOT_FOUND).entity("The user could not be found.").build();
}
- final boolean modified = qm.addUserToTeam(principal, team);
- qm.getPersistenceManager().refresh(principal);
- principal = qm.getObjectById(principal.getClass(), principal.getId());
+ final boolean modified = qm.addUserToTeam(user, team);
+ user = qm.getObjectById(user.getClass(), user.getId());
if (modified) {
- super.logSecurityEvent(LOGGER, SecurityMarkers.SECURITY_AUDIT, "Added team membership for: " + principal.getName() + " / team: " + team.getName());
- return Response.ok(principal).build();
+ super.logSecurityEvent(LOGGER, SecurityMarkers.SECURITY_AUDIT, "Added team membership for: %s / team: %s".formatted(user.getUsername(), team.getName()));
+ return Response.ok(user).build();
} else {
return Response.status(Response.Status.NOT_MODIFIED).entity("The user is already a member of the specified team.").build();
}
@@ -778,15 +844,15 @@ public Response removeTeamFromUser(
if (team == null) {
return Response.status(Response.Status.NOT_FOUND).entity("The team could not be found.").build();
}
- User principal = qm.getUser(username);
- if (principal == null) {
+ User user = qm.getUser(username);
+ if (user == null) {
return Response.status(Response.Status.NOT_FOUND).entity("The user could not be found.").build();
}
- final boolean modified = qm.removeUserFromTeam(principal, team);
- principal = qm.getObjectById(principal.getClass(), principal.getId());
+ final boolean modified = qm.removeUserFromTeam(user, team);
+ user = qm.getObjectById(user.getClass(), user.getId());
if (modified) {
- super.logSecurityEvent(LOGGER, SecurityMarkers.SECURITY_AUDIT, "Removed team membership for: " + principal.getName() + " / team: " + team.getName());
- return Response.ok(principal).build();
+ super.logSecurityEvent(LOGGER, SecurityMarkers.SECURITY_AUDIT, "Removed team membership for: " + user.getName() + " / team: " + team.getName());
+ return Response.ok(user).build();
} else {
return Response.status(Response.Status.NOT_MODIFIED)
.entity("The user was not a member of the specified team.")
@@ -813,8 +879,15 @@ public Response setUserTeams(
@Parameter(description = "Username and list of UUIDs to assign to user", required = true) @Valid TeamsSetRequest request) {
try (QueryManager qm = new QueryManager()) {
return qm.callInTransaction(() -> {
- User principal = qm.getUser(request.username());
- if (principal == null) {
+ User user = qm.getUser(request.username(),
+ (Class extends User>) switch (StringUtils.defaultString(request.userType()).toLowerCase()) {
+ case "managed" -> ManagedUser.class;
+ case "ldap" -> LdapUser.class;
+ case "oidc" -> OidcUser.class;
+ default -> User.class;
+ });
+
+ if (user == null) {
return Response.status(Response.Status.NOT_FOUND).entity("The user could not be found.").build();
}
@@ -843,16 +916,16 @@ public Response setUserTeams(
return problem.toResponse();
}
- final List currentUserTeams = Objects.requireNonNullElse(principal.getTeams(), List.of());
+ final List currentUserTeams = Objects.requireNonNullElse(user.getTeams(), List.of());
if (currentUserTeams.equals(requestedTeams)) {
return Response.notModified().entity("The user is already a member of the selected team(s)").build();
}
- principal.setTeams(requestedTeams);
- qm.persist(principal);
+ user.setTeams(requestedTeams);
+ user = qm.persist(user);
super.logSecurityEvent(LOGGER, SecurityMarkers.SECURITY_AUDIT,
- "Added team membership for: " + principal.getName() + " / team: " + requestedTeams.toString());
- return Response.ok(principal).build();
+ "Added team membership for: " + user.getName() + " / team: " + requestedTeams.toString());
+ return Response.ok(user).build();
});
}
}
diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v1/vo/RolePermissionsSetRequest.java b/apiserver/src/main/java/org/dependencytrack/resources/v1/vo/RolePermissionsSetRequest.java
new file mode 100644
index 0000000000..cd1b215797
--- /dev/null
+++ b/apiserver/src/main/java/org/dependencytrack/resources/v1/vo/RolePermissionsSetRequest.java
@@ -0,0 +1,41 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+
+package org.dependencytrack.resources.v1.vo;
+
+import java.util.Set;
+
+import org.dependencytrack.auth.Permissions;
+import org.dependencytrack.model.validation.ValidUuid;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+
+public record RolePermissionsSetRequest(
+ @Schema(requiredMode = Schema.RequiredMode.REQUIRED)
+ @NotBlank
+ @ValidUuid
+ String role,
+
+ @Schema(requiredMode = Schema.RequiredMode.REQUIRED)
+ @NotNull
+ Set permissions) {
+}
diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v1/vo/TeamsSetRequest.java b/apiserver/src/main/java/org/dependencytrack/resources/v1/vo/TeamsSetRequest.java
index aefc8f1209..33474be159 100644
--- a/apiserver/src/main/java/org/dependencytrack/resources/v1/vo/TeamsSetRequest.java
+++ b/apiserver/src/main/java/org/dependencytrack/resources/v1/vo/TeamsSetRequest.java
@@ -35,6 +35,9 @@
import jakarta.validation.constraints.Pattern;
public record TeamsSetRequest(
+ @Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED)
+ String userType,
+
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank
@JsonDeserialize(using = TrimmedStringDeserializer.class)
diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v1/vo/UserPermissionsSetRequest.java b/apiserver/src/main/java/org/dependencytrack/resources/v1/vo/UserPermissionsSetRequest.java
index 4cac17ee55..0d09cdb025 100644
--- a/apiserver/src/main/java/org/dependencytrack/resources/v1/vo/UserPermissionsSetRequest.java
+++ b/apiserver/src/main/java/org/dependencytrack/resources/v1/vo/UserPermissionsSetRequest.java
@@ -35,6 +35,9 @@
import jakarta.validation.constraints.Pattern;
public record UserPermissionsSetRequest(
+ @Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED)
+ String userType,
+
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank
@JsonDeserialize(using = TrimmedStringDeserializer.class)
diff --git a/apiserver/src/main/java/org/dependencytrack/tasks/GitLabIntegrationStateTask.java b/apiserver/src/main/java/org/dependencytrack/tasks/GitLabIntegrationStateTask.java
new file mode 100644
index 0000000000..6feec71759
--- /dev/null
+++ b/apiserver/src/main/java/org/dependencytrack/tasks/GitLabIntegrationStateTask.java
@@ -0,0 +1,66 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.tasks;
+
+import alpine.common.logging.Logger;
+import alpine.event.framework.Event;
+import alpine.event.framework.LoggableSubscriber;
+import alpine.model.ConfigProperty;
+
+import org.dependencytrack.integrations.gitlab.GitLabIntegrationStateChanger;
+import org.dependencytrack.persistence.QueryManager;
+
+import static org.dependencytrack.model.ConfigPropertyConstants.GITLAB_ENABLED;
+
+import org.dependencytrack.event.GitLabIntegrationStateEvent;
+
+public class GitLabIntegrationStateTask implements LoggableSubscriber {
+
+ private static final Logger LOGGER = Logger.getLogger(GitLabIntegrationStateTask.class);
+ private final boolean isEnabled;
+
+ public GitLabIntegrationStateTask() {
+ try (final QueryManager qm = new QueryManager()) {
+ final ConfigProperty enabled = qm.getConfigProperty(GITLAB_ENABLED.getGroupName(), GITLAB_ENABLED.getPropertyName());
+
+ this.isEnabled = enabled != null && Boolean.parseBoolean(enabled.getPropertyValue());
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void inform(final Event event) {
+ if (!(event instanceof GitLabIntegrationStateEvent)) {
+ return;
+ }
+
+ LOGGER.info("Starting GitLab state change task");
+
+ try (QueryManager qm = new QueryManager()) {
+ GitLabIntegrationStateChanger stateChanger = new GitLabIntegrationStateChanger();
+ stateChanger.setQueryManager(qm);
+ stateChanger.setState(this.isEnabled);
+ }
+
+ LOGGER.info("GitLab state change complete");
+ }
+
+}
diff --git a/apiserver/src/main/java/org/dependencytrack/tasks/GitLabSyncTask.java b/apiserver/src/main/java/org/dependencytrack/tasks/GitLabSyncTask.java
new file mode 100644
index 0000000000..46a361d323
--- /dev/null
+++ b/apiserver/src/main/java/org/dependencytrack/tasks/GitLabSyncTask.java
@@ -0,0 +1,99 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.tasks;
+
+import alpine.common.logging.Logger;
+import alpine.event.framework.Event;
+import alpine.event.framework.LoggableSubscriber;
+import alpine.model.ConfigProperty;
+import alpine.model.OidcUser;
+import net.minidev.json.JSONArray;
+import net.minidev.json.JSONValue;
+
+import org.dependencytrack.event.GitLabSyncEvent;
+import org.dependencytrack.integrations.gitlab.GitLabClient;
+import org.dependencytrack.integrations.gitlab.GitLabSyncer;
+import org.dependencytrack.persistence.QueryManager;
+
+import static org.dependencytrack.model.ConfigPropertyConstants.GITLAB_ENABLED;
+import static org.dependencytrack.model.ConfigPropertyConstants.GITLAB_INCLUDE_ARCHIVED;
+import static org.dependencytrack.model.ConfigPropertyConstants.GITLAB_TOPICS;
+
+import java.util.List;
+
+public class GitLabSyncTask implements LoggableSubscriber {
+
+ private static final Logger LOGGER = Logger.getLogger(GitLabSyncTask.class);
+ private final boolean isEnabled;
+
+ public GitLabSyncTask() {
+ final String groupName = GITLAB_ENABLED.getGroupName();
+
+ try (final QueryManager qm = new QueryManager()) {
+ final ConfigProperty enabled = qm.getConfigProperty(groupName, GITLAB_ENABLED.getPropertyName());
+
+ this.isEnabled = enabled != null && Boolean.parseBoolean(enabled.getPropertyValue());
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void inform(final Event event) {
+ if (!(event instanceof GitLabSyncEvent && this.isEnabled)) {
+ return;
+ }
+
+ GitLabSyncEvent gitLabSyncEvent = (GitLabSyncEvent) event;
+
+ String accessToken = gitLabSyncEvent.getAccessToken();
+ if (accessToken == null || accessToken.isEmpty()) {
+ LOGGER.warn("GitLab syncing is enabled, but no access token was provided. Skipping.");
+ return;
+ }
+
+ LOGGER.info("Starting GitLab sync task");
+
+ try (QueryManager qm = new QueryManager()) {
+ final OidcUser user = qm.getUser(gitLabSyncEvent.getUser().getUsername(), OidcUser.class);
+ if (user == null) {
+ LOGGER.warn("GitLab syncing is enabled, but no authenticated user was provided. Skipping.");
+ return;
+ }
+
+ String topicsProperty = qm.getConfigProperty(
+ GITLAB_TOPICS.getGroupName(), GITLAB_TOPICS.getPropertyName()).getPropertyValue();
+ List topics = List.of(JSONValue.parse(topicsProperty, JSONArray.class).toArray(String[]::new));
+
+ String includeArchivedString = qm.getConfigProperty(
+ GITLAB_INCLUDE_ARCHIVED.getGroupName(), GITLAB_INCLUDE_ARCHIVED.getPropertyName())
+ .getPropertyValue();
+ boolean includeArchived = Boolean.parseBoolean(includeArchivedString);
+
+ GitLabClient gitLabClient = new GitLabClient(accessToken, topics, includeArchived);
+ GitLabSyncer syncer = new GitLabSyncer(user, gitLabClient);
+ syncer.setQueryManager(qm);
+ syncer.synchronize();
+ }
+
+ LOGGER.info("GitLab sync complete");
+ }
+
+}
diff --git a/apiserver/src/main/resources/META-INF/services/alpine.server.auth.OidcAuthenticationCustomizer b/apiserver/src/main/resources/META-INF/services/alpine.server.auth.OidcAuthenticationCustomizer
new file mode 100644
index 0000000000..c1b46c168e
--- /dev/null
+++ b/apiserver/src/main/resources/META-INF/services/alpine.server.auth.OidcAuthenticationCustomizer
@@ -0,0 +1 @@
+org.dependencytrack.integrations.gitlab.GitLabAuthenticationCustomizer
\ No newline at end of file
diff --git a/apiserver/src/main/resources/graphql/gitlab-projects.graphql b/apiserver/src/main/resources/graphql/gitlab-projects.graphql
new file mode 100644
index 0000000000..7d65fde941
--- /dev/null
+++ b/apiserver/src/main/resources/graphql/gitlab-projects.graphql
@@ -0,0 +1,45 @@
+fragment projectsWithTopics on Query {
+ withTopics: projects(
+ archived: $archived
+ topics: $topics
+ membership: true
+ first: 100
+ after: $cursor
+ ) {
+ ...projectFields
+ }
+}
+
+fragment projectsWithoutTopics on Query {
+ withoutTopics: projects(
+ archived: $archived
+ membership: true
+ first: 100
+ after: $cursor
+ ) {
+ ...projectFields
+ }
+}
+
+fragment projectFields on ProjectConnection {
+ nodes {
+ fullPath
+ maxAccessLevel {
+ stringValue
+ }
+ }
+ pageInfo {
+ endCursor
+ hasNextPage
+ }
+}
+
+query (
+ $cursor: String
+ $archived: ProjectArchived = EXCLUDE
+ $topics: [String!] = []
+ $includeTopics: Boolean! = false
+) {
+ ...projectsWithTopics @include(if: $includeTopics)
+ ...projectsWithoutTopics @skip(if: $includeTopics)
+}
diff --git a/apiserver/src/test/java/org/dependencytrack/event/GitLabSyncEventTest.java b/apiserver/src/test/java/org/dependencytrack/event/GitLabSyncEventTest.java
new file mode 100644
index 0000000000..d4a3c8f005
--- /dev/null
+++ b/apiserver/src/test/java/org/dependencytrack/event/GitLabSyncEventTest.java
@@ -0,0 +1,98 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+
+package org.dependencytrack.event;
+
+import alpine.model.OidcUser;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class GitLabSyncEventTest {
+
+ @Test
+ public void testDefaultConstructor() {
+ // Arrange and Act
+ GitLabSyncEvent event = new GitLabSyncEvent();
+
+ // Assert
+ Assert.assertNull(event.getAccessToken());
+ Assert.assertNull(event.getUser());
+ }
+
+ @Test
+ public void testParameterizedConstructor() {
+ // Arrange
+ String accessToken = "test-access-token";
+ OidcUser user = new OidcUser();
+
+ // Act
+ GitLabSyncEvent event = new GitLabSyncEvent(accessToken, user);
+
+ // Assert
+ Assert.assertEquals(accessToken, event.getAccessToken());
+ Assert.assertEquals(user, event.getUser());
+ }
+
+ @Test
+ public void testSettersAndGetters() {
+ // Arrange
+ GitLabSyncEvent event = new GitLabSyncEvent();
+ String accessToken = "test-access-token";
+ OidcUser user = new OidcUser();
+
+ // Act
+ event.setAccessToken(accessToken);
+ event.setUser(user);
+
+ // Assert
+ Assert.assertEquals(accessToken, event.getAccessToken());
+ Assert.assertEquals(user, event.getUser());
+ }
+
+ @Test
+ public void testToString() {
+ // Arrange
+ String accessToken = "test-access-token";
+ OidcUser user = new OidcUser();
+ GitLabSyncEvent event = new GitLabSyncEvent(accessToken, user);
+
+ // Act
+ String toString = event.toString();
+
+ // Assert
+ Assert.assertNotNull(toString);
+ Assert.assertTrue(toString.contains("GitLabSyncEvent"));
+ Assert.assertTrue(toString.contains(accessToken));
+ Assert.assertTrue(toString.contains(user.toString()));
+ }
+
+ @Test
+ public void testToStringWithNullValues() {
+ // Arrange
+ GitLabSyncEvent event = new GitLabSyncEvent();
+
+ // Act
+ String toString = event.toString();
+
+ // Assert
+ Assert.assertNotNull(toString);
+ Assert.assertTrue(toString.contains("GitLabSyncEvent"));
+ Assert.assertTrue(toString.contains("null"));
+ }
+}
diff --git a/apiserver/src/test/java/org/dependencytrack/integrations/PermissionsSyncerTest.java b/apiserver/src/test/java/org/dependencytrack/integrations/PermissionsSyncerTest.java
new file mode 100644
index 0000000000..fd153614f6
--- /dev/null
+++ b/apiserver/src/test/java/org/dependencytrack/integrations/PermissionsSyncerTest.java
@@ -0,0 +1,60 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.integrations;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import org.dependencytrack.persistence.QueryManager;
+
+public class PermissionsSyncerTest {
+
+ @Test
+ public final void testIsEnabled_True() {
+ PermissionsSyncer permissionsSyncer = mock(PermissionsSyncer.class);
+ when(permissionsSyncer.isEnabled()).thenReturn(true);
+ Assert.assertTrue(permissionsSyncer.isEnabled());
+ }
+
+ @Test
+ public final void testIsEnabled_False() {
+ PermissionsSyncer permissionsSyncer = mock(PermissionsSyncer.class);
+ when(permissionsSyncer.isEnabled()).thenReturn(false);
+ Assert.assertFalse(permissionsSyncer.isEnabled());
+ }
+
+ @Test
+ public final void testSetQueryManager() {
+ PermissionsSyncer permissionsSyncer = mock(PermissionsSyncer.class);
+ QueryManager queryManager = mock(QueryManager.class);
+ permissionsSyncer.setQueryManager(queryManager);
+ verify(permissionsSyncer).setQueryManager(queryManager);
+ }
+
+ @Test
+ public final void testSynchronize() {
+ PermissionsSyncer permissionsSyncer = mock(PermissionsSyncer.class);
+ permissionsSyncer.synchronize();
+ verify(permissionsSyncer).synchronize();
+ }
+}
diff --git a/apiserver/src/test/java/org/dependencytrack/integrations/gitlab/GitLabAuthenticationCustomizerTest.java b/apiserver/src/test/java/org/dependencytrack/integrations/gitlab/GitLabAuthenticationCustomizerTest.java
new file mode 100644
index 0000000000..00cc867ffd
--- /dev/null
+++ b/apiserver/src/test/java/org/dependencytrack/integrations/gitlab/GitLabAuthenticationCustomizerTest.java
@@ -0,0 +1,104 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.integrations.gitlab;
+
+import alpine.event.framework.Event;
+import alpine.model.OidcUser;
+import alpine.server.auth.OidcProfile;
+import org.junit.Assert;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class GitLabAuthenticationCustomizerTest {
+
+ @Test
+ public void testIsProfileComplete() {
+ GitLabAuthenticationCustomizer customizer = new GitLabAuthenticationCustomizer();
+ OidcProfile profile = mock(OidcProfile.class);
+ boolean teamSyncEnabled = true;
+
+ boolean result = customizer.isProfileComplete(profile, teamSyncEnabled);
+
+ Assert.assertTrue(result);
+ }
+
+ @Test
+ public void testOnAuthenticationSuccess() {
+ GitLabAuthenticationCustomizer customizer = new GitLabAuthenticationCustomizer();
+ OidcUser user = mock(OidcUser.class);
+ OidcProfile profile = mock(OidcProfile.class);
+ String idToken = "idToken";
+ String accessToken = "accessToken";
+
+ when(profile.getGroups()).thenReturn(new ArrayList<>());
+
+ OidcUser result = customizer.onAuthenticationSuccess(user, profile, idToken, accessToken);
+
+ Assert.assertEquals(user, result);
+
+ // Verify that the GitLabSyncEvent was dispatched
+ verify(Event.class, Mockito.times(1));
+ }
+
+ @Test
+ public void testOnAuthenticationSuccess_Groups() {
+ GitLabAuthenticationCustomizer customizer = new GitLabAuthenticationCustomizer();
+ OidcUser user = mock(OidcUser.class);
+ OidcProfile profile = mock(OidcProfile.class);
+ String idToken = "idToken";
+ String accessToken = "accessToken";
+ List groups = new ArrayList<>();
+ groups.add("group1");
+ groups.add("group2");
+
+ when(profile.getGroups()).thenReturn(groups);
+
+ OidcUser result = customizer.onAuthenticationSuccess(user, profile, idToken, accessToken);
+
+ Assert.assertEquals(user, result);
+
+ // Verify that the GitLabSyncEvent was dispatched
+ verify(Event.class, Mockito.times(1));
+ }
+
+ @Test
+ public void testOnAuthenticationSuccess_NullGroups() {
+ GitLabAuthenticationCustomizer customizer = new GitLabAuthenticationCustomizer();
+ OidcUser user = mock(OidcUser.class);
+ OidcProfile profile = mock(OidcProfile.class);
+ String idToken = "idToken";
+ String accessToken = "accessToken";
+
+ when(profile.getGroups()).thenReturn(null);
+
+ OidcUser result = customizer.onAuthenticationSuccess(user, profile, idToken, accessToken);
+
+ Assert.assertEquals(user, result);
+
+ // Verify that the GitLabSyncEvent was dispatched
+ verify(Event.class, Mockito.times(1));
+ }
+}
diff --git a/apiserver/src/test/java/org/dependencytrack/integrations/gitlab/GitLabClientTest.java b/apiserver/src/test/java/org/dependencytrack/integrations/gitlab/GitLabClientTest.java
new file mode 100644
index 0000000000..cad008d9f4
--- /dev/null
+++ b/apiserver/src/test/java/org/dependencytrack/integrations/gitlab/GitLabClientTest.java
@@ -0,0 +1,172 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.integrations.gitlab;
+
+import alpine.Config;
+import jakarta.ws.rs.core.MediaType;
+import net.minidev.json.JSONArray;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.ok;
+import static com.github.tomakehurst.wiremock.client.WireMock.post;
+import static com.github.tomakehurst.wiremock.client.WireMock.stubFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo;
+
+import com.github.tomakehurst.wiremock.junit.WireMockRule;
+import com.github.tomakehurst.wiremock.stubbing.Scenario;
+
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.net.URISyntaxException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.http.HttpHeaders;
+import org.dependencytrack.event.kafka.KafkaProducerInitializer;
+import org.junit.AfterClass;
+import org.junit.Assert;
+import org.junit.BeforeClass;
+import org.junit.Rule;
+import org.junit.Test;
+
+import static org.testcontainers.shaded.org.apache.commons.io.IOUtils.resourceToString;
+
+public class GitLabClientTest {
+
+ @Rule
+ public WireMockRule wireMockRule = new WireMockRule();
+
+ @BeforeClass
+ public static void beforeClass() {
+ Config.enableUnitTests();
+ }
+
+ @AfterClass
+ public static void after() {
+ KafkaProducerInitializer.tearDown();
+ }
+
+ @Test
+ public void testConstructorWithAccessToken() {
+ String accessToken = "my-access-token";
+ GitLabClient client = new GitLabClient(accessToken);
+ Assert.assertNotNull(client);
+ }
+
+ @Test
+ public void testConstructorWithAccessTokenAndConfig() {
+ String accessToken = "my-access-token";
+ Config config = Config.getInstance();
+ GitLabClient client = new GitLabClient(accessToken, config, null, false);
+ Assert.assertNotNull(client);
+ Assert.assertEquals("Dependency-Track", client.getConfig().getApplicationName());
+ }
+
+ @Test
+ public void testGetGitLabProjects() throws URISyntaxException, IOException {
+ String accessToken = "TEST_ACCESS_TOKEN";
+
+ String page1Result = resourceToString("/unit/gitlab-api-getgitlabprojects-response-page-1.json",
+ StandardCharsets.UTF_8);
+ String page2Result = resourceToString("/unit/gitlab-api-getgitlabprojects-response-page-2.json",
+ StandardCharsets.UTF_8);
+
+ stubFor(post(urlPathEqualTo("/api/graphql"))
+ .inScenario("test-get-gitlab-projects")
+ .whenScenarioStateIs(Scenario.STARTED)
+ .willReturn(ok().withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)
+ .withBody(page1Result))
+ .willSetStateTo("second-page"));
+
+ stubFor(post(urlPathEqualTo("/api/graphql"))
+ .inScenario("test-get-gitlab-projects")
+ .whenScenarioStateIs("second-page")
+ .willReturn(ok().withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)
+ .withBody(page2Result))
+ .willSetStateTo("Finished"));
+
+ final var configMock = mock(Config.class);
+
+ when(configMock.getProperty(eq(Config.AlpineKey.OIDC_ISSUER))).thenReturn(wireMockRule.baseUrl());
+
+ GitLabClient gitLabClient = new GitLabClient(accessToken, configMock, null, false);
+
+ List gitLabProjects = gitLabClient.getGitLabProjects();
+
+ Assert.assertNotNull(gitLabProjects);
+ Assert.assertEquals(4, gitLabProjects.size());
+
+ List actualProjectPaths = new ArrayList<>();
+ for (var project : gitLabProjects)
+ actualProjectPaths.add(project.getFullPath());
+
+ List expectedProjectPaths = Arrays.asList(
+ "test-group/test-subgroup/test-project-1",
+ "test-group/test-subgroup/test-project-2",
+ "test-group/test-subgroup-2/test-project-3",
+ "test-group/test-subgroup-2/test-project-4");
+
+ Assert.assertEquals(actualProjectPaths, expectedProjectPaths);
+ }
+
+ @Test
+ public void testGetGitLabProjectsWithTopics() throws IOException, URISyntaxException {
+ String accessToken = "TEST_ACCESS_TOKEN";
+
+ String result = resourceToString("/unit/gitlab-api-getgitlabprojects-topics-response.json",
+ StandardCharsets.UTF_8);
+
+ stubFor(post(urlPathEqualTo("/api/graphql"))
+ .willReturn(ok().withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)
+ .withBody(result)));
+
+ final var configMock = mock(Config.class);
+
+ when(configMock.getProperty(eq(Config.AlpineKey.OIDC_ISSUER))).thenReturn(wireMockRule.baseUrl());
+ List topics = Arrays.asList("topic1");
+
+ GitLabClient gitLabClient = new GitLabClient(accessToken, configMock, topics, false);
+
+ List gitLabProjects = gitLabClient.getGitLabProjects();
+
+ Assert.assertNotNull(gitLabProjects);
+ Assert.assertEquals(1, gitLabProjects.size());
+
+ Assert.assertEquals("project/with/topic", gitLabProjects.get(0).getFullPath());
+ }
+
+ @Test
+ public void testJsonToList() {
+ String accessToken = "my-access-token";
+ GitLabClient client = new GitLabClient(accessToken);
+ JSONArray jsonArray = new JSONArray();
+ jsonArray.add("item1");
+ jsonArray.add("item2");
+ List list = client.jsonToList(jsonArray);
+ Assert.assertNotNull(list);
+ Assert.assertEquals(2, list.size()); // assume 2 items are returned
+ Assert.assertEquals("item1", list.get(0));
+ Assert.assertEquals("item2", list.get(1));
+ }
+
+}
diff --git a/apiserver/src/test/java/org/dependencytrack/integrations/gitlab/GitLabIntegrationStateChangerTest.java b/apiserver/src/test/java/org/dependencytrack/integrations/gitlab/GitLabIntegrationStateChangerTest.java
new file mode 100644
index 0000000000..9bdffb5372
--- /dev/null
+++ b/apiserver/src/test/java/org/dependencytrack/integrations/gitlab/GitLabIntegrationStateChangerTest.java
@@ -0,0 +1,204 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.integrations.gitlab;
+
+import alpine.model.IConfigProperty;
+import alpine.model.Permission;
+import alpine.model.Team;
+
+import org.dependencytrack.PersistenceCapableTest;
+import org.dependencytrack.auth.Permissions;
+import org.dependencytrack.model.Role;
+
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.dependencytrack.model.ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED;
+import static org.dependencytrack.model.ConfigPropertyConstants.GITLAB_API_KEY;
+import static org.dependencytrack.model.ConfigPropertyConstants.GITLAB_ENABLED;
+import static org.dependencytrack.model.ConfigPropertyConstants.GITLAB_SBOM_PUSH_ENABLED;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class GitLabIntegrationStateChangerTest extends PersistenceCapableTest {
+
+ private GitLabIntegrationStateChanger stateChanger;
+ private List roles;
+
+ @Before
+ public void setUp() {
+ stateChanger = new GitLabIntegrationStateChanger();
+ }
+
+ /**
+ * Validates that the metadata is correctly defined.
+ */
+ @Test
+ public void testIntegrationStateChangerMetadata() {
+ Assert.assertEquals("GitLab Integration State Changer", stateChanger.name());
+ Assert.assertEquals("Executes GitLab integration enable and disable tasks", stateChanger.description());
+ }
+
+ /**
+ * Validates that the when integration is enabled the roles are created.
+ */
+ @Test
+ public void testEnable() {
+ qm.createPermission(Permissions.Constants.BOM_UPLOAD, "upload BOMs");
+ qm.createPermission(Permissions.Constants.VIEW_PORTFOLIO, "view portfolio");
+
+ stateChanger.setQueryManager(qm);
+ qm.createConfigProperty(
+ ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(),
+ ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(),
+ "false",
+ IConfigProperty.PropertyType.BOOLEAN,
+ null);
+
+ qm.createConfigProperty(
+ GITLAB_ENABLED.getGroupName(),
+ GITLAB_ENABLED.getPropertyName(),
+ "false",
+ IConfigProperty.PropertyType.BOOLEAN,
+ null);
+
+ qm.createConfigProperty(
+ GITLAB_API_KEY.getGroupName(),
+ GITLAB_API_KEY.getPropertyName(),
+ null,
+ GITLAB_API_KEY.getPropertyType(),
+ null);
+
+ stateChanger.setState(true);
+ roles = qm.getRoles();
+ Assert.assertEquals(GitLabRole.values().length, roles.size());
+ for (GitLabRole role : GitLabRole.values()) {
+ Assert.assertNotNull(qm.getRoleByName(role.getDescription()));
+ }
+ List teamsList = qm.getTeams().getList(Team.class);
+
+ Assert.assertEquals(teamsList.size(), 1);
+ Assert.assertEquals(teamsList.get(0).getName(), "GitLab Users");
+ }
+
+ /**
+ * Validates that the when integration is disabled the roles are removed.
+ */
+ @Test
+ public void testDisable() {
+ qm.createPermission(Permissions.Constants.BOM_UPLOAD, "upload BOMs");
+ qm.createPermission(Permissions.Constants.VIEW_PORTFOLIO, "view portfolio");
+
+ stateChanger.setQueryManager(qm);
+ qm.createConfigProperty(
+ GITLAB_ENABLED.getGroupName(),
+ GITLAB_ENABLED.getPropertyName(),
+ "true",
+ IConfigProperty.PropertyType.BOOLEAN,
+ null);
+ qm.createConfigProperty(
+ ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(),
+ ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(),
+ "false",
+ IConfigProperty.PropertyType.BOOLEAN,
+ null);
+ qm.createConfigProperty(
+ GITLAB_SBOM_PUSH_ENABLED.getGroupName(),
+ GITLAB_SBOM_PUSH_ENABLED.getPropertyName(),
+ "true",
+ IConfigProperty.PropertyType.BOOLEAN,
+ null);
+ qm.createConfigProperty(
+ GITLAB_API_KEY.getGroupName(),
+ GITLAB_API_KEY.getPropertyName(),
+ "test_api_key",
+ GITLAB_API_KEY.getPropertyType(),
+ null);
+
+ // Create roles and team to be removed
+ stateChanger.setState(true);
+ roles = qm.getRoles();
+ Assert.assertEquals(GitLabRole.values().length, roles.size());
+ for (GitLabRole role : GitLabRole.values()) {
+ Assert.assertNotNull(qm.getRoleByName(role.getDescription()));
+ }
+ List teamsList = qm.getTeams().getList(Team.class);
+ Assert.assertEquals(teamsList.size(), 1);
+ Assert.assertEquals(teamsList.get(0).getName(), "GitLab Users");
+
+ // Disable the integration
+ // and verify that the roles and team are removed
+ stateChanger.setState(false);
+ roles = qm.getRoles();
+ teamsList = qm.getTeams().getList(Team.class);
+ Assert.assertEquals(0, roles.size());
+ Assert.assertEquals(0, teamsList.size());
+ }
+
+ @Test
+ public void testPopulatePermissionsMap() {
+ final var mockStateChanger = mock(GitLabIntegrationStateChanger.class);
+
+ // Create Permission and add it to the map
+ Permission permission = new Permission();
+ permission.setName("VIEW_PORTFOLIO");
+ Map testPermissionsMap = new HashMap();
+ testPermissionsMap.put("VIEW_PORTFOLIO", permission);
+
+ // Set query manager and permissions map for mockStateChanger
+ mockStateChanger.setQueryManager(qm);
+ when(mockStateChanger.getPermissionsMap()).thenReturn(testPermissionsMap);
+
+ // Verify that the permission was added to the map
+ Map permissionsMap = mockStateChanger.getPermissionsMap();
+ Assert.assertEquals(permissionsMap.size(), 1);
+ Assert.assertTrue(permissionsMap.containsKey("VIEW_PORTFOLIO"));
+ Assert.assertTrue(permissionsMap.containsValue(permission));
+ }
+
+ @Test
+ public void testGetPermissionsMap() {
+ stateChanger.setQueryManager(qm);
+
+ // Test method call
+ Map permissionsMap = stateChanger.getPermissionsMap();
+
+ // Verify expected state
+ Assert.assertEquals(0, permissionsMap.size());
+ }
+
+ @Test
+ public void testGetPermissionsMapPopulated() {
+ qm.createPermission("testPermission", "Test Permission");
+
+ stateChanger.setQueryManager(qm);
+
+ // Test method call
+ stateChanger.getPermissionsMap();
+
+ // Verify expected state
+ Assert.assertEquals(1, stateChanger.getPermissionsMap().size());
+ Assert.assertTrue(stateChanger.getPermissionsMap().containsKey("testPermission"));
+ }
+}
diff --git a/apiserver/src/test/java/org/dependencytrack/integrations/gitlab/GitLabRoleTest.java b/apiserver/src/test/java/org/dependencytrack/integrations/gitlab/GitLabRoleTest.java
new file mode 100644
index 0000000000..2d732e8c72
--- /dev/null
+++ b/apiserver/src/test/java/org/dependencytrack/integrations/gitlab/GitLabRoleTest.java
@@ -0,0 +1,83 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.integrations.gitlab;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.util.Set;
+
+public class GitLabRoleTest {
+
+ @Test
+ public void testGetAccessLevel() {
+ Assert.assertEquals(10, GitLabRole.GUEST.getAccessLevel());
+ Assert.assertEquals(15, GitLabRole.PLANNER.getAccessLevel());
+ Assert.assertEquals(20, GitLabRole.REPORTER.getAccessLevel());
+ Assert.assertEquals(30, GitLabRole.DEVELOPER.getAccessLevel());
+ Assert.assertEquals(40, GitLabRole.MAINTAINER.getAccessLevel());
+ Assert.assertEquals(50, GitLabRole.OWNER.getAccessLevel());
+ }
+
+ @Test
+ public void testGetDescription() {
+ Assert.assertEquals("GitLab Project Guest", GitLabRole.GUEST.getDescription());
+ Assert.assertEquals("GitLab Project Planner", GitLabRole.PLANNER.getDescription());
+ Assert.assertEquals("GitLab Project Reporter", GitLabRole.REPORTER.getDescription());
+ Assert.assertEquals("GitLab Project Developer", GitLabRole.DEVELOPER.getDescription());
+ Assert.assertEquals("GitLab Project Maintainer", GitLabRole.MAINTAINER.getDescription());
+ Assert.assertEquals("GitLab Project Owner", GitLabRole.OWNER.getDescription());
+ }
+
+ @Test
+ public void testGetPermissionsForGuest() {
+ Set permissions = GitLabRole.GUEST.getPermissions();
+ Assert.assertEquals(2, permissions.size());
+ }
+
+ @Test
+ public void testGetPermissionsForPlanner() {
+ Set permissions = GitLabRole.PLANNER.getPermissions();
+ Assert.assertEquals(3, permissions.size());
+ }
+
+ @Test
+ public void testGetPermissionsForReporter() {
+ Set permissions = GitLabRole.REPORTER.getPermissions();
+ Assert.assertEquals(4, permissions.size());
+ }
+
+ @Test
+ public void testGetPermissionsForDeveloper() {
+ Set permissions = GitLabRole.DEVELOPER.getPermissions();
+ Assert.assertEquals(8, permissions.size());
+ }
+
+ @Test
+ public void testGetPermissionsForMaintainer() {
+ Set permissions = GitLabRole.MAINTAINER.getPermissions();
+ Assert.assertEquals(12, permissions.size());
+ }
+
+ @Test
+ public void testGetPermissionsForOwner() {
+ Set permissions = GitLabRole.OWNER.getPermissions();
+ Assert.assertEquals(13, permissions.size());
+ }
+}
diff --git a/apiserver/src/test/java/org/dependencytrack/integrations/gitlab/GitLabSyncerTest.java b/apiserver/src/test/java/org/dependencytrack/integrations/gitlab/GitLabSyncerTest.java
new file mode 100644
index 0000000000..7f8c443064
--- /dev/null
+++ b/apiserver/src/test/java/org/dependencytrack/integrations/gitlab/GitLabSyncerTest.java
@@ -0,0 +1,149 @@
+/*
+ * This file is part of Dependency-Track.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright (c) OWASP Foundation. All Rights Reserved.
+ */
+package org.dependencytrack.integrations.gitlab;
+
+import org.junit.Assert;
+import alpine.model.IConfigProperty;
+import alpine.model.OidcUser;
+import java.net.URISyntaxException;
+import java.io.IOException;
+import org.dependencytrack.PersistenceCapableTest;
+import org.dependencytrack.model.Project;
+import org.dependencytrack.model.UserProjectRole;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.mock;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.dependencytrack.model.ConfigPropertyConstants.GITLAB_ENABLED;
+
+/**
+ * This test suite validates the integration with the GitLab API.
+ */
+@RunWith(MockitoJUnitRunner.class)
+public class GitLabSyncerTest extends PersistenceCapableTest {
+
+ @Mock
+ private OidcUser user;
+
+ @Mock
+ private GitLabClient gitLabClient;
+
+ @InjectMocks
+ private GitLabSyncer gitLabSyncer;
+
+ /**
+ * Validates that the integration metadata is correctly defined.
+ */
+ @Test
+ public void testIntegrationMetadata() {
+ GitLabSyncer extension = new GitLabSyncer(user, gitLabClient);
+ Assert.assertEquals("GitLab", extension.name());
+ Assert.assertEquals("Synchronizes user permissions from connected GitLab instance", extension.description());
+ }
+
+ /**
+ * Validates that the integration is enabled when the GITLAB_ENABLED property is
+ * set to true.
+ */
+ @Test
+ public void testIsEnabled() {
+ qm.createConfigProperty(
+ GITLAB_ENABLED.getGroupName(),
+ GITLAB_ENABLED.getPropertyName(),
+ "true",
+ IConfigProperty.PropertyType.BOOLEAN,
+ null);
+ GitLabSyncer extension = new GitLabSyncer(user, gitLabClient);
+ extension.setQueryManager(qm);
+ Assert.assertTrue(extension.isEnabled());
+ }
+
+ /**
+ * Validates that the integration is disabled when the GITLAB_ENABLED property
+ * is set to false.
+ */
+ @Test
+ public void testIsDisabled() {
+ qm.createConfigProperty(
+ GITLAB_ENABLED.getGroupName(),
+ GITLAB_ENABLED.getPropertyName(),
+ "false",
+ IConfigProperty.PropertyType.BOOLEAN,
+ null);
+ GitLabSyncer extension = new GitLabSyncer(user, gitLabClient);
+ extension.setQueryManager(qm);
+ Assert.assertFalse(extension.isEnabled());
+ }
+
+ /**
+ * Validates that the synchronize method is correctly executed when the
+ * integration is enabled.
+ */
+ @Test
+ public void testSynchronizeSuccess() {
+ qm.createRole("GitLab Project Guest", new ArrayList<>());
+ qm.createRole("GitLab Project Maintainer", new ArrayList<>());
+ qm.createRole("GitLab Project Reporter", new ArrayList<>());
+ qm.createRole("GitLab Project Developer", new ArrayList<>());
+ qm.createRole("GitLab Project Planner", new ArrayList<>());
+ qm.createRole("GitLab Project Owner", new ArrayList<>());
+
+ qm.createConfigProperty(
+ GITLAB_ENABLED.getGroupName(),
+ GITLAB_ENABLED.getPropertyName(),
+ "true",
+ IConfigProperty.PropertyType.BOOLEAN,
+ null);
+
+ GitLabClient mockClient = mock(GitLabClient.class);
+ GitLabSyncer extension = new GitLabSyncer(qm.createOidcUser("test_user"), mockClient);
+ extension.setQueryManager(qm);
+
+ try {
+ when(mockClient.getGitLabProjects())
+ .thenReturn(List.of(
+ new GitLabProject("this/test/project1", GitLabRole.MAINTAINER),
+ new GitLabProject("that/test/project2", GitLabRole.REPORTER)));
+ extension.synchronize();
+ } catch (IOException | URISyntaxException ex) {
+ Assert.fail("Exception " + ex);
+ }
+
+ Project testProject1 = qm.getProject("this/test/project1", null);
+ Assert.assertFalse(testProject1.isActive());
+
+ Project testProject2 = qm.getProject("that/test/project2", null);
+ Assert.assertFalse(testProject2.isActive());
+
+ List testRoles = qm.getUserRoles("test_user");
+ Assert.assertEquals(2, testRoles.size());
+ Assert.assertEquals("this/test/project1", testRoles.get(0).getProject().getName());
+ Assert.assertEquals("GitLab Project Maintainer", testRoles.get(0).getRole().getName());
+ Assert.assertEquals("that/test/project2", testRoles.get(1).getProject().getName());
+ Assert.assertEquals("GitLab Project Reporter", testRoles.get(1).getRole().getName());
+ }
+}
diff --git a/apiserver/src/test/java/org/dependencytrack/resources/v1/PermissionResourceTest.java b/apiserver/src/test/java/org/dependencytrack/resources/v1/PermissionResourceTest.java
index 68ef8f7e16..0f4778baf5 100644
--- a/apiserver/src/test/java/org/dependencytrack/resources/v1/PermissionResourceTest.java
+++ b/apiserver/src/test/java/org/dependencytrack/resources/v1/PermissionResourceTest.java
@@ -564,4 +564,82 @@ public void removePermissionFromRoleNoChangesTest() {
Assert.assertEquals(304, response.getStatus(), 0);
Assert.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER));
}
+
+ @Test
+ public void setRolePermissionsTest() {
+ // Arrange: create a role and permissions
+ Role role = qm.createRole("testRole", Collections.emptyList());
+ String roleUuid = role.getUuid().toString();
+
+ List permissionSet1 = List.of(
+ qm.getPermission("ACCESS_MANAGEMENT"),
+ qm.getPermission("ACCESS_MANAGEMENT_CREATE"),
+ qm.getPermission("ACCESS_MANAGEMENT_DELETE"));
+
+ List permissionSet2 = List.of(
+ qm.getPermission("BOM_UPLOAD"),
+ qm.getPermission("VIEW_PORTFOLIO"),
+ qm.getPermission("PORTFOLIO_MANAGEMENT"));
+
+ JsonObject request1 = Json.createObjectBuilder()
+ .add("role", roleUuid)
+ .add("permissions", Json.createArrayBuilder(permissionSet1.stream().map(Permission::getName).toList()))
+ .build();
+
+ JsonObject request2 = Json.createObjectBuilder()
+ .add("role", roleUuid)
+ .add("permissions", Json.createArrayBuilder(permissionSet2.stream().map(Permission::getName).toList()))
+ .build();
+
+ String endpoint = V1_PERMISSION + "/role";
+
+ // Act & Assert: assign first set
+ Response response = jersey.target(endpoint)
+ .request()
+ .header(X_API_KEY, apiKey)
+ .put(Entity.entity(request1.toString(), MediaType.APPLICATION_JSON));
+ Assert.assertEquals(200, response.getStatus());
+ Role updatedRole = qm.getObjectByUuid(Role.class, role.getUuid());
+ Assert.assertTrue(updatedRole.getPermissions().containsAll(permissionSet1));
+
+ // Assign second set (replace)
+ response = jersey.target(endpoint)
+ .request()
+ .header(X_API_KEY, apiKey)
+ .put(Entity.entity(request2.toString(), MediaType.APPLICATION_JSON));
+ Assert.assertEquals(200, response.getStatus());
+ updatedRole = qm.getObjectByUuid(Role.class, role.getUuid());
+ Assert.assertTrue(updatedRole.getPermissions().containsAll(permissionSet2));
+ Assert.assertTrue(updatedRole.getPermissions().stream().noneMatch(p -> permissionSet1.contains(p)));
+
+ // Assign same set again (should return 304)
+ response = jersey.target(endpoint)
+ .request()
+ .header(X_API_KEY, apiKey)
+ .put(Entity.entity(request2.toString(), MediaType.APPLICATION_JSON));
+ Assert.assertEquals(304, response.getStatus());
+
+ // Invalid role UUID
+ JsonObject invalidRoleRequest = Json.createObjectBuilder()
+ .add("role", UUID.randomUUID().toString())
+ .add("permissions", Json.createArrayBuilder(permissionSet1.stream().map(Permission::getName).toList()))
+ .build();
+ response = jersey.target(endpoint)
+ .request()
+ .header(X_API_KEY, apiKey)
+ .put(Entity.entity(invalidRoleRequest.toString(), MediaType.APPLICATION_JSON));
+ Assert.assertEquals(404, response.getStatus());
+
+ // Invalid permissions
+ JsonObject invalidPermRequest = Json.createObjectBuilder()
+ .add("role", roleUuid)
+ .add("permissions", Json.createArrayBuilder().add("INVALID_PERMISSION"))
+ .build();
+ response = jersey.target(endpoint)
+ .request()
+ .header(X_API_KEY, apiKey)
+ .put(Entity.entity(invalidPermRequest.toString(), MediaType.APPLICATION_JSON));
+ Assert.assertEquals(400, response.getStatus());
+ }
+
}
diff --git a/apiserver/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java b/apiserver/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java
index 6ad420b90d..63717c3d97 100644
--- a/apiserver/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java
+++ b/apiserver/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java
@@ -27,14 +27,6 @@
import alpine.server.filters.ApiFilter;
import alpine.server.filters.AuthenticationFeature;
import com.github.packageurl.PackageURL;
-import jakarta.json.Json;
-import jakarta.json.JsonArray;
-import jakarta.json.JsonObject;
-import jakarta.json.JsonObjectBuilder;
-import jakarta.ws.rs.HttpMethod;
-import jakarta.ws.rs.client.Entity;
-import jakarta.ws.rs.core.MediaType;
-import jakarta.ws.rs.core.Response;
import org.dependencytrack.JerseyTestRule;
import org.dependencytrack.ResourceTest;
import org.dependencytrack.auth.Permissions;
@@ -79,6 +71,14 @@
import org.junit.ClassRule;
import org.junit.Test;
+import jakarta.json.Json;
+import jakarta.json.JsonArray;
+import jakarta.json.JsonObject;
+import jakarta.json.JsonObjectBuilder;
+import jakarta.ws.rs.HttpMethod;
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
diff --git a/apiserver/src/test/java/org/dependencytrack/resources/v1/UserResourceAuthenticatedTest.java b/apiserver/src/test/java/org/dependencytrack/resources/v1/UserResourceAuthenticatedTest.java
index f2dd8708b2..44b2b1375d 100644
--- a/apiserver/src/test/java/org/dependencytrack/resources/v1/UserResourceAuthenticatedTest.java
+++ b/apiserver/src/test/java/org/dependencytrack/resources/v1/UserResourceAuthenticatedTest.java
@@ -47,6 +47,7 @@
import jakarta.ws.rs.client.Entity;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
+
import java.time.Duration;
import java.util.Collections;
import java.util.List;
@@ -80,6 +81,44 @@ public void before() throws Exception {
qm.addUserToTeam(testUser, team);
}
+ @Test
+ public void getUsersTest() {
+ qm.createLdapUser("testldapuser");
+ qm.createOidcUser("testoidcuser");
+ Response responseAll = jersey.target(V1_USER)
+ .request()
+ .header(X_API_KEY, apiKey)
+ .get(Response.class);
+
+ Response responseLdap = jersey.target(V1_USER)
+ .queryParam("type", "ldap")
+ .request()
+ .header(X_API_KEY, apiKey)
+ .get(Response.class);
+
+ Response responseManaged = jersey.target(V1_USER)
+ .queryParam("type", "managed")
+ .request()
+ .header(X_API_KEY, apiKey)
+ .get(Response.class);
+
+ Response responseOidc = jersey.target(V1_USER)
+ .queryParam("type", "oidc")
+ .request()
+ .header(X_API_KEY, apiKey)
+ .get(Response.class);
+ // add response values to a list called allResonses
+ List statuses = List.of(
+ responseAll.getStatus(), responseLdap.getStatus(),
+ responseManaged.getStatus(), responseOidc.getStatus()
+ );
+ List expectedStatuses = List.of(200, 200, 200, 200);
+ Assert.assertEquals(expectedStatuses, statuses);
+
+ JsonArray users = parseJsonArray(responseAll);
+ Assert.assertTrue(users.toArray().length >= 3);
+ }
+
@Test
public void getManagedUsersTest() {
for (int i=0; i<1000; i++) {
diff --git a/apiserver/src/test/resources/unit/gitlab-api-getgitlabprojects-response-page-1.json b/apiserver/src/test/resources/unit/gitlab-api-getgitlabprojects-response-page-1.json
new file mode 100644
index 0000000000..0665e36d9d
--- /dev/null
+++ b/apiserver/src/test/resources/unit/gitlab-api-getgitlabprojects-response-page-1.json
@@ -0,0 +1,33 @@
+{
+ "data": {
+ "withoutTopics": {
+ "nodes": [
+ {
+ "name": "test-project-1",
+ "fullPath": "test-group/test-subgroup/test-project-1",
+ "maxAccessLevel": {
+ "stringValue": "DEVELOPER"
+ }
+ },
+ {
+ "name": "test-project-2",
+ "fullPath": "test-group/test-subgroup/test-project-2",
+ "maxAccessLevel": {
+ "stringValue": "REPORTER"
+ }
+ },
+ {
+ "name": "test-project-3",
+ "fullPath": "test-group/test-subgroup-2/test-project-3",
+ "maxAccessLevel": {
+ "stringValue": "REPORTER"
+ }
+ }
+ ],
+ "pageInfo": {
+ "endCursor": "eyJpZCI6Ijk3ODU2In0",
+ "hasNextPage": true
+ }
+ }
+ }
+}
diff --git a/apiserver/src/test/resources/unit/gitlab-api-getgitlabprojects-response-page-2.json b/apiserver/src/test/resources/unit/gitlab-api-getgitlabprojects-response-page-2.json
new file mode 100644
index 0000000000..53ceb1ba08
--- /dev/null
+++ b/apiserver/src/test/resources/unit/gitlab-api-getgitlabprojects-response-page-2.json
@@ -0,0 +1,19 @@
+{
+ "data": {
+ "withoutTopics": {
+ "nodes": [
+ {
+ "name": "test-project-4",
+ "fullPath": "test-group/test-subgroup-2/test-project-4",
+ "maxAccessLevel": {
+ "stringValue": "DEVELOPER"
+ }
+ }
+ ],
+ "pageInfo": {
+ "endCursor": "zzzzzzzzzzzzzzzzzzzz",
+ "hasNextPage": false
+ }
+ }
+ }
+}
diff --git a/apiserver/src/test/resources/unit/gitlab-api-getgitlabprojects-topics-response.json b/apiserver/src/test/resources/unit/gitlab-api-getgitlabprojects-topics-response.json
new file mode 100644
index 0000000000..c23aa9c50a
--- /dev/null
+++ b/apiserver/src/test/resources/unit/gitlab-api-getgitlabprojects-topics-response.json
@@ -0,0 +1,19 @@
+{
+ "data": {
+ "withTopics": {
+ "nodes": [
+ {
+ "fullPath": "project/with/topic",
+ "maxAccessLevel": {
+ "stringValue": "MAINTAINER"
+ }
+ }
+ ],
+ "pageInfo": {
+ "endCursor": "eyJpZCI6IjgyMTY0In0",
+ "hasNextPage": false
+ }
+ }
+ },
+ "correlationId": "01JX0Y94A7FMG49C7BWT9GERP7"
+}
diff --git a/persistence-jooq/src/main/java/org/dependencytrack/persistence/jooq/generated/DefaultSchema.java b/persistence-jooq/src/main/java/org/dependencytrack/persistence/jooq/generated/DefaultSchema.java
index b9b2ebc08a..bf2d135275 100644
--- a/persistence-jooq/src/main/java/org/dependencytrack/persistence/jooq/generated/DefaultSchema.java
+++ b/persistence-jooq/src/main/java/org/dependencytrack/persistence/jooq/generated/DefaultSchema.java
@@ -4,9 +4,6 @@
package org.dependencytrack.persistence.jooq.generated;
-import java.util.Arrays;
-import java.util.List;
-
import org.dependencytrack.persistence.jooq.generated.tables.AffectedVersionAttribution;
import org.dependencytrack.persistence.jooq.generated.tables.Analysis;
import org.dependencytrack.persistence.jooq.generated.tables.AnalysisComment;
@@ -79,6 +76,9 @@
import org.jooq.impl.DSL;
import org.jooq.impl.SchemaImpl;
+import java.util.Arrays;
+import java.util.List;
+
/**
* standard public schema
diff --git a/persistence-jooq/src/main/java/org/dependencytrack/persistence/jooq/generated/Routines.java b/persistence-jooq/src/main/java/org/dependencytrack/persistence/jooq/generated/Routines.java
index fb3213fa03..85ef720f61 100644
--- a/persistence-jooq/src/main/java/org/dependencytrack/persistence/jooq/generated/Routines.java
+++ b/persistence-jooq/src/main/java/org/dependencytrack/persistence/jooq/generated/Routines.java
@@ -4,9 +4,6 @@
package org.dependencytrack.persistence.jooq.generated;
-import java.math.BigDecimal;
-import java.util.UUID;
-
import org.dependencytrack.persistence.jooq.generated.routines.CalcRiskScore;
import org.dependencytrack.persistence.jooq.generated.routines.HasProjectAccess;
import org.dependencytrack.persistence.jooq.generated.routines.HasUserProjectAccess;
@@ -18,6 +15,9 @@
import org.jooq.Field;
import org.jooq.JSONB;
+import java.math.BigDecimal;
+import java.util.UUID;
+
/**
* Convenience access to all stored procedures and functions in the default
diff --git a/persistence-migration/src/main/resources/migration/changelog-procedures.xml b/persistence-migration/src/main/resources/migration/changelog-procedures.xml
index 31f4593b2e..ab16a233e3 100644
--- a/persistence-migration/src/main/resources/migration/changelog-procedures.xml
+++ b/persistence-migration/src/main/resources/migration/changelog-procedures.xml
@@ -2,11 +2,8 @@