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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package org.opendevstack.component_catalog.server.facade;

import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.tuple.Pair;
import org.jspecify.annotations.NonNull;
Expand All @@ -14,26 +13,56 @@
import org.opendevstack.component_catalog.server.services.restrictions.evaluators.EvaluationRestrictions;
import org.opendevstack.component_catalog.server.services.restrictions.evaluators.GroupsRestrictionsEvaluator;
import org.opendevstack.component_catalog.server.services.restrictions.evaluators.RestrictionsParams;
import org.opendevstack.component_catalog.util.JwtUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.util.List;

@Component
@AllArgsConstructor
@Slf4j
public class ProvisionerActionsApiFacade {
private final ProjectsInfoService projectsInfoService;
private final GroupsRestrictionsEvaluator groupsRestrictionsEvaluator;
private final ApplicationPropertiesConfiguration.CatalogItemUserActionGroupsRestrictionProps groupsRestrictionProps;
private final AuthenticationFacade authenticationFacade;

private final List<String> permittedOids;

public ProvisionerActionsApiFacade(ProjectsInfoService projectsInfoService,
GroupsRestrictionsEvaluator groupsRestrictionsEvaluator,
ApplicationPropertiesConfiguration.CatalogItemUserActionGroupsRestrictionProps groupsRestrictionProps,
AuthenticationFacade authenticationFacade,
@Value("${devstack.marketplace-api.permitted-oids}") List<String> permittedOids) {
this.projectsInfoService = projectsInfoService;
this.groupsRestrictionsEvaluator = groupsRestrictionsEvaluator;
this.groupsRestrictionProps = groupsRestrictionProps;
this.authenticationFacade = authenticationFacade;
this.permittedOids = permittedOids;
}


public static @NonNull List<Pair<@NotNull String, @NotNull List<String>>> map(ProvisioningStatusUpdateRequest provisioningStatusUpdateRequest) {
return provisioningStatusUpdateRequest.getParameters().stream()
.map(parameter -> Pair.of(parameter.getName(), parameter.getValues()))
.toList();
}

public void validateGroupRestrictions(String projectKey) {
var accessToken = authenticationFacade.getAccessToken();

var oid = JwtUtils.extractClaim(accessToken, "oid");

boolean isAValidApplicationToken = oid.map(permittedOids::contains).orElse(false);

if (isAValidApplicationToken) {
log.debug("Token with oid '{}' is allowed to bypass group restrictions for project {}", oid.orElse("unknown"), projectKey);
} else {
validateGroupRestrictions(projectKey, accessToken);
}
}

private void validateGroupRestrictions(String projectKey, String accessToken) {
var groupRestriction = CatalogItemUserActionGroupsRestriction.builder()
.prefix(groupsRestrictionProps.getPrefix())
.suffix(groupsRestrictionProps.getSuffix())
Expand All @@ -44,7 +73,6 @@ public void validateGroupRestrictions(String projectKey) {
.build();

var evaluationRestrictions = new EvaluationRestrictions(projectKey, userActionEntityRestrictions);
var accessToken = authenticationFacade.getAccessToken();
var userGroups = projectsInfoService.getProjectGroups(accessToken);

var params = RestrictionsParams.builder()
Expand Down
2 changes: 2 additions & 0 deletions src/main/resources/application-local.env.template
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,5 @@ PROVISIONER_BITBUCKET_REPOSITORY_SLUG=project-components
PROVISIONER_BITBUCKET_SUB_PATH=projects/${project-key}.json
PROVISIONER_BITBUCKET_SUB_PATH_TOKEN=${project-key}
PROVISIONER_BITBUCKET_BRANCH_NAME=refs/heads/master

DEVSTACK_MARKETPLACE-API_PERMITTED_OIDS="any-valid-odis-for-token"
7 changes: 6 additions & 1 deletion src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,9 @@ provisioner:
repository-slug: ${PROVISIONER_BITBUCKET_REPOSITORY_SLUG}
sub-path: ${PROVISIONER_BITBUCKET_SUB_PATH}
sub-path-token: ${PROVISIONER_BITBUCKET_SUB_PATH_TOKEN}
branch-name: ${PROVISIONER_BITBUCKET_BRANCH_NAME}
branch-name: ${PROVISIONER_BITBUCKET_BRANCH_NAME}


devstack:
marketplace-api:
permitted-oids: ${DEVSTACK_MARKETPLACE-API_PERMITTED_OIDS}
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package org.opendevstack.component_catalog.server.facade;

import org.apache.commons.lang3.tuple.Pair;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.opendevstack.component_catalog.config.ApplicationPropertiesConfiguration;
Expand All @@ -14,12 +14,17 @@
import org.opendevstack.component_catalog.server.services.restrictions.evaluators.EvaluationRestrictions;
import org.opendevstack.component_catalog.server.services.restrictions.evaluators.GroupsRestrictionsEvaluator;
import org.opendevstack.component_catalog.server.services.restrictions.evaluators.RestrictionsParams;
import org.opendevstack.component_catalog.util.JwtUtils;

import java.util.List;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
Expand All @@ -33,9 +38,17 @@ class ProvisionerActionsApiFacadeTest {
private ApplicationPropertiesConfiguration.CatalogItemUserActionGroupsRestrictionProps groupsRestrictionProps;
@Mock
private AuthenticationFacade authenticationFacade;
@InjectMocks

private ProvisionerActionsApiFacade provisionerActionsApiFacade;

@BeforeEach
void setUp() {
var permittedOids = List.of("oid1", "oid2", "oid3");

provisionerActionsApiFacade = new ProvisionerActionsApiFacade(projectsInfoService,
groupsRestrictionsEvaluator, groupsRestrictionProps, authenticationFacade, permittedOids);
}

@Test
void map_convertsParametersToPairs() {
// given
Expand Down Expand Up @@ -72,59 +85,144 @@ void map_withEmptyParameters_returnsEmptyList() {
assertThat(result).isEmpty();
}

@Test
void validateGroupRestrictions_whenUserHasPermissions_doesNotThrow() {
// given
var projectKey = "PROJECT";
var accessToken = "accessToken";
var userGroups = List.of("group1");

when(authenticationFacade.getAccessToken()).thenReturn(accessToken);
when(groupsRestrictionProps.getPrefix()).thenReturn(List.of("prefix-"));
when(groupsRestrictionProps.getSuffix()).thenReturn(List.of("-suffix"));
when(projectsInfoService.getProjectGroups(accessToken)).thenReturn(userGroups);
when(groupsRestrictionsEvaluator.evaluate(any(EvaluationRestrictions.class), any(RestrictionsParams.class)))
.thenReturn(Pair.of(true, "Allowed"));

// when / then
provisionerActionsApiFacade.validateGroupRestrictions(projectKey);
}

@Test
void validateGroupRestrictions_whenUserHasNoPermissions_throwsForbiddenException() {
// given
var projectKey = "PROJECT";
var accessToken = "accessToken";
var userGroups = List.of("group1");

when(authenticationFacade.getAccessToken()).thenReturn(accessToken);
when(groupsRestrictionProps.getPrefix()).thenReturn(List.of("prefix-"));
when(groupsRestrictionProps.getSuffix()).thenReturn(List.of("-suffix"));
when(projectsInfoService.getProjectGroups(accessToken)).thenReturn(userGroups);
when(groupsRestrictionsEvaluator.evaluate(any(EvaluationRestrictions.class), any(RestrictionsParams.class)))
.thenReturn(Pair.of(false, "Forbidden"));

// when / then
assertThatThrownBy(() -> provisionerActionsApiFacade.validateGroupRestrictions(projectKey))
.isInstanceOf(ForbiddenException.class)
.hasMessage("User not allowed to perform this action");
}

@Test
void validateGroupRestrictions_whenEvaluatorReturnsNull_doesNotThrow() {
// given
var projectKey = "PROJECT";
var accessToken = "accessToken";
var userGroups = List.of("group1");

when(authenticationFacade.getAccessToken()).thenReturn(accessToken);
when(groupsRestrictionProps.getPrefix()).thenReturn(List.of("prefix-"));
when(groupsRestrictionProps.getSuffix()).thenReturn(List.of("-suffix"));
when(projectsInfoService.getProjectGroups(accessToken)).thenReturn(userGroups);
when(groupsRestrictionsEvaluator.evaluate(any(EvaluationRestrictions.class), any(RestrictionsParams.class)))
.thenReturn(Pair.of(null, "Unknown"));

// when / then
provisionerActionsApiFacade.validateGroupRestrictions(projectKey);
}
@Test
void validateGroupRestrictions_whenUserHasPermissions_doesNotThrow() {
// given
var projectKey = "PROJECT";
var accessToken = "accessToken";
var userGroups = List.of("group1");

when(authenticationFacade.getAccessToken()).thenReturn(accessToken);
when(groupsRestrictionProps.getPrefix()).thenReturn(List.of("prefix-"));
when(groupsRestrictionProps.getSuffix()).thenReturn(List.of("-suffix"));
when(projectsInfoService.getProjectGroups(accessToken)).thenReturn(userGroups);
when(groupsRestrictionsEvaluator.evaluate(any(EvaluationRestrictions.class), any(RestrictionsParams.class)))
.thenReturn(Pair.of(true, "Allowed"));

try (var jwtUtilsMocked = mockStatic(JwtUtils.class)) {
jwtUtilsMocked.when(() -> JwtUtils.extractClaim(accessToken, "oid"))
.thenReturn(Optional.empty());

// when / then
provisionerActionsApiFacade.validateGroupRestrictions(projectKey);
}
}

@Test
void validateGroupRestrictions_whenUserHasNoPermissions_throwsForbiddenException() {
// given
var projectKey = "PROJECT";
var accessToken = "accessToken";
var userGroups = List.of("group1");

when(authenticationFacade.getAccessToken()).thenReturn(accessToken);
when(groupsRestrictionProps.getPrefix()).thenReturn(List.of("prefix-"));
when(groupsRestrictionProps.getSuffix()).thenReturn(List.of("-suffix"));
when(projectsInfoService.getProjectGroups(accessToken)).thenReturn(userGroups);
when(groupsRestrictionsEvaluator.evaluate(any(EvaluationRestrictions.class), any(RestrictionsParams.class)))
.thenReturn(Pair.of(false, "Forbidden"));

try (var jwtUtilsMocked = mockStatic(JwtUtils.class)) {
jwtUtilsMocked.when(() -> JwtUtils.extractClaim(accessToken, "oid"))
.thenReturn(Optional.empty());

// when / then
assertThatThrownBy(() -> provisionerActionsApiFacade.validateGroupRestrictions(projectKey))
.isInstanceOf(ForbiddenException.class)
.hasMessage("User not allowed to perform this action");
}
}

@Test
void validateGroupRestrictions_whenEvaluatorReturnsNull_doesNotThrow() {
// given
var projectKey = "PROJECT";
var accessToken = "accessToken";
var userGroups = List.of("group1");

when(authenticationFacade.getAccessToken()).thenReturn(accessToken);
when(groupsRestrictionProps.getPrefix()).thenReturn(List.of("prefix-"));
when(groupsRestrictionProps.getSuffix()).thenReturn(List.of("-suffix"));
when(projectsInfoService.getProjectGroups(accessToken)).thenReturn(userGroups);
when(groupsRestrictionsEvaluator.evaluate(any(EvaluationRestrictions.class), any(RestrictionsParams.class)))
.thenReturn(Pair.of(null, "Unknown"));

try (var jwtUtilsMocked = mockStatic(JwtUtils.class)) {
jwtUtilsMocked.when(() -> JwtUtils.extractClaim(accessToken, "oid"))
.thenReturn(Optional.empty());

// when / then
provisionerActionsApiFacade.validateGroupRestrictions(projectKey);
}
}

@Test
void validateGroupRestrictions_whenPermittedOidsContainsExtractedOid_bypassesGroupRestrictions() {
// given
var projectKey = "PROJECT";
var accessToken = "accessToken";
var permittedOid = "oid1";

when(authenticationFacade.getAccessToken()).thenReturn(accessToken);

try (var jwtUtilsMocked = mockStatic(JwtUtils.class)) {
jwtUtilsMocked.when(() -> JwtUtils.extractClaim(accessToken, "oid"))
.thenReturn(Optional.of(permittedOid));

// when
provisionerActionsApiFacade.validateGroupRestrictions(projectKey);

// then - Verify that the group restrictions evaluator is never called when oid is in permittedOids
verify(groupsRestrictionsEvaluator, never())
.evaluate(any(EvaluationRestrictions.class), any(RestrictionsParams.class));
verify(projectsInfoService, never()).getProjectGroups(accessToken);
}
}

@Test
void validateGroupRestrictions_whenPermittedOidsContainsExtractedOid_doesNotThrow() {
// given
var projectKey = "PROJECT";
var accessToken = "accessToken";
var permittedOid = "oid2";

when(authenticationFacade.getAccessToken()).thenReturn(accessToken);

try (var jwtUtilsMocked = mockStatic(JwtUtils.class)) {
jwtUtilsMocked.when(() -> JwtUtils.extractClaim(accessToken, "oid"))
.thenReturn(Optional.of(permittedOid));

// when / then - should not throw any exception
provisionerActionsApiFacade.validateGroupRestrictions(projectKey);
}
}

@Test
void validateGroupRestrictions_whenTokenHasNoOid_callsGroupRestrictionsEvaluation() {
// given
var projectKey = "PROJECT";
var accessToken = "accessToken";
var userGroups = List.of("group1");

when(authenticationFacade.getAccessToken()).thenReturn(accessToken);
when(groupsRestrictionProps.getPrefix()).thenReturn(List.of("prefix-"));
when(groupsRestrictionProps.getSuffix()).thenReturn(List.of("-suffix"));
when(projectsInfoService.getProjectGroups(accessToken)).thenReturn(userGroups);
when(groupsRestrictionsEvaluator.evaluate(any(EvaluationRestrictions.class), any(RestrictionsParams.class)))
.thenReturn(Pair.of(true, "Allowed"));

try (var jwtUtilsMocked = mockStatic(JwtUtils.class)) {
jwtUtilsMocked.when(() -> JwtUtils.extractClaim(accessToken, "oid"))
.thenReturn(Optional.empty());

// when
provisionerActionsApiFacade.validateGroupRestrictions(projectKey);

// then - Verify that the group restrictions evaluator is called when oid is not in permittedOids
verify(groupsRestrictionsEvaluator)
.evaluate(any(EvaluationRestrictions.class), any(RestrictionsParams.class));
verify(projectsInfoService).getProjectGroups(accessToken);
}
}
}

4 changes: 3 additions & 1 deletion src/test/resources/application-testing.env
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,6 @@ PROVISIONER_BITBUCKET_PROJECT_KEY=CATALOGS
PROVISIONER_BITBUCKET_REPOSITORY_SLUG=project-components
PROVISIONER_BITBUCKET_SUB_PATH=projects/<project-key>.json
PROVISIONER_BITBUCKET_SUB_PATH_TOKEN=<project-key>
PROVISIONER_BITBUCKET_BRANCH_NAME=refs/heads/master
PROVISIONER_BITBUCKET_BRANCH_NAME=refs/heads/master

DEVSTACK_MARKETPLACE-API_PERMITTED_OIDS="any-valid-odis-for-token"
Loading