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> keys = (List>) keysObject; + for (Map keyMap : keys) { + JSONObject jsonKey = new JSONObject(); + jsonKey.put("kty", keyMap.get("kty")); + jsonKey.put("alg", keyMap.get("alg")); + jsonKey.put("use", keyMap.get("use")); + jsonKey.put("kid", keyMap.get("kid")); + jsonKey.put("n", keyMap.get("n")); + jsonKey.put("e", keyMap.get("e")); + + if (jsonKey.get("kid").equals(kid)) { + if (!jsonKey.containsKey("n") || !jsonKey.containsKey("e")) + throw new IllegalArgumentException("Missing modulus 'n' or exponent 'e' in JWKS key: " + jsonKey); + + RSAPublicKeySpec spec = new RSAPublicKeySpec( + new BigInteger(1, Base64.getUrlDecoder().decode(jsonKey.get("n").toString())), + new BigInteger(1, Base64.getUrlDecoder().decode(jsonKey.get("e").toString())) + ); + + return KeyFactory.getInstance("RSA").generatePublic(spec); + } + } + + throw new IllegalArgumentException("Public key not found for kid: " + kid); + } + + // JSONArray to ArrayList simple converter + public ArrayList jsonToList(final JSONArray jsonArray) { + ArrayList list = new ArrayList<>(); + + for (Object o : jsonArray != null ? jsonArray : Collections.emptyList()) + list.add(o.toString()); + + return list; + } + + public Config getConfig() { + return config; + } +} diff --git a/apiserver/src/main/java/org/dependencytrack/integrations/gitlab/GitLabIntegrationStateChanger.java b/apiserver/src/main/java/org/dependencytrack/integrations/gitlab/GitLabIntegrationStateChanger.java new file mode 100644 index 0000000000..949541d7a1 --- /dev/null +++ b/apiserver/src/main/java/org/dependencytrack/integrations/gitlab/GitLabIntegrationStateChanger.java @@ -0,0 +1,185 @@ +/* + * 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.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import org.dependencytrack.auth.Permissions; +import org.dependencytrack.integrations.AbstractIntegrationPoint; +import org.dependencytrack.model.ConfigPropertyConstants; +import org.dependencytrack.model.Role; +import org.dependencytrack.persistence.QueryManager; + +import alpine.common.logging.Logger; +import alpine.model.ApiKey; +import alpine.model.ConfigProperty; +import alpine.model.Permission; +import alpine.model.Team; +import alpine.security.crypto.DataEncryption; + +public class GitLabIntegrationStateChanger extends AbstractIntegrationPoint { + + private static final Logger LOGGER = Logger.getLogger(GitLabIntegrationStateChanger.class); + private static final String DEFAULT_TEAM = "GitLab Users"; + private final Map PERMISSIONS_MAP = new HashMap<>(); + + @Override + public String name() { + return "GitLab Integration State Changer"; + } + + @Override + public String description() { + return "Executes GitLab integration enable and disable tasks"; + } + + public void setState(boolean isEnabled) { + try { + if (isEnabled) { + LOGGER.info("Enabling GitLab integration"); + setConfigProperty(ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED, "true"); + createGitLabRoles(); + createGitLabDefaultTeam(); + + return; + } + + LOGGER.info("Disabling GitLab integration"); + removeGitLabRoles(); + removeGitLabDefaultTeam(); + setConfigProperty(ConfigPropertyConstants.GITLAB_SBOM_PUSH_ENABLED, "false"); + } catch (RuntimeException ex) { + LOGGER.error("An error occurred while changing GitLab Integration State", ex); + handleException(LOGGER, ex); + } + } + + private void createGitLabDefaultTeam() { + try { + if (qm.getTeam(DEFAULT_TEAM) != null) { + LOGGER.info("GitLab Users team already exists"); + return; + } + + final Team team = qm.createTeam(DEFAULT_TEAM, List.of( + qm.getPermission(Permissions.Constants.BOM_UPLOAD), + qm.getPermission(Permissions.Constants.VIEW_PORTFOLIO))); + + LOGGER.info("Created GitLab default user team"); + + final ApiKey apiKey = qm.createApiKey(team); + + setConfigProperty(ConfigPropertyConstants.GITLAB_API_KEY, DataEncryption.encryptAsString(apiKey.getKey())); + } catch (Exception ex) { + LOGGER.error("An error occurred while creating GitLab default user team", ex); + throw new RuntimeException("Failed to create default team for GitLab users", ex); + } + } + + private void createGitLabRoles() { + if (PERMISSIONS_MAP.isEmpty()) + populatePermissionsMap(qm); + + for (GitLabRole role : GitLabRole.values()) + try { + if (qm.getRoleByName(role.getDescription()) == null) { + qm.createRole(role.getDescription(), qm.getPermissionsByName(role.getPermissions())); + LOGGER.info("Created GitLab role: " + role.getDescription()); + } else { + LOGGER.info("GitLab role already exists: " + role.getDescription()); + } + } catch (Exception ex) { + LOGGER.error("An error occurred while creating GitLab roles", ex); + } + } + + private void removeGitLabDefaultTeam() { + try (final QueryManager qm = new QueryManager()) { + final Team team = qm.getTeam(DEFAULT_TEAM); + + if (team == null) { + LOGGER.info("GitLab default team does not exist"); + return; + } + + for (ApiKey key : team.getApiKeys()) + if (key != null) { + qm.delete(key); + LOGGER.info("Removed API key for GitLab team"); + } + + qm.delete(team); + LOGGER.info("Removed default GitLab team"); + + setConfigProperty(ConfigPropertyConstants.GITLAB_API_KEY, null); + } catch (Exception ex) { + LOGGER.error("An error occurred while removing GitLab team", ex); + throw new RuntimeException("Failed to remove GitLab team", ex); + } + + } + + private void removeGitLabRoles() { + try (final QueryManager qm = new QueryManager()) { + for (GitLabRole role : GitLabRole.values()) { + Role targetRole = qm.getRoleByName(role.getDescription()); + if (targetRole == null) { + LOGGER.info("GitLab role does not exist: " + role.getDescription()); + continue; + } + + qm.delete(targetRole); + LOGGER.info("Removed GitLab role: " + role.getDescription()); + } + + } catch (Exception ex) { + LOGGER.error("An error occurred while removing GitLab roles", ex); + throw new RuntimeException("Failed to remove GitLab roles", ex); + } + + } + + private void populatePermissionsMap(QueryManager qm) { + // Retrieve all permissions from the database + List allPermissions = Objects.requireNonNullElse(qm.getPermissions(), Collections.emptyList()); + + // Add all permissions to the PERMISSIONS_MAP + for (Permission permission : allPermissions) { + PERMISSIONS_MAP.put(permission.getName(), permission); + } + } + + private void setConfigProperty(ConfigPropertyConstants cpc, String value) { + final ConfigProperty property = qm.getConfigProperty(cpc.getGroupName(), cpc.getPropertyName()); + property.setPropertyValue(value); + + qm.persist(property); + } + + public Map getPermissionsMap() { + if (PERMISSIONS_MAP.isEmpty()) { + populatePermissionsMap(qm); + } + return PERMISSIONS_MAP; + } +} diff --git a/apiserver/src/main/java/org/dependencytrack/integrations/gitlab/GitLabRole.java b/apiserver/src/main/java/org/dependencytrack/integrations/gitlab/GitLabRole.java new file mode 100644 index 0000000000..5856c323c2 --- /dev/null +++ b/apiserver/src/main/java/org/dependencytrack/integrations/gitlab/GitLabRole.java @@ -0,0 +1,85 @@ +/* + * 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.util.LinkedHashSet; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.dependencytrack.auth.Permissions; + +/** + * Definitions of access levels/roles as defined by GitLab. + * + * @see GitLab Roles + */ +public enum GitLabRole { + + GUEST(10, "GitLab Project Guest", Set.of( // Applies to private and internal projects only + Permissions.Constants.VIEW_BADGES, + Permissions.Constants.VIEW_PORTFOLIO)), + PLANNER(15, "GitLab Project Planner", Set.of( + Permissions.Constants.VIEW_VULNERABILITY)), + REPORTER(20, "GitLab Project Reporter", Set.of( + Permissions.Constants.VIEW_POLICY_VIOLATION)), + DEVELOPER(30, "GitLab Project Developer", Set.of( + Permissions.Constants.BOM_UPLOAD, + Permissions.Constants.PORTFOLIO_MANAGEMENT_READ, + Permissions.Constants.PROJECT_CREATION_UPLOAD, + Permissions.Constants.VULNERABILITY_ANALYSIS_READ)), + MAINTAINER(40, "GitLab Project Maintainer", Set.of( + Permissions.Constants.POLICY_VIOLATION_ANALYSIS, + Permissions.Constants.PORTFOLIO_MANAGEMENT_UPDATE, + Permissions.Constants.VULNERABILITY_ANALYSIS_CREATE, + Permissions.Constants.VULNERABILITY_ANALYSIS_UPDATE)), + OWNER(50, "GitLab Project Owner", Set.of( + Permissions.Constants.PORTFOLIO_MANAGEMENT_DELETE)); + + private final int accessLevel; + private final String description; + private final Set permissions; + + GitLabRole(final int accessLevel, final String description, final Set permissions) { + this.accessLevel = accessLevel; + this.description = description; + this.permissions = permissions; + } + + public int getAccessLevel() { + return accessLevel; + } + + public String getDescription() { + return description; + } + + /** + * Get a set of permissions consisting of this role's permissions + * combined with permissions from the roles with lesser access levels. + * + * @return A sorted set of permissions for this role. + */ + public Set getPermissions() { + return Stream.of(GitLabRole.values()) + .filter(value -> value.accessLevel <= this.accessLevel) // Include current and lower access levels + .flatMap(value -> value.permissions.stream()) // Flatten permissions from all roles + .collect(Collectors.toCollection(LinkedHashSet::new)); // Collect into a LinkedHashSet to maintain order + } +} diff --git a/apiserver/src/main/java/org/dependencytrack/integrations/gitlab/GitLabSyncer.java b/apiserver/src/main/java/org/dependencytrack/integrations/gitlab/GitLabSyncer.java new file mode 100644 index 0000000000..faea29b952 --- /dev/null +++ b/apiserver/src/main/java/org/dependencytrack/integrations/gitlab/GitLabSyncer.java @@ -0,0 +1,120 @@ +/* + * 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 static org.dependencytrack.model.ConfigPropertyConstants.GENERAL_BASE_URL; +import static org.dependencytrack.model.ConfigPropertyConstants.GITLAB_ENABLED; + +import java.net.URISyntaxException; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.dependencytrack.integrations.AbstractIntegrationPoint; +import org.dependencytrack.integrations.PermissionsSyncer; +import org.dependencytrack.model.Project; +import org.dependencytrack.model.Role; + +import alpine.common.logging.Logger; +import alpine.model.OidcUser; + +public class GitLabSyncer extends AbstractIntegrationPoint implements PermissionsSyncer { + + private static final Logger LOGGER = Logger.getLogger(GitLabSyncer.class); + private static final String INTEGRATIONS_GROUP = GITLAB_ENABLED.getGroupName(); + private static final String GENERAL_GROUP = GENERAL_BASE_URL.getGroupName(); + private static final String ROLE_CLAIM_PREFIX = "https://gitlab.org/claims/groups/"; + + private final OidcUser user; + + private GitLabClient gitLabClient; + + public GitLabSyncer(final OidcUser user, final GitLabClient gitlabClient) { + this.user = user; + this.gitLabClient = gitlabClient; + } + + @Override + public String name() { + return "GitLab"; + } + + @Override + public String description() { + return "Synchronizes user permissions from connected GitLab instance"; + } + + @Override + public boolean isEnabled() { + return qm.isEnabled(GITLAB_ENABLED); + } + + @Override + public void synchronize() { + try { + List gitLabProjects = gitLabClient.getGitLabProjects(); + List projects = createProjects(gitLabProjects); + projects = projects.stream().map(project -> qm.updateProject(project, false)).toList(); + } catch (IOException | URISyntaxException ex) { + LOGGER.error("An error occurred while querying GitLab GraphQL API", ex); + handleException(LOGGER, ex); + } + } + + private List createProjects(final List gitLabProjects) { + final List projects = new ArrayList<>(); + + final Map roleMap = Arrays.stream(GitLabRole.values()) + .collect(Collectors.toMap(Function.identity(), role -> qm.getRoleByName(role.getDescription()))); + + for (var gitLabProject : gitLabProjects) { + qm.runInTransaction(() -> { + if (!qm.tryAcquireAdvisoryLock(gitLabProject.getFullPath())) + throw new IllegalStateException("Failed to acquire advisory lock for GitLab project %s, " + + "likely because another sync for this project is already in progress" + .formatted(gitLabProject.getFullPath())); + + Project project = qm.getProject(gitLabProject.getFullPath(), null); + + if (project == null) { + LOGGER.debug("Creating project " + gitLabProject.getFullPath()); + + project = new Project(); + project.setName(gitLabProject.getFullPath()); + project = qm.persist(project); + } + + project.setActive(project.getLastBomImport() != null); + if (!project.isActive() && project.getInactiveSince() == null) + project.setInactiveSince(new Date()); + + qm.addRoleToUser(user, roleMap.get(gitLabProject.getMaxAccessLevel().stringValue()), project); + projects.add(qm.updateProject(project, false)); + }); + } + + return projects; + } + +} diff --git a/apiserver/src/main/java/org/dependencytrack/integrations/gitlab/GitlabProject.java b/apiserver/src/main/java/org/dependencytrack/integrations/gitlab/GitlabProject.java new file mode 100644 index 0000000000..b7f8933912 --- /dev/null +++ b/apiserver/src/main/java/org/dependencytrack/integrations/gitlab/GitlabProject.java @@ -0,0 +1,68 @@ +/* + * 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 net.minidev.json.JSONObject; +import net.minidev.json.JSONValue; + +/** + * Representation of a GitLab project. + * + * @author Allen Shearin + */ +class GitLabProject { + + record MaxAccessLevel(GitLabRole stringValue) { + } + + private final String fullPath; + private final MaxAccessLevel maxAccessLevel; + + GitLabProject(final String fullPath, final GitLabRole maxAccessLevel) { + this.fullPath = fullPath; + this.maxAccessLevel = new MaxAccessLevel(maxAccessLevel); + } + + public String getFullPath() { + return fullPath; + } + + public MaxAccessLevel getMaxAccessLevel() { + return maxAccessLevel; + } + + public static GitLabProject parse(final String data) { + JSONObject obj = JSONValue.parse(data, JSONObject.class); + String fullPath = obj.getAsString("fullPath"); + + JSONObject maxAccessLevel = (JSONObject) obj.get("maxAccessLevel"); + String stringValue = maxAccessLevel.getAsString("stringValue"); + + return new GitLabProject(fullPath, GitLabRole.valueOf(stringValue)); + } + + @Override + public String toString() { + return "%s{fullPath=%s, maxAccessLevel=%s}".formatted( + getClass().getSimpleName(), + fullPath, + maxAccessLevel.stringValue().toString()); + } + +} diff --git a/apiserver/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java b/apiserver/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java index 61c1e9886d..8a719bb73b 100644 --- a/apiserver/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java +++ b/apiserver/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java @@ -86,6 +86,16 @@ public enum ConfigPropertyConstants { FORTIFY_SSC_SYNC_CADENCE("integrations", "fortify.ssc.sync.cadence", "60", PropertyType.INTEGER, "The cadence (in minutes) to upload to Fortify SSC", ConfigPropertyAccessMode.READ_WRITE), FORTIFY_SSC_URL("integrations", "fortify.ssc.url", null, PropertyType.URL, "Base URL to Fortify SSC", ConfigPropertyAccessMode.READ_WRITE), FORTIFY_SSC_TOKEN("integrations", "fortify.ssc.token", null, PropertyType.ENCRYPTEDSTRING, "The token to use to authenticate to Fortify SSC", ConfigPropertyAccessMode.READ_WRITE), + GITLAB_APP_ID("integrations", "gitlab.app.id", null, PropertyType.STRING, "ID for the configured GitLab application", ConfigPropertyAccessMode.READ_WRITE), + GITLAB_AUDIENCE("integrations", "gitlab.audience", null, PropertyType.STRING, "The audience to use when authenticating to GitLab", ConfigPropertyAccessMode.READ_WRITE), + GITLAB_AUTOCREATE_PROJECTS("integrations", "gitlab.autocreate.projects", "false", PropertyType.BOOLEAN, "Flag to enable/disable auto-creation of projects in GitLab", ConfigPropertyAccessMode.READ_WRITE), + GITLAB_ENABLED("integrations", "gitlab.enabled", "false", PropertyType.BOOLEAN, "Flag to enable/disable GitLab integration", ConfigPropertyAccessMode.READ_WRITE), + GITLAB_INCLUDE_ARCHIVED("integrations", "gitlab.include.archived", "false", PropertyType.BOOLEAN, "Flag to enable/disable syncing of archived GitLab projects", ConfigPropertyAccessMode.READ_WRITE), + GITLAB_SBOM_PUSH_ENABLED("integrations", "gitlab.sbom.push.enabled", "false", PropertyType.BOOLEAN, "Flag to enable/disable SBOM generation for GitLab projects", ConfigPropertyAccessMode.READ_WRITE), + GITLAB_TOPICS("integrations", "gitlab.topics", "[]", PropertyType.STRING, "JSON array of topics to include when syncing GitLab projects", ConfigPropertyAccessMode.READ_WRITE), + GITLAB_URL("integrations", "gitlab.url", "https://gitlab.com", PropertyType.URL, "Base URL to GitLab instance", ConfigPropertyAccessMode.READ_WRITE), + GITLAB_API_KEY("integrations", "gitlab.api.key", null, PropertyType.ENCRYPTEDSTRING, "API Key for GitLab Users team", ConfigPropertyAccessMode.READ_WRITE), + GITLAB_JWKS_PATH("integrations", "gitlab.jwks.path", "/oauth/discovery/keys", PropertyType.STRING, "The URI path to the GitLab instance's JWKS endpoint", ConfigPropertyAccessMode.READ_WRITE), DEFECTDOJO_ENABLED("integrations", "defectdojo.enabled", "false", PropertyType.BOOLEAN, "Flag to enable/disable DefectDojo integration", ConfigPropertyAccessMode.READ_WRITE), DEFECTDOJO_REIMPORT_ENABLED("integrations", "defectdojo.reimport.enabled", "false", PropertyType.BOOLEAN, "Flag to enable/disable DefectDojo reimport-scan API endpoint", ConfigPropertyAccessMode.READ_WRITE), DEFECTDOJO_SYNC_CADENCE("integrations", "defectdojo.sync.cadence", "60", PropertyType.INTEGER, "The cadence (in minutes) to upload to DefectDojo", ConfigPropertyAccessMode.READ_WRITE), diff --git a/apiserver/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java b/apiserver/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java index 8d8026f4bf..24d2fced6e 100644 --- a/apiserver/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java +++ b/apiserver/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java @@ -48,6 +48,7 @@ import org.dependencytrack.model.ProjectMetrics; import org.dependencytrack.model.ProjectProperty; import org.dependencytrack.model.ProjectVersion; +import org.dependencytrack.model.Role; import org.dependencytrack.model.ServiceComponent; import org.dependencytrack.model.Tag; import org.dependencytrack.model.Vulnerability; @@ -748,6 +749,7 @@ public void bind(final Project project, final Collection tags) { public boolean hasAccess(final Principal principal, final Project project) { if (!isEnabled(ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED) || principal == null // System request (e.g. MetricsUpdateTask, etc) where there isn't a principal + || super.hasAccessManagementPermission(principal) // TODO: After Alpine >= 3.2.0: request.getEffectivePermission().contains(Permissions.ACCESS_MANAGEMENT.name()) || getEffectivePermissions(principal).contains(Permissions.Constants.PORTFOLIO_ACCESS_CONTROL_BYPASS)) return true; @@ -863,20 +865,31 @@ void preprocessACLs(final Query query, final String inputFilter, final Map { + final var apiTeam = apiKey.getTeams().stream().findFirst(); + if (apiTeam.isPresent()) { + LOGGER.debug("adding Team to ACL of newly created project"); + final Team team = getObjectByUuid(Team.class, apiTeam.get().getUuid()); + project.addAccessTeam(team); + persist(project); + return true; + } else { + LOGGER.warn("API Key without a Team, unable to assign team ACL to project."); + return false; + } + } + case User user when aclEnabled -> { + addRoleToUser(getUser(user.getUsername()), getRole(role.getUuid().toString()), project); return true; - } else { - LOGGER.warn("API Key without a Team, unable to assign team ACL to project."); + } + default -> { + // No ACL update for other principals + return false; } } - return false; } @Override diff --git a/apiserver/src/main/java/org/dependencytrack/persistence/QueryManager.java b/apiserver/src/main/java/org/dependencytrack/persistence/QueryManager.java index bff22e7196..f468bce1b1 100644 --- a/apiserver/src/main/java/org/dependencytrack/persistence/QueryManager.java +++ b/apiserver/src/main/java/org/dependencytrack/persistence/QueryManager.java @@ -579,8 +579,8 @@ public Project updateProject(Project transientProject, boolean commitIndex) { return getProjectQueryManager().updateProject(transientProject, commitIndex); } - public boolean updateNewProjectACL(Project transientProject, Principal principal) { - return getProjectQueryManager().updateNewProjectACL(transientProject, principal); + public boolean updateNewProjectACL(Project transientProject, Principal principal, Role role) { + return getProjectQueryManager().updateNewProjectACL(transientProject, principal, role); } public Project clone(UUID from, String newVersion, boolean includeTags, boolean includeProperties, diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v1/BomResource.java b/apiserver/src/main/java/org/dependencytrack/resources/v1/BomResource.java index c9c8f0cb96..6cefdfd913 100644 --- a/apiserver/src/main/java/org/dependencytrack/resources/v1/BomResource.java +++ b/apiserver/src/main/java/org/dependencytrack/resources/v1/BomResource.java @@ -18,6 +18,7 @@ */ package org.dependencytrack.resources.v1; +import alpine.Config; import alpine.common.logging.Logger; import alpine.event.framework.Event; import alpine.model.ConfigProperty; @@ -25,6 +26,13 @@ import alpine.notification.NotificationLevel; import alpine.server.auth.PermissionRequired; import alpine.server.filters.ResourceAccessRequired; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.security.SignatureException; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; @@ -42,10 +50,14 @@ import org.dependencytrack.auth.Permissions; import org.dependencytrack.event.BomUploadEvent; import org.dependencytrack.event.kafka.KafkaEventDispatcher; +import org.dependencytrack.filestorage.FileStorage; +import org.dependencytrack.integrations.gitlab.GitLabClient; +import org.dependencytrack.integrations.gitlab.GitLabRole; import org.dependencytrack.model.BomValidationMode; import org.dependencytrack.model.Component; import org.dependencytrack.model.ConfigPropertyConstants; import org.dependencytrack.model.Project; +import org.dependencytrack.model.Role; import org.dependencytrack.model.validation.ValidUuid; import org.dependencytrack.notification.NotificationConstants; import org.dependencytrack.notification.NotificationGroup; @@ -66,6 +78,7 @@ import org.glassfish.jersey.media.multipart.BodyPartEntity; import org.glassfish.jersey.media.multipart.FormDataBodyPart; import org.glassfish.jersey.media.multipart.FormDataParam; +import org.owasp.security.logging.SecurityMarkers; import jakarta.json.Json; import jakarta.json.JsonArray; @@ -95,13 +108,20 @@ import java.util.Base64; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.function.Function; import static java.util.Objects.requireNonNull; import static java.util.function.Predicate.not; import static org.dependencytrack.model.ConfigPropertyConstants.BOM_VALIDATION_MODE; import static org.dependencytrack.model.ConfigPropertyConstants.BOM_VALIDATION_TAGS_EXCLUSIVE; import static org.dependencytrack.model.ConfigPropertyConstants.BOM_VALIDATION_TAGS_INCLUSIVE; +import static org.dependencytrack.model.ConfigPropertyConstants.GITLAB_AUTOCREATE_PROJECTS; +import static org.dependencytrack.model.ConfigPropertyConstants.GITLAB_ENABLED; +import static org.dependencytrack.model.ConfigPropertyConstants.GITLAB_JWKS_PATH; +import static org.dependencytrack.model.ConfigPropertyConstants.GITLAB_SBOM_PUSH_ENABLED; +import static org.dependencytrack.model.ConfigPropertyConstants.GITLAB_URL; /** * JAX-RS resources for processing bill-of-material (bom) documents. @@ -283,7 +303,7 @@ public Response exportComponentAsCycloneDx( 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.

@@ -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 query = qm.getPersistenceManager() + .newQuery((Class) 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) 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 @@