diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b3ad16d0..40c437210 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,55 @@ # Changelog + +## 1.7.0 (2021-09-02) + +### Added + +- IAM now enforces intermediate group membership (#400) + +- Support for X.509 managed proxies (#356) + +- Characters allowed in username are now restricted to the UNIX valid username + characters (#347) + +- Support for including custom HTML content at the bottom of the login page has + been added (#341) + +- Improved token exchange flexibility (#306) + +- CI has been migrated from travis to Github actions (#340) + +- IAM now allows to link ssh keys to an account (#374) + +### Fixed + +- A problem that prevented the deletion of dynamically registered clients under + certains conditions has been fixed (#397) + +- Token exchange is no longer allowed for single-client exchanges that involve + the `offline_access` scope (#392) + +- More flexibility in populating registration fields from SAML authentication + assertion attributes (#371) + +- A problem with the userinfo endpoint disclosing too much information has been + fixed (#348) + +- A problem which allowed to submit multiple group requests for the same group + has been fixed (#351) + +- A problem with the escaping of certificate subjects in the IAM dashboard has + been fixed (#373) + +- A problem with the refresh of CRLs on the test client application has been + fixed (#368) + +### Documentation + +- The IAM website and documentation has been migrated to a site based on + [Google Docsy][docsy], including improved documentation for the SCIM, Scope + policy and Token exchange IAM APIs (#410) + ## 1.6.0 (2020-07-31) ### Added @@ -358,3 +408,4 @@ GitBook manual][gitbook-manual] or on [Github][github-doc]. [gitbook-manual]: https://www.gitbook.com/book/andreaceccanti/iam/details [github-doc]: https://github.com/indigo-iam/iam/blob/master/SUMMARY.md [jira-v0.4.0]: https://issues.infn.it/jira/browse/INDIAM/fixforversion/13811 +[docsy]: https://github.com/google/docsy diff --git a/README.md b/README.md index db948e3ec..b6b612cc8 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,6 @@ Grant number 777536. [mitreid]: https://github.com/mitreid-connect/OpenID-Connect-Java-Spring-Server [scim]: http://www.simplecloud.info/ [token-exchange]: https://tools.ietf.org/html/draft-ietf-oauth-token-exchange-09 -[iam-doc]: https://indigo-iam.github.io/docs +[iam-doc]: https://indigo-iam.github.io [eosc-hub]: https://www.eosc-hub.eu/ [infn]: https://home.infn.it/it/ diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/IamLoginService.java b/iam-login-service/src/main/java/it/infn/mw/iam/IamLoginService.java index 79bf76fdf..7c4431f33 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/IamLoginService.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/IamLoginService.java @@ -16,6 +16,7 @@ package it.infn.mw.iam; import org.mitre.discovery.web.DiscoveryEndpoint; +import org.mitre.openid.connect.web.JWKSetPublishingEndpoint; import org.mitre.openid.connect.web.RootController; import org.mitre.openid.connect.web.UserInfoEndpoint; import org.springframework.boot.SpringApplication; @@ -64,7 +65,9 @@ @ComponentScan.Filter(type=FilterType.ASSIGNABLE_TYPE, value=DiscoveryEndpoint.class), @ComponentScan.Filter(type=FilterType.ASSIGNABLE_TYPE, - value=HealthEndpoint.class) + value=HealthEndpoint.class), + @ComponentScan.Filter(type=FilterType.ASSIGNABLE_TYPE, + value=JWKSetPublishingEndpoint.class) }) // @formatter:on diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/account/find/DefaultFindAccountService.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/find/DefaultFindAccountService.java index 8203f0afa..9f0ab9ec3 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/api/account/find/DefaultFindAccountService.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/find/DefaultFindAccountService.java @@ -112,4 +112,35 @@ private Supplier groupNotFoundError(String groupNameOr return () -> new IllegalArgumentException("Group does not exist: " + groupNameOrUuid); } + @Override + public ScimListResponse findAccountByCertificateSubject(String certSubject) { + Optional account = repo.findByCertificateSubject(certSubject); + ScimListResponseBuilder builder = ScimListResponse.builder(); + account.ifPresent(a -> builder.singleResource(converter.dtoFromEntity(a))); + return builder.build(); + } + + @Override + public ScimListResponse findAccountNotInGroup(String groupUuid, + Pageable pageable) { + IamGroup group = groupRepo.findByUuid(groupUuid).orElseThrow(groupNotFoundError(groupUuid)); + Page results = repo.findNotInGroup(group.getUuid(), pageable); + return responseFromPage(results, converter, pageable); + } + + @Override + public ScimListResponse findAccountNotInGroupWithFilter(String groupUuid, String filter, + Pageable pageable) { + IamGroup group = groupRepo.findByUuid(groupUuid).orElseThrow(groupNotFoundError(groupUuid)); + Page results = repo.findNotInGroupWithFilter(group.getUuid(), filter, pageable); + return responseFromPage(results, converter, pageable); + } + + @Override + public ScimListResponse findAccountByGroupUuidWithFilter(String groupUuid, + String filter, Pageable pageable) { + IamGroup group = groupRepo.findByUuid(groupUuid).orElseThrow(groupNotFoundError(groupUuid)); + Page results = repo.findByGroupUuidWithFilter(group.getUuid(), filter, pageable); + return responseFromPage(results, converter, pageable); + } } diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/account/find/FindAccountController.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/find/FindAccountController.java index 47e5a145b..d1290e354 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/api/account/find/FindAccountController.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/find/FindAccountController.java @@ -16,15 +16,22 @@ package it.infn.mw.iam.api.account.find; import static it.infn.mw.iam.api.common.PagingUtils.buildPageRequest; +import static it.infn.mw.iam.api.utils.ValidationErrorUtils.handleValidationError; +import static java.util.Objects.isNull; import static org.springframework.web.bind.annotation.RequestMethod.GET; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Pageable; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.BindingResult; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import it.infn.mw.iam.api.common.ListResponseDTO; +import it.infn.mw.iam.api.common.form.PaginatedRequestWithFilterForm; import it.infn.mw.iam.api.scim.model.ScimConstants; import it.infn.mw.iam.api.scim.model.ScimUser; @@ -32,9 +39,15 @@ @PreAuthorize("hasRole('ADMIN')") public class FindAccountController { + public static final String INVALID_FIND_ACCOUNT_REQUEST = "Invalid find account request"; + public static final String FIND_BY_LABEL_RESOURCE = "/iam/account/find/bylabel"; public static final String FIND_BY_EMAIL_RESOURCE = "/iam/account/find/byemail"; public static final String FIND_BY_USERNAME_RESOURCE = "/iam/account/find/byusername"; + public static final String FIND_BY_CERT_SUBJECT_RESOURCE = "/iam/account/find/bycertsubject"; + public static final String FIND_BY_GROUP_RESOURCE = "/iam/account/find/bygroup/{groupUuid}"; + public static final String FIND_NOT_IN_GROUP_RESOURCE = + "/iam/account/find/notingroup/{groupUuid}"; final FindAccountService service; @@ -65,4 +78,47 @@ public ListResponseDTO findByUsername(@RequestParam(required = true) S return service.findAccountByUsername(username); } + @RequestMapping(method = GET, value = FIND_BY_CERT_SUBJECT_RESOURCE, + produces = ScimConstants.SCIM_CONTENT_TYPE) + public ListResponseDTO findByCertSubject( + @RequestParam(required = true) String certificateSubject) { + return service.findAccountByCertificateSubject(certificateSubject); + } + + + @RequestMapping(method = GET, value = FIND_BY_GROUP_RESOURCE, + produces = ScimConstants.SCIM_CONTENT_TYPE) + public ListResponseDTO findByGroup(@PathVariable String groupUuid, + @Validated PaginatedRequestWithFilterForm form, + BindingResult formValidationResult) { + + + handleValidationError(INVALID_FIND_ACCOUNT_REQUEST, formValidationResult); + + Pageable pr = buildPageRequest(form.getCount(), form.getStartIndex(), 100); + + if (isNull(form.getFilter())) { + return service.findAccountByGroupUuid(groupUuid, pr); + } else { + return service.findAccountByGroupUuidWithFilter(groupUuid, form.getFilter(), pr); + } + } + + + @RequestMapping(method = GET, value = FIND_NOT_IN_GROUP_RESOURCE, + produces = ScimConstants.SCIM_CONTENT_TYPE) + public ListResponseDTO findNotInGroup(@PathVariable String groupUuid, + @Validated PaginatedRequestWithFilterForm form, BindingResult formValidationResult) { + + handleValidationError(INVALID_FIND_ACCOUNT_REQUEST, formValidationResult); + + Pageable pr = buildPageRequest(form.getCount(), form.getStartIndex(), 100); + + if (isNull(form.getFilter())) { + return service.findAccountNotInGroup(groupUuid, pr); + } else { + return service.findAccountNotInGroupWithFilter(groupUuid, form.getFilter(), pr); + } + } + } diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/account/find/FindAccountService.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/find/FindAccountService.java index 10fa5abe3..4be6d939b 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/api/account/find/FindAccountService.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/find/FindAccountService.java @@ -30,10 +30,20 @@ public interface FindAccountService { ScimListResponse findInactiveAccounts(Pageable pageable); + ScimListResponse findAccountByCertificateSubject(String certSubject); + ScimListResponse findActiveAccounts(Pageable pageable); ScimListResponse findAccountByGroupName(String groupName, Pageable pageable); ScimListResponse findAccountByGroupUuid(String groupUuid, Pageable pageable); + ScimListResponse findAccountByGroupUuidWithFilter(String groupUuid, String filter, + Pageable pageable); + + ScimListResponse findAccountNotInGroup(String groupUuid, Pageable pageable); + + ScimListResponse findAccountNotInGroupWithFilter(String groupUuid, String filter, + Pageable pageable); + } diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/common/form/PaginatedRequestForm.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/common/form/PaginatedRequestForm.java new file mode 100644 index 000000000..170c37d7c --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/common/form/PaginatedRequestForm.java @@ -0,0 +1,43 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2019 + * + * 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. + */ +package it.infn.mw.iam.api.common.form; + +import javax.validation.constraints.Min; + +public class PaginatedRequestForm { + + @Min(value = 0, message = "must be >=0") + private Integer count; + + @Min(value = 1, message = "must be >=1") + private Integer startIndex; + + public Integer getCount() { + return count; + } + + public void setCount(Integer count) { + this.count = count; + } + + public Integer getStartIndex() { + return startIndex; + } + + public void setStartIndex(Integer startIndex) { + this.startIndex = startIndex; + } +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/common/form/PaginatedRequestWithFilterForm.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/common/form/PaginatedRequestWithFilterForm.java new file mode 100644 index 000000000..d8b4074da --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/common/form/PaginatedRequestWithFilterForm.java @@ -0,0 +1,36 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2019 + * + * 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. + */ +package it.infn.mw.iam.api.common.form; + +import javax.validation.constraints.Size; + +import it.infn.mw.iam.api.common.validator.NullableNonBlankString; + +public class PaginatedRequestWithFilterForm extends PaginatedRequestForm { + + @NullableNonBlankString(message = "Please provide a non-blank filter string") + @Size(min = 2, max = 64, message = "Please provide a filter that is between 2 and 64 chars long") + private String filter; + + public String getFilter() { + return filter; + } + + public void setFilter(String filter) { + this.filter = filter; + } + +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/common/validator/NullableNonBlankString.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/common/validator/NullableNonBlankString.java new file mode 100644 index 000000000..0743874ee --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/common/validator/NullableNonBlankString.java @@ -0,0 +1,39 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2019 + * + * 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. + */ +package it.infn.mw.iam.api.common.validator; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import javax.validation.Constraint; +import javax.validation.Payload; + +@Retention(RUNTIME) +@Target({FIELD, METHOD}) +@Constraint(validatedBy = NullableNonBlankStringValidator.class) +public @interface NullableNonBlankString { + + String message() default "The string, if not null, must not be blank"; + + Class[] groups() default {}; + + Class[] payload() default {}; + +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/common/validator/NullableNonBlankStringValidator.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/common/validator/NullableNonBlankStringValidator.java new file mode 100644 index 000000000..56c043ffc --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/common/validator/NullableNonBlankStringValidator.java @@ -0,0 +1,38 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2019 + * + * 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. + */ +package it.infn.mw.iam.api.common.validator; + +import static java.util.Objects.isNull; + +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; + +public class NullableNonBlankStringValidator + implements ConstraintValidator { + + public NullableNonBlankStringValidator() { + } + + @Override + public void initialize(NullableNonBlankString constraintAnnotation) { + } + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + return isNull(value) || value.trim().length() > 0; + } + +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/exchange_policy/DefaultTokenExchangePolicyService.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/exchange_policy/DefaultTokenExchangePolicyService.java new file mode 100644 index 000000000..f280bb0da --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/exchange_policy/DefaultTokenExchangePolicyService.java @@ -0,0 +1,97 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2019 + * + * 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. + */ +package it.infn.mw.iam.api.exchange_policy; + +import java.time.Clock; +import java.util.Date; +import java.util.Optional; +import java.util.function.Supplier; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import it.infn.mw.iam.core.oauth.exchange.TokenExchangePdp; +import it.infn.mw.iam.persistence.model.IamTokenExchangePolicyEntity; +import it.infn.mw.iam.persistence.repository.IamTokenExchangePolicyRepository; + +@Service +@Transactional +public class DefaultTokenExchangePolicyService implements TokenExchangePolicyService { + + private final IamTokenExchangePolicyRepository repo; + private final ExchangePolicyConverter converter; + private final Clock clock; + private final TokenExchangePdp pdp; + + @Autowired + public DefaultTokenExchangePolicyService(IamTokenExchangePolicyRepository repo, + ExchangePolicyConverter converter, Clock clock, TokenExchangePdp pdp) { + this.repo = repo; + this.converter = converter; + this.clock = clock; + this.pdp = pdp; + } + + private Supplier notFoundError(Long id) { + return () -> new ExchangePolicyNotFoundError("Exchange policy not found for id: " + id); + } + + @Override + public Page getTokenExchangePolicies(Pageable page) { + return repo.findAll(page).map(converter::dtoFromEntity); + } + + @Override + public void deleteTokenExchangePolicyById(Long id) { + IamTokenExchangePolicyEntity policy = + Optional.ofNullable(repo.findOne(id)).orElseThrow(notFoundError(id)); + + repo.delete(policy); + + pdp.reloadPolicies(); + } + + @Override + public Optional getTokenExchangePolicyById(Long id) { + return Optional.ofNullable(repo.findOne(id)).map(converter::dtoFromEntity); + } + + @Override + public ExchangePolicyDTO createTokenExchangePolicy(ExchangePolicyDTO policy) { + + Date now = Date.from(clock.instant()); + IamTokenExchangePolicyEntity policyEntity = converter.entityFromDto(policy); + + policyEntity.setCreationTime(now); + policyEntity.setLastUpdateTime(now); + + policyEntity = repo.save(policyEntity); + + pdp.reloadPolicies(); + + return converter.dtoFromEntity(policyEntity); + } + + @Override + public void deleteAllTokenExchangePolicies() { + repo.deleteAll(); + pdp.reloadPolicies(); + } + +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/exchange_policy/ExchangePolicyController.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/exchange_policy/ExchangePolicyController.java index 57155ea51..bfe4b384a 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/api/exchange_policy/ExchangePolicyController.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/exchange_policy/ExchangePolicyController.java @@ -15,17 +15,13 @@ */ package it.infn.mw.iam.api.exchange_policy; -import static java.util.stream.Collectors.toList; -import static java.util.stream.StreamSupport.stream; - -import java.time.Clock; -import java.util.Date; import java.util.List; -import java.util.Optional; import javax.validation.Valid; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.http.HttpStatus; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.security.access.prepost.PreAuthorize; @@ -39,28 +35,28 @@ import org.springframework.web.bind.annotation.RestController; import it.infn.mw.iam.api.common.ErrorDTO; -import it.infn.mw.iam.persistence.model.IamTokenExchangePolicyEntity; -import it.infn.mw.iam.persistence.repository.IamTokenExchangePolicyRepository; @RestController @RequestMapping("/iam/api/exchange") @PreAuthorize("hasRole('ADMIN')") public class ExchangePolicyController { - final IamTokenExchangePolicyRepository repo; - final ExchangePolicyConverter converter; - final Clock clock; + private final TokenExchangePolicyService service; + + private static final int UNPAGED_PAGE_SIZE = 1000; + + // Unfortunately the version of spring data used by IAM does not still support + // unpaged, so we mock an upaged request with a limit of UNPAGED_PAGE_SIZE results per page. + private static final PageRequest UNPAGED = new PageRequest(0, UNPAGED_PAGE_SIZE); + + private static final String UNPAGED_ERROR_MSG = String.format( + "More than %d exchange policies found, but only the first %d will be returned. it's time to properly implement pagination", + UNPAGED_PAGE_SIZE, UNPAGED_PAGE_SIZE); - @Autowired - public ExchangePolicyController(Clock clock, IamTokenExchangePolicyRepository repo, - ExchangePolicyConverter converter) { - this.clock = clock; - this.repo = repo; - this.converter = converter; - } - private ExchangePolicyNotFoundError notFoundError(Long id) { - return new ExchangePolicyNotFoundError("Exchange policy not found for id: " + id); + @Autowired + public ExchangePolicyController(TokenExchangePolicyService service) { + this.service = service; } protected InvalidExchangePolicyError buildValidationError(BindingResult result) { @@ -70,16 +66,18 @@ protected InvalidExchangePolicyError buildValidationError(BindingResult result) @RequestMapping(value = "/policies", method = RequestMethod.GET) public List getExchangePolicies() { - return stream(repo.findAll().spliterator(), false).map(converter::dtoFromEntity) - .collect(toList()); + Page resultsPage = service.getTokenExchangePolicies(UNPAGED); + if (resultsPage.hasNext()) { + throw new IllegalStateException(UNPAGED_ERROR_MSG); + } + + return resultsPage.getContent(); } @RequestMapping(value = "/policies/{id}", method = RequestMethod.DELETE) @ResponseStatus(code = HttpStatus.NO_CONTENT) public void deleteExchangePolicy(@PathVariable Long id) { - IamTokenExchangePolicyEntity p = - Optional.ofNullable(repo.findOne(id)).orElseThrow(() -> notFoundError(id)); - repo.delete(p.getId()); + service.deleteTokenExchangePolicyById(id); } @RequestMapping(value = "/policies", method = RequestMethod.POST) @@ -91,14 +89,14 @@ public void createExchangePolicy(@Valid @RequestBody ExchangePolicyDTO dto, throw buildValidationError(validationResult); } - Date now = Date.from(clock.instant()); - - IamTokenExchangePolicyEntity policy = converter.entityFromDto(dto); + service.createTokenExchangePolicy(dto); + } - policy.setCreationTime(now); - policy.setLastUpdateTime(now); - repo.save(policy); + @ResponseStatus(value = HttpStatus.NOT_IMPLEMENTED) + @ExceptionHandler(IllegalStateException.class) + public ErrorDTO notImplementedError(Exception ex) { + return ErrorDTO.fromString(ex.getMessage()); } @ResponseStatus(value = HttpStatus.NOT_FOUND) diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/exchange_policy/TokenExchangePolicyService.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/exchange_policy/TokenExchangePolicyService.java new file mode 100644 index 000000000..c7e41e902 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/exchange_policy/TokenExchangePolicyService.java @@ -0,0 +1,34 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2019 + * + * 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. + */ +package it.infn.mw.iam.api.exchange_policy; + +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface TokenExchangePolicyService { + + Page getTokenExchangePolicies(Pageable page); + + Optional getTokenExchangePolicyById(Long id); + + ExchangePolicyDTO createTokenExchangePolicy(ExchangePolicyDTO policy); + + void deleteTokenExchangePolicyById(Long id); + + void deleteAllTokenExchangePolicies(); +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/group/find/DefaultFindGroupService.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/group/find/DefaultFindGroupService.java index 53b443f57..dddbf2640 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/api/group/find/DefaultFindGroupService.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/group/find/DefaultFindGroupService.java @@ -61,4 +61,22 @@ public ScimListResponse findGroupByLabel(String labelName, String lab return responseFromPage(results, converter, pageable); } + @Override + public ScimListResponse findUnsubscribedGroupsForAccount(String accountUuid, + String filter, Pageable pageable) { + + Page results; + + Optional nameFilter = Optional.ofNullable(filter); + + if (nameFilter.isPresent()) { + results = repo.findUnsubscribedGroupsForAccountWithNameLike(accountUuid, nameFilter.get(), + pageable); + } else { + results = repo.findUnsubscribedGroupsForAccount(accountUuid, pageable); + } + + return responseFromPage(results, converter, pageable); + } + } diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/group/find/FindGroupController.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/group/find/FindGroupController.java index fb0284fb7..63750f005 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/api/group/find/FindGroupController.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/group/find/FindGroupController.java @@ -16,11 +16,15 @@ package it.infn.mw.iam.api.group.find; import static it.infn.mw.iam.api.common.PagingUtils.buildPageRequest; +import static it.infn.mw.iam.api.utils.ValidationErrorUtils.handleValidationError; import static org.springframework.web.bind.annotation.RequestMethod.GET; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.converter.json.MappingJacksonValue; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.BindingResult; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -30,6 +34,7 @@ import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider; import it.infn.mw.iam.api.common.ListResponseDTO; +import it.infn.mw.iam.api.common.form.PaginatedRequestWithFilterForm; import it.infn.mw.iam.api.scim.model.ScimConstants; import it.infn.mw.iam.api.scim.model.ScimGroup; @@ -37,8 +42,14 @@ @PreAuthorize("hasRole('ADMIN')") public class FindGroupController { + public static final int PAGE_SIZE = 200; + public static final String FIND_BY_LABEL_RESOURCE = "/iam/group/find/bylabel"; public static final String FIND_BY_NAME_RESOURCE = "/iam/group/find/byname"; + public static final String FIND_UNSUBSCRIBED_GROUPS_FOR_ACCOUNT = + "/iam/group/find/unsubscribed/{accountUuid}"; + + public static final String INVALID_FIND_GROUP_REQUEST = "Invalid find group request"; final FindGroupService service; @@ -66,7 +77,7 @@ public MappingJacksonValue findByLabel(@RequestParam(required = true) String nam @RequestParam(required = false) final Integer startIndex) { return filterOutMembers( - service.findGroupByLabel(name, value, buildPageRequest(count, startIndex, 100))); + service.findGroupByLabel(name, value, buildPageRequest(count, startIndex, PAGE_SIZE))); } @@ -77,4 +88,15 @@ public MappingJacksonValue findByName(@RequestParam(required = true) String name return filterOutMembers(service.findGroupByName(name)); } + @RequestMapping(method = GET, value = FIND_UNSUBSCRIBED_GROUPS_FOR_ACCOUNT, + produces = ScimConstants.SCIM_CONTENT_TYPE) + public MappingJacksonValue findUnsubscribedGroupsForAccount(@PathVariable String accountUuid, + @Validated PaginatedRequestWithFilterForm form, BindingResult formValidationResult) { + + handleValidationError(INVALID_FIND_GROUP_REQUEST, formValidationResult); + + return filterOutMembers(service.findUnsubscribedGroupsForAccount(accountUuid, form.getFilter(), + buildPageRequest(form.getCount(), form.getStartIndex(), PAGE_SIZE))); + } + } diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/group/find/FindGroupService.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/group/find/FindGroupService.java index e38ad6923..0b9afda7d 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/api/group/find/FindGroupService.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/group/find/FindGroupService.java @@ -27,4 +27,7 @@ public interface FindGroupService { ScimListResponse findGroupByLabel(String labelName, String labelValue, Pageable pageable); + ScimListResponse findUnsubscribedGroupsForAccount(String accountUuid, + String nameFilter, Pageable pageable); + } diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/scim/provisioning/ScimGroupProvisioning.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/scim/provisioning/ScimGroupProvisioning.java index efda961bc..6db023cdc 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/api/scim/provisioning/ScimGroupProvisioning.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/scim/provisioning/ScimGroupProvisioning.java @@ -21,7 +21,6 @@ import java.time.Clock; import java.util.ArrayList; import java.util.List; -import java.util.Set; import java.util.UUID; import java.util.function.Supplier; @@ -49,7 +48,6 @@ import it.infn.mw.iam.api.scim.model.ScimPatchOperation; import it.infn.mw.iam.api.scim.model.ScimPatchOperation.ScimPatchOperationType; import it.infn.mw.iam.api.scim.provisioning.paging.ScimPageRequest; -import it.infn.mw.iam.api.scim.updater.AccountUpdater; import it.infn.mw.iam.api.scim.updater.factory.DefaultGroupMembershipUpdaterFactory; import it.infn.mw.iam.core.group.IamGroupService; import it.infn.mw.iam.core.user.IamAccountService; @@ -126,11 +124,11 @@ public ScimGroup create(ScimGroup group) { String fullName = String.format("%s/%s", parentGroupName, group.getDisplayName()); fullNameSanityChecks(fullName); - iamGroup.setParentGroup(iamParentGroup); iamGroup.setName(fullName); - Set children = iamParentGroup.getChildrenGroups(); - children.add(iamGroup); + iamGroup.setParentGroup(iamParentGroup); + iamParentGroup.getChildrenGroups().add(iamGroup); + } groupService.createGroup(iamGroup); @@ -162,29 +160,8 @@ private void displayNameSanityChecks(String displayName) { private void executePatchOperation(IamGroup group, ScimPatchOperation> op) { patchOperationSanityChecks(op); + groupUpdaterFactory.getUpdatersForPatchOperation(group, op).forEach(u -> u.update()); - List updaters = groupUpdaterFactory.getUpdatersForPatchOperation(group, op); - List updatesToPublish = new ArrayList<>(); - - boolean hasChanged = false; - - for (AccountUpdater u : updaters) { - if (u.update()) { - IamAccount a = u.getAccount(); - accountService.saveAccount(a); - hasChanged = true; - updatesToPublish.add(u); - } - } - - if (hasChanged) { - - group.touch(clock); - groupService.save(group); - for (AccountUpdater u : updatesToPublish) { - u.publishUpdateEvent(this, eventPublisher); - } - } } diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/scope_policy/ScopePolicyController.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/scope_policy/ScopePolicyController.java index 4c9e1d99e..77dfe3188 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/api/scope_policy/ScopePolicyController.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/scope_policy/ScopePolicyController.java @@ -20,8 +20,11 @@ import javax.validation.Valid; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; +import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -40,6 +43,8 @@ @PreAuthorize("hasRole('ADMIN')") public class ScopePolicyController { + private static final Logger LOG = LoggerFactory.getLogger(ScopePolicyController.class); + private final ScopePolicyService policyService; private final IamScopePolicyConverter converter; @@ -126,6 +131,16 @@ public ErrorDTO duplicatePolicyError(Exception ex) { return ErrorDTO.fromString(ex.getMessage()); } + @ResponseStatus(value = HttpStatus.BAD_REQUEST) + @ExceptionHandler(HttpMessageNotReadableException.class) + public ErrorDTO invalidRequestBody(Exception ex) { + if (LOG.isDebugEnabled()) { + LOG.debug("Error parsing scope policy JSON: {}", ex.getMessage(), ex); + } + return ErrorDTO + .fromString("Invalid scope policy: could not parse the policy JSON representation"); + } + protected InvalidScopePolicyError buildValidationError(BindingResult result) { String firstErrorMessage = result.getAllErrors().get(0).getDefaultMessage(); return new InvalidScopePolicyError(firstErrorMessage); diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/utils/ValidationErrorUtils.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/utils/ValidationErrorUtils.java index 60ebc7b4b..acb445564 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/api/utils/ValidationErrorUtils.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/utils/ValidationErrorUtils.java @@ -20,12 +20,21 @@ import org.springframework.validation.BindingResult; import org.springframework.validation.FieldError; +import it.infn.mw.iam.api.scim.exception.IllegalArgumentException; + public class ValidationErrorUtils { private ValidationErrorUtils() { // prevent instantiation } + public static void handleValidationError(String prefix, BindingResult result) { + + if (result.hasErrors()) { + throw new IllegalArgumentException( + String.format("%s: [%s]", prefix, stringifyValidationError(result))); + } + } public static String stringifyValidationError(BindingResult result) { StringBuilder sb = new StringBuilder(); diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/config/IamProperties.java b/iam-login-service/src/main/java/it/infn/mw/iam/config/IamProperties.java index a55781b2b..d14254d5d 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/config/IamProperties.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/config/IamProperties.java @@ -43,6 +43,41 @@ public enum LocalAuthenticationLoginPageMode { VISIBLE, HIDDEN, HIDDEN_WITH_LINK } + + public static class VersionedStaticResourcesProperties { + boolean enableVersioning = true; + + public boolean isEnableVersioning() { + return enableVersioning; + } + + public void setEnableVersioning(boolean enableVersioning) { + this.enableVersioning = enableVersioning; + } + } + + public static class CustomizationProperties { + boolean includeCustomLoginPageContent = false; + + String customLoginPageContentUrl; + + public boolean isIncludeCustomLoginPageContent() { + return includeCustomLoginPageContent; + } + + public void setIncludeCustomLoginPageContent(boolean includeCustomLoginPageContent) { + this.includeCustomLoginPageContent = includeCustomLoginPageContent; + } + + public String getCustomLoginPageContentUrl() { + return customLoginPageContentUrl; + } + + public void setCustomLoginPageContentUrl(String customLoginPageContentUrl) { + this.customLoginPageContentUrl = customLoginPageContentUrl; + } + } + public static class LocalAuthenticationProperties { LocalAuthenticationLoginPageMode loginPageVisibility; @@ -429,6 +464,11 @@ public void setUrnNamespace(String urnNamespace) { private IamTokenEnhancerProperties tokenEnhancer = new IamTokenEnhancerProperties(); + private CustomizationProperties customization = new CustomizationProperties(); + + private VersionedStaticResourcesProperties versionedStaticResources = + new VersionedStaticResourcesProperties(); + public String getBaseUrl() { return baseUrl; } @@ -605,4 +645,21 @@ public void setTokenEnhancer(IamTokenEnhancerProperties tokenEnhancer) { this.tokenEnhancer = tokenEnhancer; } + public CustomizationProperties getCustomization() { + return customization; + } + + public void setCustomization(CustomizationProperties customization) { + this.customization = customization; + } + + public VersionedStaticResourcesProperties getVersionedStaticResources() { + return versionedStaticResources; + } + + public void setVersionedStaticResources( + VersionedStaticResourcesProperties versionedStaticResources) { + this.versionedStaticResources = versionedStaticResources; + } + } diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/config/JpaConfig.java b/iam-login-service/src/main/java/it/infn/mw/iam/config/JpaConfig.java index 77980b41d..cf9cfc754 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/config/JpaConfig.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/config/JpaConfig.java @@ -53,7 +53,7 @@ protected Map getVendorProperties() { Map map = new HashMap<>(); map.put("eclipselink.weaving", "false"); - map.put("eclipselink.logging.level", "INFO"); + map.put("eclipselink.logging.level", "WARNING"); map.put("eclipselink.logging.level.sql", "OFF"); map.put("eclipselink.cache.shared.default", "false"); diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/IamOAuth2RequestFactory.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/IamOAuth2RequestFactory.java index 21ad2aca8..910a1114e 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/IamOAuth2RequestFactory.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/IamOAuth2RequestFactory.java @@ -15,6 +15,8 @@ */ package it.infn.mw.iam.core.oauth; +import static it.infn.mw.iam.core.oauth.granters.TokenExchangeTokenGranter.TOKEN_EXCHANGE_GRANT_TYPE; + import java.util.Map; import java.util.Set; @@ -26,6 +28,8 @@ import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.common.exceptions.InvalidClientException; +import org.springframework.security.oauth2.common.exceptions.InvalidRequestException; import org.springframework.security.oauth2.common.util.OAuth2Utils; import org.springframework.security.oauth2.provider.AuthorizationRequest; import org.springframework.security.oauth2.provider.ClientDetails; @@ -50,10 +54,12 @@ public class IamOAuth2RequestFactory extends ConnectOAuth2RequestFactory { private final JWTProfileResolver profileResolver; private final Joiner joiner = Joiner.on(' '); + private final ClientDetailsEntityService clientDetailsService; public IamOAuth2RequestFactory(ClientDetailsEntityService clientDetailsService, IamScopeFilter scopeFilter, JWTProfileResolver profileResolver) { super(clientDetailsService); + this.clientDetailsService = clientDetailsService; this.scopeFilter = scopeFilter; this.profileResolver = profileResolver; } @@ -71,15 +77,15 @@ public AuthorizationRequest createAuthorizationRequest(Map input scopeFilter.filterScopes(requestedScopes, authn); inputParams.put(OAuth2Utils.SCOPE, joiner.join(requestedScopes)); } - + AuthorizationRequest authzRequest = super.createAuthorizationRequest(inputParams); - + for (String audienceKey : AUDIENCE_KEYS) { if (inputParams.containsKey(audienceKey)) { if (!authzRequest.getExtensions().containsKey(AUD)) { authzRequest.getExtensions().put(AUD, inputParams.get(audienceKey)); } - + break; } } @@ -126,4 +132,35 @@ public OAuth2Request createOAuth2Request(ClientDetails client, TokenRequest toke return request; } + + @Override + public TokenRequest createTokenRequest(Map requestParameters, + ClientDetails authenticatedClient) { + + String clientId = requestParameters.get(OAuth2Utils.CLIENT_ID); + if (clientId == null) { + clientId = authenticatedClient.getClientId(); + } else { + if (!clientId.equals(authenticatedClient.getClientId())) { + throw new InvalidClientException("Given client ID does not match authenticated client"); + } + } + + String grantType = requestParameters.get(OAuth2Utils.GRANT_TYPE); + + Set scopes = OAuth2Utils.parseParameterList(requestParameters.get(OAuth2Utils.SCOPE)); + + if (scopes == null || scopes.isEmpty()) { + if (TOKEN_EXCHANGE_GRANT_TYPE.equals(grantType)) { + throw new InvalidRequestException( + "The scope parameter is required for a token exchange request!"); + } else { + ClientDetails clientDetails = clientDetailsService.loadClientByClientId(clientId); + scopes = clientDetails.getScope(); + } + } + + return new TokenRequest(requestParameters, clientId, scopes, grantType); + } + } diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/exchange/DefaultTokenExchangePdp.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/exchange/DefaultTokenExchangePdp.java index 1820a8982..186d8a18a 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/exchange/DefaultTokenExchangePdp.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/exchange/DefaultTokenExchangePdp.java @@ -22,10 +22,12 @@ import java.util.List; import java.util.Set; +import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.oauth2.provider.ClientDetails; import org.springframework.security.oauth2.provider.TokenRequest; @@ -39,17 +41,22 @@ import it.infn.mw.iam.persistence.repository.IamTokenExchangePolicyRepository; @Service -public class DefaultTokenExchangePdp implements TokenExchangePdp { +public class DefaultTokenExchangePdp implements TokenExchangePdp, InitializingBean { + public static final Logger LOG = LoggerFactory.getLogger(DefaultTokenExchangePdp.class); public static final String NOT_APPLICABLE_ERROR_TEMPLATE = "No applicable policies found for clients: %s -> %s"; - final IamTokenExchangePolicyRepository repo; + private final IamTokenExchangePolicyRepository repo; + + private final ScopeMatcherRegistry scopeMatcherRegistry; - final ScopeMatcherRegistry scopeMatcherRegistry; + private List policies = Lists.newArrayList(); - List policies = Lists.newArrayList(); + private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); + private final ReentrantReadWriteLock.ReadLock readLock = lock.readLock(); + private final ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock(); @Autowired public DefaultTokenExchangePdp(IamTokenExchangePolicyRepository repo, @@ -58,22 +65,32 @@ public DefaultTokenExchangePdp(IamTokenExchangePolicyRepository repo, this.scopeMatcherRegistry = scopeMatcherRegistry; } - - void loadPolicies() { - policies.clear(); - - for (IamTokenExchangePolicyEntity p : repo.findAll()) { - policies.add(TokenExchangePolicy.builder().fromEntity(p).build()); - } - } - Set applicablePolicies(ClientDetails origin, ClientDetails destination) { - loadPolicies(); return policies.stream() .filter(p -> p.appicableFor(origin, destination)) .collect(Collectors.toSet()); } + /** + * In an exchange of scopes between the origin and destination client, the destination client is + * the client that issued the token request. The scope verification at this stage checks that the + * requested scopes are allowed by the origin client configuration, as the destination client is + * impersonating the origin client. + * + * After this checks, the scope policies linked to the exchange policy are applied for each + * requested scope. If there's a policy that does not allow one of the requested scope, an + * invalidScope result is returned providing detail on which scope is not allowed by the policy. + * + * Checks on whether the requested scopes are also allowed by the destination client configuration + * are implemented elsewhere in the chain (e.g., in the token exchange granter), and not + * replicated here + * + * @param p the policy for which scope policies should be applied + * @param request the token request + * @param origin, the origin client + * @param destination the destination client + * @return a decision result + */ private TokenExchangePdpResult verifyScopes(TokenExchangePolicy p, TokenRequest request, ClientDetails origin, ClientDetails destination) { @@ -88,7 +105,7 @@ private TokenExchangePdpResult verifyScopes(TokenExchangePolicy p, TokenRequest for (String scope : request.getScope()) { // Check requested scope is permitted by client configuration if (originClientMatchers.stream().noneMatch(m -> m.matches(scope))) { - return invalidScope(p, scope, "scope not allowed by client configuration"); + return invalidScope(p, scope, "scope not allowed by origin client configuration"); } // Check requested scope is compliant with policies @@ -104,14 +121,43 @@ private TokenExchangePdpResult verifyScopes(TokenExchangePolicy p, TokenRequest } + @Override public TokenExchangePdpResult validateTokenExchange(TokenRequest request, ClientDetails origin, ClientDetails destination) { - return applicablePolicies(origin, destination).stream() - .max(comparing(TokenExchangePolicy::rank).thenComparing(TokenExchangePolicy::getRule)) - .map(p -> verifyScopes(p, request, origin, destination)) - .orElse(notApplicable()); + try { + readLock.lock(); + return applicablePolicies(origin, destination).stream() + .max(comparing(TokenExchangePolicy::rank).thenComparing(TokenExchangePolicy::getRule)) + .map(p -> verifyScopes(p, request, origin, destination)) + .orElse(notApplicable()); + } finally { + readLock.unlock(); + } + } + + @Override + public void reloadPolicies() { + + try { + LOG.debug("Token exchange policy reload started"); + writeLock.lock(); + policies.clear(); + + for (IamTokenExchangePolicyEntity p : repo.findAll()) { + policies.add(TokenExchangePolicy.builder().fromEntity(p).build()); + } + + } finally { + writeLock.unlock(); + LOG.debug("Token exchange policy reload done"); + } + } + + @Override + public void afterPropertiesSet() throws Exception { + reloadPolicies(); } } diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/exchange/TokenExchangePdp.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/exchange/TokenExchangePdp.java index bd05f78ab..045a36be0 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/exchange/TokenExchangePdp.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/exchange/TokenExchangePdp.java @@ -20,6 +20,8 @@ public interface TokenExchangePdp { + public void reloadPolicies(); + TokenExchangePdpResult validateTokenExchange(TokenRequest request, ClientDetails originClient, ClientDetails destinationClient); diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/granters/TokenExchangeTokenGranter.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/granters/TokenExchangeTokenGranter.java index 4aa1949cb..bca2a4eed 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/granters/TokenExchangeTokenGranter.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/granters/TokenExchangeTokenGranter.java @@ -21,6 +21,7 @@ import static java.lang.String.format; import static java.util.Objects.isNull; +import java.util.Objects; import java.util.Optional; import java.util.Set; @@ -56,6 +57,7 @@ public class TokenExchangeTokenGranter extends AbstractTokenGranter { "urn:ietf:params:oauth:grant-type:token-exchange"; private static final String TOKEN_TYPE = "urn:ietf:params:oauth:token-type:jwt"; private static final String AUDIENCE_FIELD = "audience"; + private static final String OFFLINE_ACCESS_SCOPE = "offline_access"; private final OAuth2TokenEntityService tokenServices; @@ -81,6 +83,12 @@ protected void validateExchange(final ClientDetails actorClient, final TokenRequ ClientDetailsEntity subjectClient = subjectToken.getClient(); Set requestedScopes = tokenRequest.getScope(); + if (Objects.isNull(requestedScopes) || requestedScopes.isEmpty()) { + LOG.debug( + "No scope parameter found in token exchange request, defaulting to scopes linked to the suject token"); + requestedScopes = subjectToken.getScope(); + } + if (!isNull(subjectToken.getAuthenticationHolder().getUserAuth())) { LOG.info( "Client '{}' requests token exchange from client '{}' to impersonate user '{}' on audience '{}' with scopes '{}'", @@ -93,6 +101,11 @@ protected void validateExchange(final ClientDetails actorClient, final TokenRequ actorClient.getClientId(), subjectClient.getClientId(), audience, requestedScopes); } + if (subjectClient.equals(actorClient) && requestedScopes.contains(OFFLINE_ACCESS_SCOPE)) { + throw new OAuth2AccessDeniedException( + "Token exchange not allowed: the actor and the subject are the same client and offline_access is in the requested scopes"); + } + TokenExchangePdpResult result = exchangePdp.validateTokenExchange(tokenRequest, subjectClient, actorClient); diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/user/DefaultIamAccountService.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/user/DefaultIamAccountService.java index b9b3e8c86..d352fb6a0 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/core/user/DefaultIamAccountService.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/user/DefaultIamAccountService.java @@ -19,9 +19,11 @@ import static com.google.common.base.Preconditions.checkNotNull; import static com.google.common.base.Strings.isNullOrEmpty; import static it.infn.mw.iam.core.lifecycle.ExpiredAccountsHandler.LIFECYCLE_STATUS_LABEL; +import static java.util.Objects.isNull; import java.time.Clock; import java.util.Date; +import java.util.LinkedHashSet; import java.util.List; import java.util.Optional; import java.util.Set; @@ -32,6 +34,7 @@ import org.mitre.oauth2.service.OAuth2TokenEntityService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ApplicationEventPublisherAware; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.security.crypto.password.PasswordEncoder; @@ -68,14 +71,14 @@ @Service @Transactional -public class DefaultIamAccountService implements IamAccountService { +public class DefaultIamAccountService implements IamAccountService, ApplicationEventPublisherAware { private final Clock clock; private final IamAccountRepository accountRepo; private final IamGroupRepository groupRepo; private final IamAuthoritiesRepository authoritiesRepo; private final PasswordEncoder passwordEncoder; - private final ApplicationEventPublisher eventPublisher; + private ApplicationEventPublisher eventPublisher; private final OAuth2TokenEntityService tokenService; @Autowired @@ -102,8 +105,7 @@ private void accountAddedToGroupEvent(IamAccount account, IamGroup group) { } private void accountRemovedFromGroupEvent(IamAccount account, IamGroup group) { - eventPublisher - .publishEvent(new GroupMembershipRemovedEvent(this, account, group)); + eventPublisher.publishEvent(new GroupMembershipRemovedEvent(this, account, group)); } private void labelRemovedEvent(IamAccount account, IamLabel label) { @@ -437,10 +439,20 @@ public IamAccount addToGroup(IamAccount account, IamGroup group) { account.getGroups() .add(IamAccountGroupMembership.forAccountAndGroup(clock.instant(), account, group)); + group.touch(clock); + account.touch(clock); + + groupRepo.save(group); accountRepo.save(account); + accountAddedToGroupEvent(account, group); } + // Also add the user to any intermediate groups up to the root + if (!isNull(group.getParentGroup())) { + account = addToGroup(account, group.getParentGroup()); + } + return account; } @@ -450,11 +462,28 @@ public IamAccount removeFromGroup(IamAccount account, IamGroup group) { groupRepo.findGroupByMemberAccountUuidAndGroupUuid(account.getUuid(), group.getUuid()); if (maybeGroup.isPresent()) { - account.getGroups() - .remove(IamAccountGroupMembership.forAccountAndGroup(clock.instant(), account, group)); - accountRepo.save(account); - accountRemovedFromGroupEvent(account, group); + + Set toBeDeleted = new LinkedHashSet<>(); + + for (IamAccountGroupMembership gm : account.getGroups()) { + if (gm.getGroup().isSubgroupOf(maybeGroup.get())) { + toBeDeleted.add(gm.getGroup()); + } + } + + toBeDeleted.add(maybeGroup.get()); + + for (IamGroup dg : toBeDeleted) { + account.getGroups() + .remove(IamAccountGroupMembership.forAccountAndGroup(clock.instant(), account, dg)); + account.touch(clock); + dg.touch(clock); + accountRepo.save(account); + groupRepo.save(dg); + accountRemovedFromGroupEvent(account, dg); + } } + return account; } @@ -524,4 +553,9 @@ public IamAccount removeSshKey(IamAccount account, IamSshKey key) { accountRepo.save(account); return account; } + + @Override + public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) { + eventPublisher = applicationEventPublisher; + } } diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/web/DefaultLoginPageConfiguration.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/DefaultLoginPageConfiguration.java index 225bbc182..0d6dd4692 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/core/web/DefaultLoginPageConfiguration.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/DefaultLoginPageConfiguration.java @@ -177,4 +177,13 @@ public boolean isShowLinkToLocalAuthenticationPage() { public boolean isShowRegistrationButton() { return iamProperties.getRegistration().isShowRegistrationButtonInLoginPage(); } + + public boolean isIncludeCustomContent() { + return iamProperties.getCustomization().isIncludeCustomLoginPageContent(); + } + + @Override + public String getCustomContentUrl() { + return iamProperties.getCustomization().getCustomLoginPageContentUrl(); + } } diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/web/IamErrorController.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/IamErrorController.java index 45f27c374..4d583009c 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/core/web/IamErrorController.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/IamErrorController.java @@ -31,25 +31,25 @@ public class IamErrorController implements ErrorController { private static final String IAM_ERROR_VIEW = "iam/error"; private static final String PATH = "/error"; - @RequestMapping(method=RequestMethod.GET, path=PATH) + @RequestMapping(method = RequestMethod.GET, path = PATH) public ModelAndView error(HttpServletRequest request) { ModelAndView errorPage = new ModelAndView(IAM_ERROR_VIEW); - + HttpStatus status = HttpStatus.valueOf(getErrorCode(request)); - - errorPage.addObject("errorMessage", String.format("%d. %s", status.value(), - status.getReasonPhrase())); - + + errorPage.addObject("errorMessage", + String.format("%d. %s", status.value(), status.getReasonPhrase())); + Exception exception = getRequestException(request); - - if (exception != null){ + + if (exception != null) { errorPage.addObject("exceptionMessage", exception.getMessage()); errorPage.addObject("exceptionStackTrace", ExceptionUtils.getStackTrace(exception).trim()); } return errorPage; } - + private int getErrorCode(HttpServletRequest httpRequest) { return (Integer) httpRequest.getAttribute("javax.servlet.error.status_code"); } diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/web/IamViewInfoInterceptor.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/IamViewInfoInterceptor.java index 7505f6967..d0ef27ba6 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/core/web/IamViewInfoInterceptor.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/IamViewInfoInterceptor.java @@ -23,6 +23,7 @@ import org.springframework.stereotype.Component; import org.springframework.web.servlet.handler.HandlerInterceptorAdapter; +import it.infn.mw.iam.config.IamProperties; import it.infn.mw.iam.config.saml.IamSamlProperties; import it.infn.mw.iam.rcauth.RCAuthProperties; @@ -38,6 +39,8 @@ public class IamViewInfoInterceptor extends HandlerInterceptorAdapter { public static final String SIMULATE_NETWORK_LATENCY_KEY = "simulateNetworkLatency"; public static final String RCAUTH_ENABLED_KEY = "iamRcauthEnabled"; + public static final String RESOURCES_PATH_KEY = "resourcesPrefix"; + @Value("${iam.version}") String iamVersion; @@ -56,6 +59,9 @@ public class IamViewInfoInterceptor extends HandlerInterceptorAdapter { @Autowired RCAuthProperties rcAuthProperties; + @Autowired + IamProperties iamProperties; + @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { @@ -71,6 +77,13 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons request.setAttribute(RCAUTH_ENABLED_KEY, rcAuthProperties.isEnabled()); + + if (iamProperties.getVersionedStaticResources().isEnableVersioning()) { + request.setAttribute(RESOURCES_PATH_KEY, String.format("/resources/%s", gitCommitId)); + } else { + request.setAttribute(RESOURCES_PATH_KEY, "/resources"); + } + return true; } diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/web/LoginPageConfiguration.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/LoginPageConfiguration.java index 390e9b82a..c190812dc 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/core/web/LoginPageConfiguration.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/LoginPageConfiguration.java @@ -26,11 +26,11 @@ public interface LoginPageConfiguration { boolean isShowRegistrationButton(); boolean isLocalAuthenticationVisible(); - + boolean isShowLinkToLocalAuthenticationPage(); - + boolean isExternalAuthenticationEnabled(); - + boolean isOidcEnabled(); boolean isGithubEnabled(); @@ -41,6 +41,10 @@ public interface LoginPageConfiguration { boolean isAccountLinkingEnabled(); + boolean isIncludeCustomContent(); + + String getCustomContentUrl(); + Optional getPrivacyPolicyUrl(); String getPrivacyPolicyText(); @@ -48,7 +52,7 @@ public interface LoginPageConfiguration { String getLoginButtonText(); List getOidcProviders(); - + Logo getLogo(); } diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/web/jwk/IamJWKSetPublishingEndpoint.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/jwk/IamJWKSetPublishingEndpoint.java new file mode 100644 index 000000000..4dfa5bdf8 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/jwk/IamJWKSetPublishingEndpoint.java @@ -0,0 +1,68 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2019 + * + * 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. + */ +package it.infn.mw.iam.core.web.jwk; + +import java.util.ArrayList; +import java.util.Map; + +import org.mitre.jwt.signer.service.JWTSigningAndValidationService; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKSet; + +@RestController +public class IamJWKSetPublishingEndpoint implements InitializingBean { + + public static final String URL = "jwk"; + + private String jsonKeys; + + @Autowired + private JWTSigningAndValidationService jwtService; + + @RequestMapping(value = "/" + URL, produces = MediaType.APPLICATION_JSON_VALUE) + public String getJwk() { + + return jsonKeys; + } + + /** + * @return the jwtService + */ + public JWTSigningAndValidationService getJwtService() { + return jwtService; + } + + /** + * @param jwtService the jwtService to set + */ + public void setJwtService(JWTSigningAndValidationService jwtService) { + this.jwtService = jwtService; + } + + @Override + public void afterPropertiesSet() throws Exception { + + Map keys = jwtService.getAllPublicKeys(); + jsonKeys = new JWKSet(new ArrayList<>(keys.values())).toString(); + } + +} diff --git a/iam-login-service/src/main/resources/application-h2-test.yml b/iam-login-service/src/main/resources/application-h2-test.yml index d991ab76b..4271069ce 100644 --- a/iam-login-service/src/main/resources/application-h2-test.yml +++ b/iam-login-service/src/main/resources/application-h2-test.yml @@ -37,3 +37,7 @@ flyway: locations: - classpath:db/migration/h2 - classpath:db/migration/test + +iam: + versioned-static-resources: + enable-versioning: false \ No newline at end of file diff --git a/iam-login-service/src/main/resources/application.yml b/iam-login-service/src/main/resources/application.yml index e552b0765..6c85a2008 100644 --- a/iam-login-service/src/main/resources/application.yml +++ b/iam-login-service/src/main/resources/application.yml @@ -111,7 +111,11 @@ iam: local-authn: login-page-visibility: ${IAM_LOCAL_AUTHN_LOGIN_PAGE_VISIBILITY:visible} enabled-for: ${IAM_LOCAL_AUTHN_ENABLED_FOR:all} - + + customization: + custom-login-page-content-url: ${IAM_CUSTOMIZATION_CUSTOM_LOGIN_PAGE_CONTENT_URL} + include-custom-login-page-content: ${IAM_CUSTOMIZATION_INCLUDE_CUSTOM_LOGIN_PAGE_CONTENT:false} + user-profile: editable-fields: - email diff --git a/iam-login-service/src/main/webapp/WEB-INF/tags/footer.tag b/iam-login-service/src/main/webapp/WEB-INF/tags/footer.tag index db4a0e366..f44265de9 100644 --- a/iam-login-service/src/main/webapp/WEB-INF/tags/footer.tag +++ b/iam-login-service/src/main/webapp/WEB-INF/tags/footer.tag @@ -15,14 +15,14 @@ - - - - - - - - + + + + + + + + - +
diff --git a/iam-login-service/src/main/webapp/WEB-INF/tags/header.tag b/iam-login-service/src/main/webapp/WEB-INF/tags/header.tag index 9b67df29b..26f700650 100644 --- a/iam-login-service/src/main/webapp/WEB-INF/tags/header.tag +++ b/iam-login-service/src/main/webapp/WEB-INF/tags/header.tag @@ -16,26 +16,26 @@ - - - - - - - + + + + + + + - + - - - + + + - - - - - - + + + + + +
-
+
\ No newline at end of file diff --git a/iam-login-service/src/main/webapp/WEB-INF/views/iam/dashboard.jsp b/iam-login-service/src/main/webapp/WEB-INF/views/iam/dashboard.jsp index e2bfec443..746c26eb3 100644 --- a/iam-login-service/src/main/webapp/WEB-INF/views/iam/dashboard.jsp +++ b/iam-login-service/src/main/webapp/WEB-INF/views/iam/dashboard.jsp @@ -61,105 +61,106 @@ - + - - + + - + - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + + src="${resourcesPrefix}/iam/apps/dashboard-app/components/user/privileges/user.privileges.component.js"> - + src="${resourcesPrefix}/iam/apps/dashboard-app/components/user/password/user.password.component.js"> + - - - - - - + src="${resourcesPrefix}/iam/apps/dashboard-app/components/user/linked-accounts/user.linked-accounts.component.js"> + + + + + + - - + + - - - + + + + src="${resourcesPrefix}/iam/apps/dashboard-app/components/tokens/refreshlist/tokens.refreshlist.component.js"> - + src="${resourcesPrefix}/iam/apps/dashboard-app/components/tokens/accesslist/tokens.accesslist.component.js"> + + + - + + + + + + - - - - - - + diff --git a/iam-login-service/src/main/webapp/WEB-INF/views/iam/login.jsp b/iam-login-service/src/main/webapp/WEB-INF/views/iam/login.jsp index 85d92dca6..02a72dbb3 100644 --- a/iam-login-service/src/main/webapp/WEB-INF/views/iam/login.jsp +++ b/iam-login-service/src/main/webapp/WEB-INF/views/iam/login.jsp @@ -24,9 +24,9 @@ - - - + + + - - - - - - - + + + + + + +
-
+
diff --git a/iam-login-service/src/main/webapp/WEB-INF/views/iam/resetPassword.jsp b/iam-login-service/src/main/webapp/WEB-INF/views/iam/resetPassword.jsp index 439cb45d8..8ed074f39 100644 --- a/iam-login-service/src/main/webapp/WEB-INF/views/iam/resetPassword.jsp +++ b/iam-login-service/src/main/webapp/WEB-INF/views/iam/resetPassword.jsp @@ -23,10 +23,10 @@ - - - - + + + + diff --git a/iam-login-service/src/main/webapp/WEB-INF/views/iam/samlDiscovery.jsp b/iam-login-service/src/main/webapp/WEB-INF/views/iam/samlDiscovery.jsp index f1f98f7e8..5adfbec99 100644 --- a/iam-login-service/src/main/webapp/WEB-INF/views/iam/samlDiscovery.jsp +++ b/iam-login-service/src/main/webapp/WEB-INF/views/iam/samlDiscovery.jsp @@ -27,8 +27,8 @@ - - + +
diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/group-membership/adder/group-membership.adder.component.html b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/group-membership/adder/group-membership.adder.component.html new file mode 100644 index 000000000..963705d55 --- /dev/null +++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/group-membership/adder/group-membership.adder.component.html @@ -0,0 +1,72 @@ + + + + \ No newline at end of file diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/group-membership/adder/group-membership.adder.component.js b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/group-membership/adder/group-membership.adder.component.js new file mode 100644 index 000000000..b10f045c9 --- /dev/null +++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/group-membership/adder/group-membership.adder.component.js @@ -0,0 +1,131 @@ +/* + * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2019 + * + * 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. + */ +(function () { + 'use strict'; + + angular + .module('dashboardApp') + .component('groupMembershipAdder', groupMembershipAdder()); + + function GroupMembershipAdderController(toaster, $q, FindService, scimFactory) { + var self = this; + self.selectedGroups = []; + + self.searchResults = []; + + function handleSuccess(res) { + self.close({ $value: 'Groups added succesfully!' }); + } + + function handleError(err) { + var msg; + + if (err.data && err.data.error) { + msg = err.data.error; + } else { + msg = err.statusText; + } + + if (!msg) { + self.error = "An error occurred. Is your network down?"; + } else { + self.error = msg; + } + } + + self.clearError = function () { + self.error = undefined; + }; + + self.cancel = function () { + self.dismiss({ $value: 'Add group canceled!' }); + }; + + self.labelName = function (label) { + if (label.prefix) { + return label.prefix + "/" + label.name; + } + + return label.name; + }; + + function groupRank(g) { + return (g.displayName.match(/\//g) || []).length; + } + + // Returns true if g1 is parent of g2 + function isAncestor(g1, g2) { + return g1.id !== g2.id && groupRank(g1) != groupRank(g2) && + g2.displayName.startsWith(g1.displayName); + } + self.submit = function () { + + var promises = []; + var filteredGroups = [...self.selectedGroups]; + + filteredGroups = filteredGroups.filter(function (g1) { + for (let g2 of self.selectedGroups) { + if (isAncestor(g1, g2)) { + return false; + } + } + return true; + }); + + filteredGroups.slice().reverse().forEach(function (group) { + promises.push(scimFactory.addUserToGroup(group.id, self.resolve.user)); + }); + + return $q.all(promises).then(handleSuccess).catch(handleError); + }; + + self.findUnsubscribedGroups = function (search) { + if (search === '' || search.length < 2) { + search = undefined; + } + + FindService.findUnsubscribedGroupsForAccount(self.resolve.user.id, search, 1).then( + function (res) { + self.searchResults = res.Resources; + }); + }; + + self.$onInit = function () { + console.log('GroupMembershipAdderController onInit'); + + FindService.findUnsubscribedGroupsForAccount(self.resolve.user.id, null, 1).then( + function (res) { + self.searchResults = res.Resources; + }); + }; + } + + function groupMembershipAdder() { + return { + templateUrl: "/resources/iam/apps/dashboard-app/components/group-membership/adder/group-membership.adder.component.html", + bindings: { + resolve: "<", + close: "&", + dismiss: "&" + }, + controller: ['toaster', '$q', 'FindService', 'scimFactory', + GroupMembershipAdderController + ], + controllerAs: '$ctrl' + }; + } + +}()); \ No newline at end of file diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/group-requests/join-group.component.js b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/group-requests/join-group.component.js index 5fd6dd034..12f3f98b3 100644 --- a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/group-requests/join-group.component.js +++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/group-requests/join-group.component.js @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -(function() { +(function () { 'use strict'; function JoinGroupRequest($uibModalInstance, $q, GroupRequestsService, toaster, $sanitize, user, groups) { @@ -61,14 +61,14 @@ function cancel() { $uibModalInstance.dismiss('Dismissed'); } - + function submit() { var sanitizedNotes = $sanitize(self.req.notes); var promises = []; self.enabled = false; - self.selectedGroups.forEach(function(g){ + self.selectedGroups.forEach(function (g) { var req = { notes: sanitizedNotes, username: self.user.userName, @@ -80,6 +80,15 @@ return $q.all(promises).then(handleSuccess); } + + self.labelName = function (label) { + if (label.prefix) { + return label.prefix + "/" + label.name; + } + + return label.name; + }; + } function UserGroupRequestsController(GroupsService, GroupRequestsService, Utils, toaster, $uibModal) { @@ -87,30 +96,30 @@ self.joinGroup = joinGroup; - self.$onInit = function() { + self.$onInit = function () { self.voAdmin = Utils.isAdmin(); }; function loadGroupRequests() { - return GroupRequestsService.getAllPendingGroupRequestsForAuthenticatedUser().then(function(reqs) { + return GroupRequestsService.getAllPendingGroupRequestsForAuthenticatedUser().then(function (reqs) { self.groupRequests = reqs; }); } - function applicableGroup(g){ - - var matchingGroupRequests = self.groupRequests.filter(function(el){ + function applicableGroup(g) { + + var matchingGroupRequests = self.groupRequests.filter(function (el) { return el.groupUuid == g.id; }); - + var userGroups = []; if (self.user.groups) { - userGroups = self.user.groups.filter(function(el){ + userGroups = self.user.groups.filter(function (el) { return el.value == g.id; }); } - + return matchingGroupRequests.length === 0 && userGroups.length === 0; } @@ -121,18 +130,18 @@ controllerAs: '$ctrl', size: 'lg', resolve: { - user: function(){ + user: function () { return self.user; }, - groups: function(){ - return GroupsService.getAllGroups().then(function(res){ + groups: function () { + return GroupsService.getAllGroups().then(function (res) { return res.filter(applicableGroup); }); } } }); - modalInstance.result.then(function(r) { + modalInstance.result.then(function (r) { loadGroupRequests(); toaster.pop({ type: 'success', @@ -145,9 +154,9 @@ angular .module('dashboardApp') .component('joinGroup', { - bindings: { user: '<' , groupRequests: '='}, + bindings: { user: '<', groupRequests: '=' }, templateUrl: '/resources/iam/apps/dashboard-app/components/user/group-requests/join-group.component.html', - controller: ['GroupsService','GroupRequestsService', 'Utils', 'toaster', '$uibModal', UserGroupRequestsController], + controller: ['GroupsService', 'GroupRequestsService', 'Utils', 'toaster', '$uibModal', UserGroupRequestsController], controllerAs: '$ctrl' }); }()); \ No newline at end of file diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/group-requests/join-group.dialog.html b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/group-requests/join-group.dialog.html index 4d5b745f5..b9702c4f6 100644 --- a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/group-requests/join-group.dialog.html +++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/group-requests/join-group.dialog.html @@ -22,32 +22,50 @@
diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/controllers/add-user-group.controller.js b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/controllers/add-user-group.controller.js deleted file mode 100644 index 4c1c564ae..000000000 --- a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/controllers/add-user-group.controller.js +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2019 - * - * 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. - */ -'use strict'; - -angular.module('dashboardApp') - .controller('AddUserGroupController', AddUserGroupController); - -AddUserGroupController.$inject = [ - '$scope', '$state', '$filter', 'Utils', '$q', '$uibModalInstance', - '$sanitize', 'scimFactory', 'user' -]; - -function AddUserGroupController( - $scope, $state, $filter, Utils, $q, $uibModalInstance, $sanitize, - scimFactory, user) { - var addGroupCtrl = this; - - // methods - addGroupCtrl.cancel = cancel; - addGroupCtrl.lookupGroups = lookupGroups; - addGroupCtrl.addGroup = addGroup; - addGroupCtrl.getAllGroups = getAllGroups; - addGroupCtrl.getNotMemberGroups = getNotMemberGroups; - addGroupCtrl.loadGroups = loadGroups; - - // params - addGroupCtrl.user = user; - - // fields - addGroupCtrl.groupsSelected = null; - addGroupCtrl.groups = []; - addGroupCtrl.oGroups = []; - addGroupCtrl.enabled = true; - - addGroupCtrl.loadGroups(); - - function lookupGroups() { - return addGroupCtrl.oGroups; - } - - function loadGroups() { - addGroupCtrl.loadingGroupsProgress = 30; - addGroupCtrl.getAllGroups(1, 100); - } - - function cancel() { - $uibModalInstance.dismiss('Cancel'); - } - - function addGroup() { - addGroupCtrl.enabled = false; - var requests = []; - var groupNames = []; - angular.forEach(addGroupCtrl.groupsSelected, function(groupToAdd) { - groupNames.push(groupToAdd.displayName); - requests.push( - scimFactory.addUserToGroup(groupToAdd.id, addGroupCtrl.user)); - }); - - $q.all(requests).then( - function(response) { - console.log('Added ', addGroupCtrl.groupsSelected); - $uibModalInstance.close( - `User added to groups: '${groupNames.join(',')}'`); - addGroupCtrl.enabled = true; - }, - function(error) { - console.error(error); - $scope.operationResult = Utils.buildErrorOperationResult(error); - addGroupCtrl.enabled = true; - }); - } - - function getAllGroups(startIndex, count) { - scimFactory.getGroups(startIndex, count) - .then( - function(response) { - angular.forEach(response.data.Resources, function(group) { - addGroupCtrl.groups.push(group); - }); - addGroupCtrl.groups = - $filter('orderBy')(addGroupCtrl.groups, 'displayName', false); - - if (response.data.totalResults >= - (response.data.startIndex + response.data.itemsPerPage)) { - addGroupCtrl.loadingGroupsProgress = Math.floor( - (response.data.startIndex + response.data.itemsPerPage) * 100 / response.data.totalResults); - addGroupCtrl.getAllGroups(response.data.startIndex + response.data.itemsPerPage, count); - - } else { - addGroupCtrl.loadingGroupsProgress = 100; - addGroupCtrl.oGroups = addGroupCtrl.getNotMemberGroups(); - } - }, - function(error) { - console.error(error); - $scope.operationResult = Utils.buildErrorOperationResult(error); - }); - } - - function getNotMemberGroups() { - return addGroupCtrl.groups.filter(function(group) { - for (var i in addGroupCtrl.user.groups) { - if (group.id === addGroupCtrl.user.groups[i].value) { - return false; - } - } - return true; - }); - } -} \ No newline at end of file diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/controllers/user.controller.js b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/controllers/user.controller.js deleted file mode 100644 index e74cea053..000000000 --- a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/controllers/user.controller.js +++ /dev/null @@ -1,512 +0,0 @@ -/* - * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2019 - * - * 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. - */ -'use strict'; - -angular.module('dashboardApp').controller('UserController', UserController); - -UserController.$inject = [ '$scope', '$rootScope', '$state', '$uibModal', '$filter', '$q', - 'filterFilter', 'Utils', 'scimFactory', 'ModalService', - 'ResetPasswordService', 'RegistrationRequestService', 'UserService']; - -function UserController($scope, $rootScope, $state, $uibModal, $filter, $q, filterFilter, - Utils, scimFactory, ModalService, ResetPasswordService, RegistrationRequestService, - UserService, user) { - - var vm = this; - vm.id = $state.params.id; - - vm.user = user; - - // methods - vm.loadUserInfo = loadUserInfo; - vm.showSshKeyValue = showSshKeyValue; - vm.showCertValue = showCertValue; - vm.openAddGroupDialog = openAddGroupDialog; - vm.openAddOIDCAccountDialog = openAddOIDCAccountDialog; - vm.openAddSshKeyDialog = openAddSshKeyDialog; - vm.openAddSamlAccountDialog = openAddSamlAccountDialog; - vm.openAddX509CertificateDialog = openAddX509CertificateDialog; - vm.openEditUserDialog = openEditUserDialog; - vm.deleteGroup = deleteGroup; - vm.deleteOidcAccount = deleteOidcAccount; - vm.deleteSshKey = deleteSshKey; - vm.deleteX509Certificate = deleteX509Certificate; - vm.deleteSamlId = deleteSamlId; - vm.setActive = setActive; - vm.openLoadingModal = openLoadingModal; - vm.closeLoadingModal = closeLoadingModal; - vm.closeLoadingModalAndSetError = closeLoadingModalAndSetError; - - vm.isVoAdmin = isVoAdmin; - vm.openAssignVoAdminPrivilegesDialog = openAssignVoAdminPrivilegesDialog; - vm.openRevokeVoAdminPrivilegesDialog = openRevokeVoAdminPrivilegesDialog; - vm.isMe = isMe; - - vm.isEditUserDisabled = false; - vm.isSendResetDisabled = false; - vm.isEnableUserDisabled = false; - - // password reset - vm.doPasswordReset = doPasswordReset; - - function getUser() { - UserService.getUser(vm.id).then(function(user){ - vm.user = user; - }).catch(function(error){ - $scope.operationResult = Utils.buildErrorResult(error.message || error); - }); - } - - function openLoadingModal() { - $rootScope.pageLoadingProgress = 0; - vm.loadingModal = $uibModal.open({ - animation: false, - templateUrl : '/resources/iam/apps/dashboard-app/templates/loading-modal.html' - }); - - return vm.loadingModal.opened; - } - - function closeLoadingModal(){ - $rootScope.pageLoadingProgress = 100; - vm.loadingModal.dismiss("Cancel"); - } - - function closeLoadingModalAndSetError(error){ - $rootScope.pageLoadingProgress = 100; - vm.loadingModal.dismiss("Error"); - $scope.operationResult = Utils.buildErrorOperationResult(error); - } - - function doPasswordReset() { - - ResetPasswordService.forgotPassword($scope.vm.emails[0].value).then( - function(response) { - ModalService.showModal({}, { - closeButtonText: null, - user: $scope.user.name.formatted, - actionButtonText: 'OK', - headerText: 'Password reset requested for user', - bodyText: `A password reset link has just been sent to your e-mail address` - }); - }, function(error) { - $scope.operationResult = Utils.buildErrorOperationResult(error); - }); - } - - function sendResetMail() { - - vm.isSendResetDisabled = true; - var modalOptions = { - closeButtonText: 'Cancel', - actionButtonText: 'Send password reset e-mail', - headerText: 'Send password reset e-mail', - bodyText: `Are you sure you want to send the password reset e-mail to ${$scope.vm.name.formatted}?` - }; - - ModalService.showModal({}, modalOptions).then( - function (){ - vm.doPasswordReset(); - vm.isSendResetDisabled = false; - }, function () { - vm.isSendResetDisabled = false; - }); - } - - function openEditUserDialog() { - - vm.isEditUserDisabled = true; - - var modalInstance = $uibModal - .open({ - templateUrl : '/resources/iam/apps/dashboard-app/templates/user/editvm.html', - controller : 'EditUserController', - controllerAs : 'editUserCtrl', - resolve : { - user : function() { - return $scope.user; - } - } - }); - - modalInstance.result.then(function() { - loadUserInfo(); - $scope.operationResult = Utils.buildSuccessOperationResult("User's info updated successfully"); - vm.isEditUserDisabled = false; - }, function() { - console.log('Modal dismissed at: ', new Date()); - vm.isEditUserDisabled = false; - }); - } - - function openAddGroupDialog() { - var modalInstance = $uibModal - .open({ - templateUrl : '/resources/iam/apps/dashboard-app/templates/user/addusergroup.html', - controller : 'AddUserGroupController', - controllerAs : 'addGroupCtrl', - resolve : { - user : function() { - return $scope.user; - } - } - }); - modalInstance.result.then(function() { - console.info("Added group"); - loadUserInfo(); - $scope.operationResult = Utils.buildSuccessOperationResult("User's groups updated successfully"); - }, function() { - console.log('Modal dismissed at: ', new Date()); - }); - } - - function openAddOIDCAccountDialog() { - var modalInstance = $uibModal.open({ - templateUrl : '/resources/iam/apps/dashboard-app/templates/user/addoidc.html', - controller : 'AddOIDCAccountController', - controllerAs : 'addOidcCtrl', - resolve : { - user : function() { - return $scope.user; - } - } - }); - modalInstance.result.then(function() { - console.info("Added oidc account"); - - loadUserInfo(); - $scope.operationResult = Utils.buildSuccessOperationResult("User's Open ID Account created successfully"); - }, function() { - console.log('Modal dismissed at: ', new Date()); - }); - } - - function openAddSshKeyDialog() { - var modalInstance = $uibModal.open({ - templateUrl : '/resources/iam/apps/dashboard-app/templates/user/addsshkey.html', - controller : 'AddSshKeyController', - controllerAs : 'addSshKeyCtrl', - resolve : { - user : function() { - return $scope.user; - } - } - }); - modalInstance.result.then(function() { - console.info("Added ssh key"); - - loadUserInfo(); - $scope.operationResult = Utils.buildSuccessOperationResult("User's SSH Key added successfully"); - }, function(error) { - console.log('Modal dismissed at: ', new Date()); - }); - } - - function openAddSamlAccountDialog() { - var modalInstance = $uibModal - .open({ - templateUrl : '/resources/iam/apps/dashboard-app/templates/user/addsamlaccount.html', - controller : 'AddSamlAccountController', - controllerAs : 'addSamlAccountCtrl', - resolve : { - user : function() { - return $scope.user; - } - } - }); - modalInstance.result.then(function() { - console.info("Added saml account"); - - loadUserInfo(); - $scope.operationResult = Utils.buildSuccessOperationResult("User's SAML Account created successfully"); - }, function() { - console.log('Modal dismissed at: ', new Date()); - }); - } - - function openAddX509CertificateDialog() { - var modalInstance = $uibModal - .open({ - templateUrl : '/resources/iam/apps/dashboard-app/templates/user/addx509certificate.html', - controller : 'AddX509CertificateController', - controllerAs : 'addX509CertCtrl', - resolve : { - user : function() { - return $scope.user; - } - } - }); - modalInstance.result.then(function() { - console.info("Added x509 certificate"); - - loadUserInfo(); - $scope.operationResult = Utils.buildSuccessOperationResult("User's x509 Certificate added successfully"); - }, function() { - console.log('Modal dismissed at: ', new Date()); - }); - } - - function deleteGroup(group) { - - var modalOptions = { - closeButtonText: 'Cancel', - actionButtonText: 'Remove user from group', - headerText: 'Remove ' + $scope.user.name.formatted + ' from ' + group.display, - bodyText: `Are you sure you want to remove «${$scope.user.name.formatted}» from «${group.display}»?` - }; - - ModalService.showModal({}, modalOptions).then( - function (){ - scimFactory.removeUserFromGroup(group.value, $scope.user.id, - $scope.user.meta.location, $scope.user.name.formatted) - .then(function(response) { - - console.log("Removed group: ", group); - loadUserInfo(); - $scope.operationResult = Utils.buildSuccessOperationResult("Group membership removed successfully"); - }, function(error) { - $scope.operationResult = Utils.buildErrorOperationResult(error); - }); - }); - } - - function showSshKeyValue(sshKey) { - - ModalService.showModal({}, { - closeButtonText: null, - actionButtonText: 'OK', - headerText: sshKey.display, - bodyText: `${sshKey.value}` - }); - } - - function showCertValue(cert) { - - ModalService.showModal({}, { - closeButtonText: null, - actionButtonText: 'OK', - headerText: cert.display, - bodyText: `${cert.value}` - }); - } - - function deleteOidcAccount(oidcId) { - - var summary = oidcId.issuer + " - " + oidcId.subject; - - var modalOptions = { - closeButtonText: 'Cancel', - actionButtonText: 'Remove Open ID Account', - headerText: 'Remove Open ID Account', - bodyText: `Are you sure you want to remove the following Open ID Account?`, - bodyDetail: `${summary}` - }; - - ModalService.showModal({}, modalOptions).then( - function (){ - scimFactory.removeOpenIDAccount($scope.user.id, oidcId.issuer, - oidcId.subject) - .then(function(response) { - - console.info("Remove OIDC Account", oidcId.issuer, oidcId.subject); - loadUserInfo(); - $scope.operationResult = Utils.buildSuccessOperationResult("Open ID Account has been removed successfully"); - }, function(error) { - $scope.operationResult = Utils.buildErrorOperationResult(error); - }); - }); - } - - function deleteSshKey(sshKey) { - - var modalOptions = { - closeButtonText: 'Cancel', - actionButtonText: 'Remove ssh key', - headerText: 'Remove ssh key «' + sshKey.display + '»', - bodyText: `Are you sure you want to remove '${sshKey.display} ssh key?` - }; - - ModalService.showModal({}, modalOptions).then( - function (){ - scimFactory.removeSshKey($scope.user.id, sshKey.fingerprint) - .then(function(response) { - - console.info("Removed SSH Key", sshKey.display, sshKey.fingerprint); - loadUserInfo(); - $scope.operationResult = Utils.buildSuccessOperationResult("Ssh key has been removed successfully"); - - }, function(error) { - $scope.operationResult = Utils.buildErrorOperationResult(error); - }); - }); - } - - function deleteX509Certificate(x509cert) { - - var modalOptions = { - closeButtonText: 'Cancel', - actionButtonText: 'Remove x509 Certificate', - headerText: 'Remove «' + x509cert.display + '» x509 certificate?', - bodyText: `Are you sure you want to remove «${x509cert.display}» x509 Certificate?`, - }; - - ModalService.showModal({}, modalOptions).then( - function (){ - scimFactory.removeX509Certificate($scope.user.id, x509cert.value) - .then(function(response) { - - console.info("Removed x509 Certificate", x509cert.display); - - loadUserInfo(); - $scope.operationResult = Utils.buildSuccessOperationResult("X509 Certificate has been removed successfully"); - - }, function(error) { - $scope.operationResult = Utils.buildErrorOperationResult(error); - }); - }); - } - - function deleteSamlId(samlId) { - - var summary = samlId.idpId + " - " + samlId.userId; - - var modalOptions = { - closeButtonText: 'Cancel', - actionButtonText: 'Remove SAML Account', - headerText: 'Remove SAML account', - bodyText: `Are you sure you want to remove the following SAML Account?`, - bodyDetail: `${summary}` - }; - - ModalService.showModal({}, modalOptions).then( - function (){ - scimFactory.removeSamlId($scope.user.id, samlId) - .then(function(response) { - - console.info("Removed SAML Id", samlId.idpId, samlId.userId); - loadUserInfo(); - $scope.operationResult = Utils.buildSuccessOperationResult("SAML Account has been removed successfully"); - - }, function(error) { - $scope.operationResult = Utils.buildErrorOperationResult(error); - }); - }); - } - - function setActive(status) { - - vm.isEnableUserDisabled = true; - var modalOptions = {}; - - if (status) { - - modalOptions = { - closeButtonText: 'Cancel', - actionButtonText: 'Enable user', - headerText: 'Enable ' + $scope.user.name.formatted, - bodyText: `Are you sure you want to enable '${$scope.user.name.formatted}'?` - }; - } else { - - modalOptions = { - closeButtonText: 'Cancel', - actionButtonText: 'Disable user', - headerText: 'Disable ' + $scope.user.name.formatted, - bodyText: `Are you sure you want to disable '${$scope.user.name.formatted}'?` - }; - } - - ModalService.showModal({}, modalOptions).then( - function (){ - scimFactory.setUserActiveStatus($scope.user.id, status) - .then(function(response) { - - loadUserInfo(); - - if (status) { - $scope.operationResult = Utils.buildSuccessOperationResult("User " + $scope.user.name.formatted + " enabled"); - } else { - $scope.operationResult = Utils.buildSuccessOperationResult("User " + $scope.user.name.formatted + " disabled"); - } - vm.isEnableUserDisabled = false; - }, function(error) { - $scope.operationResult = Utils.buildErrorOperationResult(error); - vm.isEnableUserDisabled = false; - }); - }, function () { - vm.isEnableUserDisabled = false; - }); - } - - function openRevokeVoAdminPrivilegesDialog() { - var modalInstance = $uibModal - .open({ - templateUrl : '/resources/iam/apps/dashboard-app/templates/user/revoke-vo-admin-privileges.html', - controller : 'AccountPrivilegesController', - controllerAs : 'ctrl', - resolve : { - user : function() { - return user; - } - } - }); - - modalInstance.result.then(function() { - loadUserInfo(); - $scope.operationResult = Utils.buildSuccessOperationResult("Vo admin privileges revoked succesfully"); - }, function() { - console.info('Modal dismissed at: ', new Date()); - }); - } - - function openAssignVoAdminPrivilegesDialog() { - var modalInstance = $uibModal - .open({ - templateUrl : '/resources/iam/apps/dashboard-app/templates/user/assign-vo-admin-privileges.html', - controller : 'AccountPrivilegesController', - controllerAs : 'ctrl', - resolve : { - user : function() { - return user; - } - } - }); - - modalInstance.result.then(function() { - loadUserInfo(); - $scope.operationResult = Utils.buildSuccessOperationResult("Vo admin privileges assigned succesfully"); - }, function() { - console.info('Modal dismissed at: ', new Date()); - }); - } - - function isMe(){ - var authenticatedUserSub = getUserInfo().sub; - return vm.id == authenticatedUserSub; - } - - - - function isVoAdmin() { - if (vm.authorities){ - if (vm.authorities.indexOf("ROLE_ADMIN") > -1){ - return true; - } - } - - return false; - } -} diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/find.service.js b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/find.service.js new file mode 100644 index 000000000..cbb8b67c5 --- /dev/null +++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/find.service.js @@ -0,0 +1,50 @@ +/* + * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2019 + * + * 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. + */ +(function () { + 'use strict'; + + angular + .module('dashboardApp') + .service('FindService', FindService); + + + FindService.$inject = ['$q', '$http']; + + function FindService($q, $http) { + + var service = { + findUnsubscribedGroupsForAccount: findUnsubscribedGroupsForAccount + }; + + return service; + + function findUnsubscribedGroupsForAccount(accountId, nameFilter, startIndex, count) { + var url = "/iam/group/find/unsubscribed/" + accountId; + return $http.get(url, { + params: { + 'filter': nameFilter, + 'startIndex': startIndex, + 'count': count + } + }).then(function (result) { + return result.data; + }).catch(function (error) { + console.error("Error loading group members: ", error); + return $q.reject(error); + }); + } + } +}()); \ No newline at end of file diff --git a/iam-login-service/src/main/webapp/resources/iam/css/iam.css b/iam-login-service/src/main/webapp/resources/iam/css/iam.css index 8eda06e35..6dc753636 100644 --- a/iam-login-service/src/main/webapp/resources/iam/css/iam.css +++ b/iam-login-service/src/main/webapp/resources/iam/css/iam.css @@ -253,6 +253,13 @@ body.skin-blue { text-align: center; } +#login-custom-content { + max-width: 600px; + margin: 0 auto; + margin-top: 2em; + text-align: center; +} + .saml-icon { font-weight: bolder; } @@ -609,4 +616,22 @@ body.skin-blue { .bs-callout-info h4 { color: #5bc0de; +} + +.group-select-name { + font-size: 16pt; +} + +.group-select-description { + +} + +.group-select-labels { + margin-top: 1em; + margin-bottom: 1em; + border-bottom: 1px; +} + +.add-group-form{ + } \ No newline at end of file diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/api/TestSupport.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/TestSupport.java index 22c35eb1d..2790bfd05 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/api/TestSupport.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/TestSupport.java @@ -42,8 +42,12 @@ public class TestSupport { public static final String TEST_001_GROUP_UUID = "c617d586-54e6-411d-8e38-649677980001"; public static final String TEST_002_GROUP_UUID = "c617d586-54e6-411d-8e38-649677980002"; + public static final String ADMIN_USER = "admin"; + public static final String ADMIN_USER_UUID = "73f16d93-2441-4a50-88ff-85360d78c6b5"; + public static final String TEST_USER = "test"; public static final String TEST_USER_UUID = "80e5fb8d-b7c8-451a-89ba-346ae278a66f"; + public static final String TEST_100_USER = "test_100"; public static final String TEST_100_USER_UUID = "f2ce8cb2-a1db-4884-9ef0-d8842cc02b4a"; diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/api/account/find/FindAccountIntegrationTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/account/find/FindAccountIntegrationTests.java index 8e43b3338..fa9fd1bbf 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/api/account/find/FindAccountIntegrationTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/account/find/FindAccountIntegrationTests.java @@ -16,9 +16,14 @@ package it.infn.mw.iam.test.api.account.find; import static it.infn.mw.iam.api.account.find.FindAccountController.FIND_BY_EMAIL_RESOURCE; +import static it.infn.mw.iam.api.account.find.FindAccountController.FIND_BY_GROUP_RESOURCE; import static it.infn.mw.iam.api.account.find.FindAccountController.FIND_BY_LABEL_RESOURCE; import static it.infn.mw.iam.api.account.find.FindAccountController.FIND_BY_USERNAME_RESOURCE; +import static it.infn.mw.iam.api.account.find.FindAccountController.FIND_NOT_IN_GROUP_RESOURCE; +import static org.hamcrest.CoreMatchers.allOf; +import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.not; import static org.hamcrest.Matchers.emptyIterable; import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -42,10 +47,13 @@ import org.springframework.web.context.WebApplicationContext; import it.infn.mw.iam.IamLoginService; +import it.infn.mw.iam.core.group.IamGroupService; import it.infn.mw.iam.core.user.IamAccountService; import it.infn.mw.iam.persistence.model.IamAccount; +import it.infn.mw.iam.persistence.model.IamGroup; import it.infn.mw.iam.persistence.model.IamLabel; import it.infn.mw.iam.persistence.repository.IamAccountRepository; +import it.infn.mw.iam.persistence.repository.IamGroupRepository; import it.infn.mw.iam.test.api.TestSupport; import it.infn.mw.iam.test.core.CoreControllerTestSupport; import it.infn.mw.iam.test.util.WithAnonymousUser; @@ -59,7 +67,13 @@ public class FindAccountIntegrationTests extends TestSupport { @Autowired - private IamAccountRepository repo; + private IamAccountRepository accountRepo; + + @Autowired + private IamGroupRepository groupRepo; + + @Autowired + private IamGroupService groupService; @Autowired private IamAccountService accountService; @@ -97,6 +111,8 @@ public void findingRequiresAuthenticatedUser() throws Exception { .andExpect(UNAUTHORIZED); mvc.perform(get(FIND_BY_EMAIL_RESOURCE).param("email", "test@example")).andExpect(UNAUTHORIZED); mvc.perform(get(FIND_BY_USERNAME_RESOURCE).param("username", "test")).andExpect(UNAUTHORIZED); + mvc.perform(get(FIND_BY_GROUP_RESOURCE, TEST_001_GROUP_UUID)).andExpect(UNAUTHORIZED); + mvc.perform(get(FIND_NOT_IN_GROUP_RESOURCE, TEST_001_GROUP_UUID)).andExpect(UNAUTHORIZED); } @@ -109,14 +125,16 @@ public void findingRequiresAdminUser() throws Exception { mvc.perform(get(FIND_BY_EMAIL_RESOURCE).param("email", "test@example")).andExpect(FORBIDDEN); mvc.perform(get(FIND_BY_USERNAME_RESOURCE).param("username", "test")).andExpect(FORBIDDEN); + mvc.perform(get(FIND_BY_GROUP_RESOURCE, TEST_001_GROUP_UUID)).andExpect(FORBIDDEN); + mvc.perform(get(FIND_NOT_IN_GROUP_RESOURCE, TEST_001_GROUP_UUID)).andExpect(FORBIDDEN); } @Test public void findByLabelWorks() throws Exception { - IamAccount testAccount = - repo.findByUsername(TEST_USER).orElseThrow(assertionError(EXPECTED_ACCOUNT_NOT_FOUND)); + IamAccount testAccount = accountRepo.findByUsername(TEST_USER) + .orElseThrow(assertionError(EXPECTED_ACCOUNT_NOT_FOUND)); mvc.perform(get(FIND_BY_LABEL_RESOURCE).param("name", "test").param("value", "test")) .andExpect(OK) @@ -149,8 +167,8 @@ public void findByLabelWorks() throws Exception { @Test public void findByEmailWorks() throws Exception { - IamAccount testAccount = - repo.findByUsername(TEST_USER).orElseThrow(assertionError(EXPECTED_ACCOUNT_NOT_FOUND)); + IamAccount testAccount = accountRepo.findByUsername(TEST_USER) + .orElseThrow(assertionError(EXPECTED_ACCOUNT_NOT_FOUND)); String email = testAccount.getUserInfo().getEmail(); @@ -169,8 +187,8 @@ public void findByEmailWorks() throws Exception { @Test public void findByUsernameWorks() throws Exception { - IamAccount testAccount = - repo.findByUsername(TEST_USER).orElseThrow(assertionError(EXPECTED_ACCOUNT_NOT_FOUND)); + IamAccount testAccount = accountRepo.findByUsername(TEST_USER) + .orElseThrow(assertionError(EXPECTED_ACCOUNT_NOT_FOUND)); mvc.perform(get(FIND_BY_USERNAME_RESOURCE).param("username", testAccount.getUsername())) .andExpect(OK) @@ -183,4 +201,103 @@ public void findByUsernameWorks() throws Exception { .andExpect(jsonPath("$.Resources", emptyIterable())); } + + @Test + public void findByGroupWorks() throws Exception { + + IamAccount testAccount = accountRepo.findByUsername(TEST_USER) + .orElseThrow(assertionError(EXPECTED_ACCOUNT_NOT_FOUND)); + + // Cleanup all group memberships and groups + accountRepo.deleteAllAccountGroupMemberships(); + groupRepo.deleteAll(); + + // Create group hierarchy + IamGroup rootGroup = new IamGroup(); + rootGroup.setName("root"); + + rootGroup = groupService.createGroup(rootGroup); + + IamGroup subgroup = new IamGroup(); + subgroup.setName("root/subgroup"); + subgroup.setParentGroup(rootGroup); + + subgroup = groupService.createGroup(subgroup); + + IamGroup sibling = new IamGroup(); + sibling.setName("sibling"); + + sibling = groupService.createGroup(sibling); + + mvc.perform(get(FIND_BY_GROUP_RESOURCE, rootGroup.getUuid())) + .andExpect(OK) + .andExpect(jsonPath("$.totalResults", is(0))) + .andExpect(jsonPath("$.Resources", emptyIterable())); + + accountService.addToGroup(testAccount, rootGroup); + + mvc.perform(get(FIND_BY_GROUP_RESOURCE, rootGroup.getUuid())) + .andExpect(OK) + .andExpect(jsonPath("$.totalResults", is(1))) + .andExpect(jsonPath("$.Resources[0].id", is(testAccount.getUuid()))); + + mvc.perform(get(FIND_BY_GROUP_RESOURCE, rootGroup.getUuid()).param("filter", "")) + .andExpect(BAD_REQUEST) + .andExpect(jsonPath("$.detail", allOf(containsString("Invalid find account request"), + containsString("Please provide a non-blank"), containsString("between 2 and 64")))); + + mvc.perform(get(FIND_BY_GROUP_RESOURCE, rootGroup.getUuid()).param("filter", " ")) + .andExpect(BAD_REQUEST) + .andExpect(jsonPath("$.detail", allOf(containsString("Invalid find account request"), + containsString("Please provide a non-blank"), not(containsString("between 2 and 64"))))); + + mvc.perform(get(FIND_BY_GROUP_RESOURCE, rootGroup.getUuid()).param("filter", "no_match")) + .andExpect(OK) + .andExpect(jsonPath("$.totalResults", is(0))) + .andExpect(jsonPath("$.Resources", emptyIterable())); + + mvc.perform(get(FIND_BY_GROUP_RESOURCE, rootGroup.getUuid()).param("filter", "est")) + .andExpect(OK) + .andExpect(jsonPath("$.totalResults", is(1))) + .andExpect(jsonPath("$.Resources[0].id", is(testAccount.getUuid()))); + } + + @Test + public void findNotInGroupWorks() throws Exception { + IamAccount adminAccount = + accountRepo.findByUsername(ADMIN_USER) + .orElseThrow(assertionError(EXPECTED_ACCOUNT_NOT_FOUND)); + + // Cleanup all group memberships and groups + accountRepo.deleteAllAccountGroupMemberships(); + groupRepo.deleteAll(); + + // Create group hierarchy + IamGroup rootGroup = new IamGroup(); + rootGroup.setName("root"); + + rootGroup = groupService.createGroup(rootGroup); + + IamGroup subgroup = new IamGroup(); + subgroup.setName("root/subgroup"); + subgroup.setParentGroup(rootGroup); + + subgroup = groupService.createGroup(subgroup); + + IamGroup sibling = new IamGroup(); + sibling.setName("sibling"); + + sibling = groupService.createGroup(sibling); + + mvc.perform(get(FIND_NOT_IN_GROUP_RESOURCE, rootGroup.getUuid()).param("count", "10")) + .andExpect(OK) + .andExpect(jsonPath("$.totalResults", is(253))); + + mvc.perform(get(FIND_NOT_IN_GROUP_RESOURCE, rootGroup.getUuid()).param("filter", "admin")) + .andExpect(OK) + .andExpect(jsonPath("$.totalResults", is(2))) + .andExpect(jsonPath("$.Resources[0].id", is(adminAccount.getUuid()))) + .andExpect(jsonPath("$.Resources[1].id", is("bffc67b7-47fe-410c-a6a0-cf00173a8fbb"))); + } + } diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/api/account/group/GroupMembersIntegrationTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/account/group/GroupMembersIntegrationTests.java index d252805cc..9fead991a 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/api/account/group/GroupMembersIntegrationTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/account/group/GroupMembersIntegrationTests.java @@ -43,6 +43,7 @@ import org.springframework.web.context.WebApplicationContext; import it.infn.mw.iam.IamLoginService; +import it.infn.mw.iam.core.group.IamGroupService; import it.infn.mw.iam.core.user.IamAccountService; import it.infn.mw.iam.persistence.model.IamAccount; import it.infn.mw.iam.persistence.model.IamAccountGroupMembership; @@ -73,6 +74,9 @@ public class GroupMembersIntegrationTests { @Autowired private IamAccountService accountService; + @Autowired + private IamGroupService groupService; + @Autowired private IamAccountRepository accountRepo; @@ -90,10 +94,8 @@ public class GroupMembersIntegrationTests { @Before public void setup() { mockOAuth2Filter.cleanupSecurityContext(); - mvc = MockMvcBuilders.webAppContextSetup(context) - .apply(springSecurity()) - .alwaysDo(log()) - .build(); + mvc = + MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).alwaysDo(log()).build(); mockOAuth2Filter.cleanupSecurityContext(); } @@ -269,12 +271,10 @@ public void groupManagerCanRemoveMember() throws Exception { } - - @Test @WithMockUser(username = ADMIN_USER, roles = {"USER", "ADMIN"}) - public void cannotChangeMembershipForUnknownGroupOrAccount()throws Exception { - + public void cannotChangeMembershipForUnknownGroupOrAccount() throws Exception { + IamAccount account = accountRepo.findByUsername(TEST_USER).orElseThrow(assertionError(EXPECTED_USER_NOT_FOUND)); @@ -282,22 +282,221 @@ public void cannotChangeMembershipForUnknownGroupOrAccount()throws Exception { groupRepo.findByName(TEST_001_GROUP).orElseThrow(assertionError(EXPECTED_GROUP_NOT_FOUND)); String randomUuid = UUID.randomUUID().toString(); - + mvc.perform(post("/iam/account/{account}/groups/{group}", randomUuid, group.getUuid())) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error", containsString("Account not found"))); - + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error", containsString("Account not found"))); + mvc.perform(post("/iam/account/{account}/groups/{group}", account.getUuid(), randomUuid)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error", containsString("Group not found"))); - + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error", containsString("Group not found"))); + mvc.perform(delete("/iam/account/{account}/groups/{group}", randomUuid, group.getUuid())) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error", containsString("Account not found"))); - + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error", containsString("Account not found"))); + mvc.perform(delete("/iam/account/{account}/groups/{group}", account.getUuid(), randomUuid)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error", containsString("Group not found"))); - + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error", containsString("Group not found"))); + + } + + @Test + @WithMockUser(username = ADMIN_USER, roles = {"USER", "ADMIN"}) + public void intermediateGroupMembershipIsEnforcedOnAdd() throws Exception { + + // Create group hierarchy + IamGroup rootGroup = new IamGroup(); + rootGroup.setName("root"); + + rootGroup = groupService.createGroup(rootGroup); + + IamGroup subgroup = new IamGroup(); + subgroup.setName("root/subgroup"); + subgroup.setParentGroup(rootGroup); + + subgroup = groupService.createGroup(subgroup); + + IamGroup subsubgroup = new IamGroup(); + subsubgroup.setName("root/subgroup/subsubgroup"); + subsubgroup.setParentGroup(subgroup); + + subsubgroup = groupService.createGroup(subsubgroup); + + IamGroup sibling = new IamGroup(); + sibling.setName("root/sibling"); + sibling.setParentGroup(rootGroup); + sibling = groupService.createGroup(sibling); + + IamAccount account = + accountRepo.findByUsername(TEST_USER).orElseThrow(assertionError(EXPECTED_USER_NOT_FOUND)); + + mvc + .perform( + post("/iam/account/{account}/groups/{group}", account.getUuid(), subsubgroup.getUuid())) + .andExpect(status().isCreated()); + + assertThat( + groupRepo.findGroupByMemberAccountUuidAndGroupUuid(account.getUuid(), subgroup.getUuid()) + .isPresent(), + is(true)); + + assertThat( + groupRepo.findGroupByMemberAccountUuidAndGroupUuid(account.getUuid(), rootGroup.getUuid()) + .isPresent(), + is(true)); + + assertThat( + groupRepo.findGroupByMemberAccountUuidAndGroupUuid(account.getUuid(), sibling.getUuid()) + .isPresent(), + is(false)); + + + IamAccountGroupMembership m = + IamAccountGroupMembership.forAccountAndGroup(null, account, subsubgroup); + + assertThat(account.getGroups().contains(m), is(true)); + + m = IamAccountGroupMembership.forAccountAndGroup(null, account, subgroup); + + assertThat(account.getGroups().contains(m), is(true)); + + m = IamAccountGroupMembership.forAccountAndGroup(null, account, rootGroup); + + assertThat(account.getGroups().contains(m), is(true)); + + } + + @Test + @WithMockUser(username = ADMIN_USER, roles = {"USER", "ADMIN"}) + public void intermediateGroupMembershipIsEnforcedOnRemove() throws Exception { + + // Create group hierarchy + IamGroup rootGroup = new IamGroup(); + rootGroup.setName("root"); + + rootGroup = groupService.createGroup(rootGroup); + + IamGroup subgroup = new IamGroup(); + subgroup.setName("root/subgroup"); + subgroup.setParentGroup(rootGroup); + + subgroup = groupService.createGroup(subgroup); + + IamGroup subsubgroup = new IamGroup(); + subsubgroup.setName("root/subgroup/subsubgroup"); + subsubgroup.setParentGroup(subgroup); + + subsubgroup = groupService.createGroup(subsubgroup); + + IamGroup sibling = new IamGroup(); + sibling.setName("root/sibling"); + sibling.setParentGroup(rootGroup); + sibling = groupService.createGroup(sibling); + + IamAccount account = + accountRepo.findByUsername(TEST_USER).orElseThrow(assertionError(EXPECTED_USER_NOT_FOUND)); + + // Add test user to /root/subgroup and /root/sibling + mvc + .perform(post("/iam/account/{account}/groups/{group}", account.getUuid(), subgroup.getUuid())) + .andExpect(status().isCreated()); + + mvc.perform(post("/iam/account/{account}/groups/{group}", account.getUuid(), sibling.getUuid())) + .andExpect(status().isCreated()); + + assertThat( + groupRepo.findGroupByMemberAccountUuidAndGroupUuid(account.getUuid(), subgroup.getUuid()) + .isPresent(), + is(true)); + + assertThat( + groupRepo.findGroupByMemberAccountUuidAndGroupUuid(account.getUuid(), sibling.getUuid()) + .isPresent(), + is(true)); + + assertThat( + groupRepo.findGroupByMemberAccountUuidAndGroupUuid(account.getUuid(), rootGroup.getUuid()) + .isPresent(), + is(true)); + + + IamAccountGroupMembership m = + IamAccountGroupMembership.forAccountAndGroup(null, account, subgroup); + + assertThat(account.getGroups().contains(m), is(true)); + + m = IamAccountGroupMembership.forAccountAndGroup(null, account, rootGroup); + + assertThat(account.getGroups().contains(m), is(true)); + + m = IamAccountGroupMembership.forAccountAndGroup(null, account, sibling); + + assertThat(account.getGroups().contains(m), is(true)); + + // Remove test user from /root + mvc + .perform( + delete("/iam/account/{account}/groups/{group}", account.getUuid(), rootGroup.getUuid())) + .andExpect(status().isNoContent()); + + assertThat( + groupRepo.findGroupByMemberAccountUuidAndGroupUuid(account.getUuid(), rootGroup.getUuid()) + .isPresent(), + is(false)); + + assertThat( + groupRepo.findGroupByMemberAccountUuidAndGroupUuid(account.getUuid(), subgroup.getUuid()) + .isPresent(), + is(false)); + + assertThat( + groupRepo.findGroupByMemberAccountUuidAndGroupUuid(account.getUuid(), sibling.getUuid()) + .isPresent(), + is(false)); + + // Add test user to /root/subgroup/subsubgroup + mvc + .perform( + post("/iam/account/{account}/groups/{group}", account.getUuid(), subsubgroup.getUuid())) + .andExpect(status().isCreated()); + + assertThat( + groupRepo.findGroupByMemberAccountUuidAndGroupUuid(account.getUuid(), subgroup.getUuid()) + .isPresent(), + is(true)); + + assertThat( + groupRepo.findGroupByMemberAccountUuidAndGroupUuid(account.getUuid(), subsubgroup.getUuid()) + .isPresent(), + is(true)); + + assertThat( + groupRepo.findGroupByMemberAccountUuidAndGroupUuid(account.getUuid(), rootGroup.getUuid()) + .isPresent(), + is(true)); + + // Remove test user from /root/subgroup/subsubgroup + mvc + .perform( + delete("/iam/account/{account}/groups/{group}", account.getUuid(), subsubgroup.getUuid())) + .andExpect(status().isNoContent()); + + assertThat( + groupRepo.findGroupByMemberAccountUuidAndGroupUuid(account.getUuid(), subsubgroup.getUuid()) + .isPresent(), + is(false)); + + assertThat( + groupRepo.findGroupByMemberAccountUuidAndGroupUuid(account.getUuid(), subgroup.getUuid()) + .isPresent(), + is(true)); + + assertThat( + groupRepo.findGroupByMemberAccountUuidAndGroupUuid(account.getUuid(), rootGroup.getUuid()) + .isPresent(), + is(true)); } + + } diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/api/group/FindGroupTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/group/FindGroupTests.java index 0a6619078..957419813 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/api/group/FindGroupTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/group/FindGroupTests.java @@ -17,6 +17,9 @@ import static it.infn.mw.iam.api.group.find.FindGroupController.FIND_BY_LABEL_RESOURCE; import static it.infn.mw.iam.api.group.find.FindGroupController.FIND_BY_NAME_RESOURCE; +import static it.infn.mw.iam.api.group.find.FindGroupController.FIND_UNSUBSCRIBED_GROUPS_FOR_ACCOUNT; +import static org.apache.commons.lang3.RandomStringUtils.randomAlphabetic; +import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.Matchers.emptyIterable; import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; @@ -41,7 +44,11 @@ import org.springframework.web.context.WebApplicationContext; import it.infn.mw.iam.IamLoginService; +import it.infn.mw.iam.core.group.IamGroupService; +import it.infn.mw.iam.core.user.IamAccountService; +import it.infn.mw.iam.persistence.model.IamAccount; import it.infn.mw.iam.persistence.model.IamGroup; +import it.infn.mw.iam.persistence.repository.IamAccountRepository; import it.infn.mw.iam.persistence.repository.IamGroupRepository; import it.infn.mw.iam.test.api.TestSupport; import it.infn.mw.iam.test.core.CoreControllerTestSupport; @@ -64,7 +71,16 @@ public class FindGroupTests extends TestSupport { private MockOAuth2Filter mockOAuth2Filter; @Autowired - private IamGroupRepository repo; + private IamGroupRepository groupRepo; + + @Autowired + private IamGroupService groupService; + + @Autowired + private IamAccountRepository accountRepo; + + @Autowired + private IamAccountService accountService; @Before public void setup() { @@ -87,13 +103,12 @@ private Supplier assertionError(String message) { @WithAnonymousUser public void findingRequiresAuthenticatedUser() throws Exception { - mvc - .perform(get(FIND_BY_LABEL_RESOURCE).param("name", "test") - .param("value", "test")) + mvc.perform(get(FIND_BY_LABEL_RESOURCE).param("name", "test").param("value", "test")) .andExpect(UNAUTHORIZED); - mvc.perform(get(FIND_BY_NAME_RESOURCE).param("name", "test")) - .andExpect(UNAUTHORIZED); + mvc.perform(get(FIND_BY_NAME_RESOURCE).param("name", "test")).andExpect(UNAUTHORIZED); + + mvc.perform(get(FIND_UNSUBSCRIBED_GROUPS_FOR_ACCOUNT, TEST_USER_UUID)).andExpect(UNAUTHORIZED); } @@ -101,22 +116,20 @@ public void findingRequiresAuthenticatedUser() throws Exception { @WithMockUser(username = "test", roles = "USER") public void findingRequiresAdminUser() throws Exception { - mvc - .perform(get(FIND_BY_LABEL_RESOURCE).param("name", "test") - .param("value", "test")) + mvc.perform(get(FIND_BY_LABEL_RESOURCE).param("name", "test").param("value", "test")) .andExpect(FORBIDDEN); - mvc.perform(get(FIND_BY_NAME_RESOURCE).param("name", "test")) - .andExpect(FORBIDDEN); + mvc.perform(get(FIND_BY_NAME_RESOURCE).param("name", "test")).andExpect(FORBIDDEN); + mvc.perform(get(FIND_UNSUBSCRIBED_GROUPS_FOR_ACCOUNT, TEST_USER_UUID)).andExpect(FORBIDDEN); } @Test public void findByNameWorks() throws Exception { - IamGroup group = - repo.findByUuid(TEST_001_GROUP_UUID).orElseThrow(assertionError(EXPECTED_GROUP_NOT_FOUND)); + IamGroup group = groupRepo.findByUuid(TEST_001_GROUP_UUID) + .orElseThrow(assertionError(EXPECTED_GROUP_NOT_FOUND)); mvc.perform(get(FIND_BY_NAME_RESOURCE).param("name", group.getName())) .andExpect(OK) @@ -132,4 +145,80 @@ public void findByNameWorks() throws Exception { } + @Test + public void findUnsubscribedGroupsWorks() throws Exception { + + IamAccount testAccount = + accountRepo.findByUsername(TEST_USER) + .orElseThrow(assertionError(EXPECTED_ACCOUNT_NOT_FOUND)); + + // Cleanup all group memberships and groups + accountRepo.deleteAllAccountGroupMemberships(); + groupRepo.deleteAll(); + + // Create group hierarchy + IamGroup rootGroup = new IamGroup(); + rootGroup.setName("root"); + + rootGroup = groupService.createGroup(rootGroup); + + IamGroup subgroup = new IamGroup(); + subgroup.setName("root/subgroup"); + subgroup.setParentGroup(rootGroup); + + subgroup = groupService.createGroup(subgroup); + + IamGroup sibling = new IamGroup(); + sibling.setName("sibling"); + + sibling = groupService.createGroup(sibling); + + mvc.perform(get(FIND_UNSUBSCRIBED_GROUPS_FOR_ACCOUNT, TEST_USER_UUID)) + .andExpect(OK) + .andExpect(jsonPath("$.totalResults", is(3))) + .andExpect(jsonPath("$.Resources[0].displayName", is("root"))) + .andExpect(jsonPath("$.Resources[1].displayName", is("root/subgroup"))) + .andExpect(jsonPath("$.Resources[2].displayName", is("sibling"))); + + accountService.addToGroup(testAccount, subgroup); + + mvc.perform(get(FIND_UNSUBSCRIBED_GROUPS_FOR_ACCOUNT, TEST_USER_UUID)) + .andExpect(OK) + .andExpect(jsonPath("$.totalResults", is(1))) + .andExpect(jsonPath("$.Resources[0].displayName", is("sibling"))); + + accountService.addToGroup(testAccount, sibling); + + mvc.perform(get(FIND_UNSUBSCRIBED_GROUPS_FOR_ACCOUNT, TEST_USER_UUID)) + .andExpect(OK) + .andExpect(jsonPath("$.totalResults", is(0))) + .andExpect(jsonPath("$.Resources", emptyIterable())); + + accountRepo.deleteAllAccountGroupMemberships(); + + mvc + .perform(get(FIND_UNSUBSCRIBED_GROUPS_FOR_ACCOUNT, TEST_USER_UUID).param("filter", "sib")) + .andExpect(OK) + .andExpect(jsonPath("$.totalResults", is(1))) + .andExpect(jsonPath("$.Resources[0].displayName", is("sibling"))); + + mvc.perform(get(FIND_UNSUBSCRIBED_GROUPS_FOR_ACCOUNT, TEST_USER_UUID).param("filter", "")) + .andExpect(BAD_REQUEST) + .andExpect(jsonPath("$.status", is("400"))) + .andExpect(jsonPath("$.detail", containsString("Invalid find group request"))); + + mvc.perform(get(FIND_UNSUBSCRIBED_GROUPS_FOR_ACCOUNT, TEST_USER_UUID).param("filter", "a")) + .andExpect(BAD_REQUEST) + .andExpect(jsonPath("$.status", is("400"))) + .andExpect(jsonPath("$.detail", containsString("Invalid find group request"))); + + mvc + .perform(get(FIND_UNSUBSCRIBED_GROUPS_FOR_ACCOUNT, TEST_USER_UUID).param("filter", + randomAlphabetic(65))) + .andExpect(BAD_REQUEST) + .andExpect(jsonPath("$.status", is("400"))) + .andExpect(jsonPath("$.detail", containsString("Invalid find group request"))); + + } + } diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/notification/RegistrationFlowFailTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/notification/RegistrationFlowFailTests.java index 2818b7736..e428194c1 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/notification/RegistrationFlowFailTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/notification/RegistrationFlowFailTests.java @@ -15,15 +15,13 @@ */ package it.infn.mw.iam.test.notification; -import com.fasterxml.jackson.databind.ObjectMapper; -import freemarker.template.TemplateException; -import it.infn.mw.iam.IamLoginService; -import it.infn.mw.iam.notification.NotificationProperties; -import it.infn.mw.iam.registration.RegistrationRequestDto; -import it.infn.mw.iam.test.core.CoreControllerTestSupport; -import it.infn.mw.iam.test.util.WithAnonymousUser; -import it.infn.mw.iam.test.util.notification.MockNotificationDelivery; -import it.infn.mw.iam.test.util.oauth.MockOAuth2Filter; +import static org.hamcrest.Matchers.hasSize; +import static org.junit.Assert.assertThat; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.log; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -36,93 +34,89 @@ import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; import org.springframework.test.context.web.WebAppConfiguration; import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.result.MockMvcResultMatchers; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.context.WebApplicationContext; import org.springframework.web.util.NestedServletException; -import static org.hamcrest.Matchers.*; -import static org.junit.Assert.assertThat; -import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.log; +import com.fasterxml.jackson.databind.ObjectMapper; + +import it.infn.mw.iam.IamLoginService; +import it.infn.mw.iam.registration.RegistrationRequestDto; +import it.infn.mw.iam.test.core.CoreControllerTestSupport; +import it.infn.mw.iam.test.util.WithAnonymousUser; +import it.infn.mw.iam.test.util.notification.MockNotificationDelivery; +import it.infn.mw.iam.test.util.oauth.MockOAuth2Filter; @RunWith(SpringJUnit4ClassRunner.class) @SpringApplicationConfiguration(classes = {IamLoginService.class, NotificationTestConfig.class, - CoreControllerTestSupport.class}) + CoreControllerTestSupport.class}) @WebAppConfiguration @Transactional @WithAnonymousUser -@TestPropertySource(properties = { - "notification.disable=false", - "spring.freemarker.template-loader-path=/invalid/" -}) +@TestPropertySource( + properties = {"notification.disable=false", "spring.freemarker.template-loader-path=/invalid/"}) public class RegistrationFlowFailTests { - @Autowired - private NotificationProperties properties; + @Value("${spring.mail.host}") + private String mailHost; - @Value("${spring.mail.host}") - private String mailHost; + @Value("${spring.mail.port}") + private Integer mailPort; - @Value("${spring.mail.port}") - private Integer mailPort; + @Value("${iam.organisation.name}") + private String organisationName; - @Value("${iam.organisation.name}") - private String organisationName; + @Value("${iam.baseUrl}") + private String baseUrl; - @Value("${iam.baseUrl}") - private String baseUrl; + @Autowired + private MockNotificationDelivery notificationDelivery; - @Autowired - private MockNotificationDelivery notificationDelivery; + @Autowired + private MockOAuth2Filter mockOAuth2Filter; - @Autowired - private MockOAuth2Filter mockOAuth2Filter; + @Autowired + private WebApplicationContext context; - @Autowired - private WebApplicationContext context; + @Autowired + private ObjectMapper mapper; - @Autowired - private ObjectMapper mapper; + private MockMvc mvc; - private MockMvc mvc; + @Before + public void setUp() throws InterruptedException { + mvc = + MockMvcBuilders.webAppContextSetup(context).alwaysDo(log()).apply(springSecurity()).build(); + } - @Before - public void setUp() throws InterruptedException { - mvc = MockMvcBuilders.webAppContextSetup(context).alwaysDo(log()).apply(springSecurity()).build(); - } + @After + public void tearDown() throws InterruptedException { + mockOAuth2Filter.cleanupSecurityContext(); + notificationDelivery.clearDeliveredNotifications(); + } - @After - public void tearDown() throws InterruptedException { - mockOAuth2Filter.cleanupSecurityContext(); - notificationDelivery.clearDeliveredNotifications(); - } + @Test + public void testSendWithEmptyQueue() { + notificationDelivery.sendPendingNotifications(); + assertThat(notificationDelivery.getDeliveredNotifications(), hasSize(0)); + } - @Test - public void testSendWithEmptyQueue() { - notificationDelivery.sendPendingNotifications(); - assertThat(notificationDelivery.getDeliveredNotifications(), hasSize(0)); - } + @Test(expected = NestedServletException.class) + public void testBadTemplateDir() throws Exception { + String username = "baddir_flow"; - @Test(expected = NestedServletException.class) - public void testBadDir() throws Exception { - String username = "baddir_flow"; + RegistrationRequestDto request = new RegistrationRequestDto(); + request.setGivenname("Badddir flow"); + request.setFamilyname("Test"); + request.setEmail("Baddir@example.com"); + request.setUsername(username); + request.setNotes("Some short notes..."); - RegistrationRequestDto request = new RegistrationRequestDto(); - request.setGivenname("Badddir flow"); - request.setFamilyname("Test"); - request.setEmail("Baddir@example.com"); - request.setUsername(username); - request.setNotes("Some short notes..."); + mvc + .perform(post("/registration/create").contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request))) + .andExpect(status().isInternalServerError()); - String responseJson = mvc - .perform(post("/registration/create").contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request))) - .andExpect(MockMvcResultMatchers.status().isInternalServerError()) - .andReturn() - .getResponse() - .getContentAsString(); - } + } } diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/JWKEndpointTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/JWKEndpointTests.java new file mode 100644 index 000000000..934a541de --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/JWKEndpointTests.java @@ -0,0 +1,77 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2019 + * + * 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. + */ +package it.infn.mw.iam.test.oauth; + +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.log; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import static org.hamcrest.Matchers.hasSize; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.SpringApplicationConfiguration; +import org.springframework.http.MediaType; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.test.context.web.WebAppConfiguration; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.context.WebApplicationContext; + +import it.infn.mw.iam.IamLoginService; +import it.infn.mw.iam.core.web.jwk.IamJWKSetPublishingEndpoint; + +@RunWith(SpringJUnit4ClassRunner.class) +@SpringApplicationConfiguration(classes = IamLoginService.class) +@WebAppConfiguration +@Transactional +public class JWKEndpointTests { + + private static final String ENDPOINT = "/" + IamJWKSetPublishingEndpoint.URL; + + @Autowired + private WebApplicationContext context; + + private MockMvc mvc; + + @Before + public void setup() throws Exception { + mvc = + MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).alwaysDo(log()).build(); + } + + @Test + public void testKeys() throws Exception { + + // @formatter:off + mvc.perform(get(ENDPOINT)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("$.keys", hasSize(1))) + .andExpect(jsonPath("$.keys[0].kty").value("RSA")) + .andExpect(jsonPath("$.keys[0].e").value("AQAB")) + .andExpect(jsonPath("$.keys[0].kid").value("rsa1")) + .andExpect(jsonPath("$.keys[0].n").value("nuvTJO-6RxIbIyYpPvAWeLSZ4o8o9T_lFU0ltiqAlp5eR-ID36aPqMvBGnNOcTVPcoFpfmQL5INgoWNJGTUm7pWTpV1wZjZe7PX6dFBhRe8SQQ0yb5SVc29-sX1QK-Cg7gKTe0l7Wrhve2vazHH1uYEqLUoTVnGsAx1nzL66M-M")); + // @formatter:on + + } +} diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/TokenExchangeTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/TokenExchangeTests.java index 57d517329..43d019fc2 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/TokenExchangeTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/TokenExchangeTests.java @@ -502,7 +502,46 @@ public void testTokenExchangeForClientCredentialsClient() throws Exception { .andExpect(jsonPath("$.scope", allOf(containsString("read-tasks"), containsString("offline_access")))); } - + + + @Test + public void testTokenExchangeForbiddenWhenActorClientIsSubjectClient() throws Exception { + + + String clientId = "token-exchange-actor"; + String clientSecret = "secret"; + + + String accessToken = new AccessTokenGetter().grantType("password") + .clientId(clientId) + .clientSecret(clientSecret) + .username(TEST_USER_USERNAME) + .password(TEST_USER_PASSWORD) + .scope("openid profile offline_access") + .getAccessTokenValue(); + + + mvc.perform(post(TOKEN_ENDPOINT).with(httpBasic(clientId, clientSecret)) + .param("grant_type", GRANT_TYPE) + .param("subject_token", accessToken) + .param("subject_token_type", TOKEN_TYPE) + .param("scope", "openid offline_access")) + .andExpect(status().isForbidden()); + + + mvc + .perform(post(TOKEN_ENDPOINT).with(httpBasic(clientId, clientSecret)) + .param("grant_type", GRANT_TYPE) + .param("subject_token", accessToken) + .param("subject_token_type", TOKEN_TYPE) + .param("scope", "openid")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.scope", equalTo("openid"))) + .andExpect(jsonPath("$.id_token", notNullValue())) + .andExpect(jsonPath("$.access_token", notNullValue())) + .andExpect(jsonPath("$.refresh_token").doesNotExist()); + } + @Test public void testActClaimSetting() throws Exception { diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/exchange/ExchangePolicyApiIntegrationTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/exchange/ExchangePolicyApiIntegrationTests.java index b6bfff499..2050bf084 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/exchange/ExchangePolicyApiIntegrationTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/exchange/ExchangePolicyApiIntegrationTests.java @@ -20,6 +20,9 @@ import static org.apache.commons.lang3.RandomStringUtils.randomAlphanumeric; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.hasSize; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; @@ -34,8 +37,12 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.SpringApplicationConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; import org.springframework.test.context.web.WebAppConfiguration; import org.springframework.test.web.servlet.MockMvc; @@ -50,21 +57,37 @@ import it.infn.mw.iam.api.exchange_policy.ClientMatchingPolicyDTO; import it.infn.mw.iam.api.exchange_policy.ExchangePolicyDTO; import it.infn.mw.iam.api.exchange_policy.ExchangeScopePolicyDTO; +import it.infn.mw.iam.core.oauth.exchange.DefaultTokenExchangePdp; +import it.infn.mw.iam.core.oauth.exchange.TokenExchangePdp; +import it.infn.mw.iam.core.oauth.scope.matchers.ScopeMatcherRegistry; import it.infn.mw.iam.persistence.model.IamScopePolicy.MatchingPolicy; import it.infn.mw.iam.persistence.model.PolicyRule; import it.infn.mw.iam.persistence.repository.IamTokenExchangePolicyRepository; import it.infn.mw.iam.test.core.CoreControllerTestSupport; +import it.infn.mw.iam.test.oauth.exchange.ExchangePolicyApiIntegrationTests.TestBeans; import it.infn.mw.iam.test.util.WithAnonymousUser; import it.infn.mw.iam.test.util.WithMockOAuthUser; import it.infn.mw.iam.test.util.oauth.MockOAuth2Filter; @RunWith(SpringJUnit4ClassRunner.class) -@SpringApplicationConfiguration(classes = {IamLoginService.class, CoreControllerTestSupport.class}) +@SpringApplicationConfiguration( + classes = {IamLoginService.class, CoreControllerTestSupport.class, TestBeans.class}) @WebAppConfiguration @Transactional @WithAnonymousUser public class ExchangePolicyApiIntegrationTests { + @Configuration + public static class TestBeans { + @Bean + @Primary + public TokenExchangePdp tokenExchangePdp(IamTokenExchangePolicyRepository repo, + ScopeMatcherRegistry registry) { + DefaultTokenExchangePdp pdp = new DefaultTokenExchangePdp(repo, registry); + return Mockito.spy(pdp); + } + } + public static final String ENDPOINT = "/iam/api/exchange/policies"; @Autowired @@ -79,12 +102,16 @@ public class ExchangePolicyApiIntegrationTests { @Autowired IamTokenExchangePolicyRepository repo; + @Autowired + TokenExchangePdp pdp; + private MockMvc mvc; @Before public void setup() throws Exception { mvc = MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).alwaysDo(log()).build(); + reset(pdp); } @@ -143,6 +170,7 @@ public void deletePolicyRequiresAdminUser() throws Exception { public void deletePolicyWorks() throws Exception { mvc.perform(delete(ENDPOINT + "/1")).andExpect(status().isNoContent()); mvc.perform(delete(ENDPOINT + "/1")).andExpect(status().isNotFound()); + verify(pdp, times(1)).reloadPolicies(); } @@ -181,6 +209,9 @@ public void createPolicyWorks() throws Exception { mvc.perform(post(ENDPOINT).content(policy).contentType(APPLICATION_JSON)) .andExpect(status().isCreated()); + + verify(pdp, times(2)).reloadPolicies(); + mvc.perform(get(ENDPOINT)) .andExpect(status().isOk()) .andExpect(jsonPath("$").isArray()) diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/exchange/TokenExchangePdPTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/exchange/TokenExchangePdPTests.java index 69168e1d2..6b248b59a 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/exchange/TokenExchangePdPTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/exchange/TokenExchangePdPTests.java @@ -87,6 +87,7 @@ public void before() { .map(StringEqualsScopeMatcher::stringEqualsMatcher) .collect(toSet())); when(repo.findAll()).thenReturn(emptyList()); + pdp.reloadPolicies(); } @Test @@ -104,6 +105,7 @@ public void tokenAllowAllExchanges() { IamTokenExchangePolicyEntity pe = buildPermitExamplePolicy(1L, "Allow all exchanges"); when(repo.findAll()).thenReturn(Arrays.asList(pe)); + pdp.reloadPolicies(); TokenExchangePdpResult result = pdp.validateTokenExchange(request, originClient, destinationClient); @@ -120,6 +122,7 @@ public void tokenDenyAllExchanges() { IamTokenExchangePolicyEntity pe = buildDenyExamplePolicy(1L, "Deny all exchanges"); when(repo.findAll()).thenReturn(Arrays.asList(pe)); + pdp.reloadPolicies(); TokenExchangePdpResult result = pdp.validateTokenExchange(request, originClient, destinationClient); @@ -138,6 +141,7 @@ public void testPolicyRankedCombination() { p2.setOriginClient(buildByIdClientMatcher("origin")); when(repo.findAll()).thenReturn(Arrays.asList(p1, p2)); + pdp.reloadPolicies(); TokenExchangePdpResult result = pdp.validateTokenExchange(request, originClient, destinationClient); @@ -156,6 +160,7 @@ public void testSameRankDenyWins() { IamTokenExchangePolicyEntity p3 = buildPermitExamplePolicy(3L, "Allow all exchanges"); when(repo.findAll()).thenReturn(asList(p1, p2, p3)); + pdp.reloadPolicies(); TokenExchangePdpResult result = pdp.validateTokenExchange(request, originClient, destinationClient); @@ -181,6 +186,7 @@ public void rankingWorksAsExpected() { p2.setDestinationClient(s2ScopeClient); when(repo.findAll()).thenReturn(asList(p1, p2)); + pdp.reloadPolicies(); TokenExchangePdpResult result = pdp.validateTokenExchange(request, originClient, destinationClient); @@ -198,21 +204,39 @@ public void clientScopeCheckingWorks() { request.setScope(asList("s5")); when(repo.findAll()).thenReturn(asList(p1)); + pdp.reloadPolicies(); TokenExchangePdpResult result = pdp.validateTokenExchange(request, originClient, destinationClient); assertThat(result.decision(), is(Decision.INVALID_SCOPE)); assertThat(result.message().isPresent(), is(true)); - assertThat(result.message().get(), is("scope not allowed by client configuration")); + assertThat(result.message().get(), is("scope not allowed by origin client configuration")); } + @Test + public void clientOriginScopeCheckingWorks() { + IamTokenExchangePolicyEntity p1 = buildPermitExamplePolicy(1L, "Allow all exchanges"); + request.setScope(asList("s3")); + + when(repo.findAll()).thenReturn(asList(p1)); + pdp.reloadPolicies(); + + TokenExchangePdpResult result = + pdp.validateTokenExchange(request, originClient, destinationClient); + + assertThat(result.decision(), is(Decision.INVALID_SCOPE)); + assertThat(result.message().isPresent(), is(true)); + assertThat(result.message().get(), is("scope not allowed by origin client configuration")); + } + @Test public void clientScopeCheckWorks() { IamTokenExchangePolicyEntity p1 = buildPermitExamplePolicy(1L, "Allow all exchanges"); request.setScope(asList("s1","s2")); when(repo.findAll()).thenReturn(asList(p1)); + pdp.reloadPolicies(); TokenExchangePdpResult result = pdp.validateTokenExchange(request, originClient, destinationClient); @@ -229,6 +253,7 @@ public void scopeExchangeDenyPolicyWorks() { p1.getScopePolicies().add(buildScopePolicy(PERMIT, "s2")); when(repo.findAll()).thenReturn(asList(p1)); + pdp.reloadPolicies(); TokenExchangePdpResult result = pdp.validateTokenExchange(request, originClient, destinationClient); @@ -239,6 +264,50 @@ public void scopeExchangeDenyPolicyWorks() { assertThat(result.invalidScope().get(), is("s1")); assertThat(result.message().isPresent(), is (true)); assertThat(result.message().get(), is("scope exchange not allowed by policy")); + } + + @Test + public void scopeExchangeDenyPolicyWithRegexpWorks() { + IamTokenExchangePolicyEntity p1 = buildPermitExamplePolicy(1L, "Allow all exchanges"); + request.setScope(asList("s2", "s1")); + + p1.getScopePolicies().add(buildScopePolicy(DENY, "s1")); + p1.getScopePolicies().add(buildRegexpAllScopePolicy(PERMIT)); + + when(repo.findAll()).thenReturn(asList(p1)); + pdp.reloadPolicies(); + + TokenExchangePdpResult result = + pdp.validateTokenExchange(request, originClient, destinationClient); + + assertThat(result.decision(), is(Decision.INVALID_SCOPE)); + assertThat(result.invalidScope().isPresent(), is(true)); + assertThat(result.invalidScope().get(), is("s1")); + assertThat(result.message().isPresent(), is(true)); + assertThat(result.message().get(), is("scope exchange not allowed by policy")); + } + + @Test + public void scopeExchangeDenyAllScopesPolicyWorks() { + IamTokenExchangePolicyEntity p1 = buildPermitExamplePolicy(1L, "Allow all exchanges"); + request.setScope(asList("s2", "s1")); + + // A permit policy that denies all scopes that does not make much sense in practice, + // but we want to verify it works + p1.getScopePolicies().add(buildRegexpAllScopePolicy(DENY)); + + when(repo.findAll()).thenReturn(asList(p1)); + pdp.reloadPolicies(); + + TokenExchangePdpResult result = + pdp.validateTokenExchange(request, originClient, destinationClient); + + assertThat(result.decision(), is(Decision.INVALID_SCOPE)); + + assertThat(result.invalidScope().isPresent(), is(true)); + assertThat(result.invalidScope().get(), is("s2")); + assertThat(result.message().isPresent(), is(true)); + assertThat(result.message().get(), is("scope exchange not allowed by policy")); } } diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/exchange/TokenExchangePdpTestSupport.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/exchange/TokenExchangePdpTestSupport.java index 54113c8ed..42b9519e9 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/exchange/TokenExchangePdpTestSupport.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/exchange/TokenExchangePdpTestSupport.java @@ -23,6 +23,7 @@ import it.infn.mw.iam.persistence.model.IamClientMatchingPolicy; import it.infn.mw.iam.persistence.model.IamClientMatchingPolicy.ClientMatchingPolicyType; +import it.infn.mw.iam.persistence.model.IamScopePolicy.MatchingPolicy; import it.infn.mw.iam.persistence.model.IamTokenExchangePolicyEntity; import it.infn.mw.iam.persistence.model.IamTokenExchangeScopePolicy; import it.infn.mw.iam.persistence.model.PolicyRule; @@ -91,4 +92,13 @@ public IamTokenExchangeScopePolicy buildScopePolicy(PolicyRule rule, String scop return policy; } + public IamTokenExchangeScopePolicy buildRegexpAllScopePolicy(PolicyRule rule) { + IamTokenExchangeScopePolicy policy = new IamTokenExchangeScopePolicy(); + policy.setRule(rule); + policy.setType(MatchingPolicy.REGEXP); + policy.setMatchParam(".*"); + + return policy; + } + } diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/exchange/TokenExchangeWithPdpIntegrationTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/exchange/TokenExchangeWithPdpIntegrationTests.java new file mode 100644 index 000000000..fc9e5e447 --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/exchange/TokenExchangeWithPdpIntegrationTests.java @@ -0,0 +1,150 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2019 + * + * 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. + */ +package it.infn.mw.iam.test.oauth.exchange; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.log; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.SpringApplicationConfiguration; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.test.context.web.WebAppConfiguration; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.context.WebApplicationContext; + +import it.infn.mw.iam.IamLoginService; +import it.infn.mw.iam.api.exchange_policy.ClientMatchingPolicyDTO; +import it.infn.mw.iam.api.exchange_policy.ExchangePolicyDTO; +import it.infn.mw.iam.api.exchange_policy.TokenExchangePolicyService; +import it.infn.mw.iam.api.exchange_policy.ExchangeScopePolicyDTO; +import it.infn.mw.iam.persistence.model.IamScopePolicy.MatchingPolicy; +import it.infn.mw.iam.persistence.model.PolicyRule; +import it.infn.mw.iam.test.oauth.EndpointsTestUtils; + +@RunWith(SpringJUnit4ClassRunner.class) +@SpringApplicationConfiguration(classes = IamLoginService.class) +@Transactional +@DirtiesContext +@WebAppConfiguration +public class TokenExchangeWithPdpIntegrationTests extends EndpointsTestUtils { + + private static final String TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"; + private static final String TOKEN_TYPE = "urn:ietf:params:oauth:token-type:jwt"; + private static final String TOKEN_ENDPOINT = "/token"; + + private static final String TEST_USER_USERNAME = "test"; + private static final String TEST_USER_PASSWORD = "password"; + + + @Autowired + private WebApplicationContext context; + + @Autowired + private TokenExchangePolicyService service; + + @Before + public void setup() throws Exception { + mvc = + MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).alwaysDo(log()).build(); + } + + + @Test + public void testTokenExchangeBlockedWithNoPolicy() throws Exception { + String clientId = "token-exchange-subject"; + String clientSecret = "secret"; + + String actorClientId = "token-exchange-actor"; + String actorClientSecret = "secret"; + + String accessToken = new AccessTokenGetter().grantType("password") + .clientId(clientId) + .clientSecret(clientSecret) + .username(TEST_USER_USERNAME) + .password(TEST_USER_PASSWORD) + .scope("openid profile") + .getAccessTokenValue(); + + service.deleteAllTokenExchangePolicies(); + + mvc.perform(post(TOKEN_ENDPOINT) + .with(httpBasic(actorClientId, actorClientSecret)) + .param("grant_type", TOKEN_EXCHANGE_GRANT_TYPE) + .param("subject_token", accessToken) + .param("subject_token_type", TOKEN_TYPE) + .param("scope", "openid")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error").value("access_denied")) + .andExpect( + jsonPath("$.error_description").value("No policy found authorizing this exchange")); + + ExchangePolicyDTO policyDto = + ExchangePolicyDTO.permitPolicy("Permit policy with scope policies"); + + ExchangeScopePolicyDTO allowAllScopes = new ExchangeScopePolicyDTO(); + allowAllScopes.setType(MatchingPolicy.REGEXP); + allowAllScopes.setRule(PolicyRule.PERMIT); + allowAllScopes.setMatchParam(".*"); + + ExchangeScopePolicyDTO sp = new ExchangeScopePolicyDTO(); + + sp.setType(MatchingPolicy.EQ); + sp.setMatchParam("offline_access"); + sp.setRule(PolicyRule.DENY); + + policyDto.getScopePolicies().add(sp); + policyDto.getScopePolicies().add(allowAllScopes); + + policyDto.setOriginClient(ClientMatchingPolicyDTO.anyClient()); + policyDto.setDestinationClient(ClientMatchingPolicyDTO.clientById(actorClientId)); + + service.createTokenExchangePolicy(policyDto); + + mvc + .perform(post(TOKEN_ENDPOINT).with(httpBasic(actorClientId, actorClientSecret)) + .param("grant_type", TOKEN_EXCHANGE_GRANT_TYPE) + .param("subject_token", accessToken) + .param("subject_token_type", TOKEN_TYPE) + .param("scope", "openid")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.scope").value("openid")) + .andExpect(jsonPath("$.access_token").exists()) + .andExpect(jsonPath("$.id_token").exists()); + + mvc + .perform(post(TOKEN_ENDPOINT).with(httpBasic(actorClientId, actorClientSecret)) + .param("grant_type", TOKEN_EXCHANGE_GRANT_TYPE) + .param("subject_token", accessToken) + .param("subject_token_type", TOKEN_TYPE) + .param("scope", "openid offline_access")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("invalid_scope")) + .andExpect(jsonPath("$.error_description") + .value("scope exchange not allowed by policy: offline_access")); + + + } + +} diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/WLCGProfileIntegrationTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/WLCGProfileIntegrationTests.java index a10b2c1c6..4ec3a9147 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/WLCGProfileIntegrationTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/WLCGProfileIntegrationTests.java @@ -431,6 +431,28 @@ public void testWlcgProfileServiceIdentityTokenExchange() throws Exception { } + + @Test + public void testWlcgProfileUserIdentityTokenExchangeNoScopeParameter() throws Exception { + String subjectToken = new AccessTokenGetter().grantType(PASSWORD_GRANT_TYPE) + .clientId(SUBJECT_CLIENT_ID) + .clientSecret(SUBJECT_CLIENT_SECRET) + .username(USERNAME) + .password(PASSWORD) + .scope("openid profile") + .audience(ACTOR_CLIENT_ID) + .getAccessTokenValue(); + + mvc + .perform(post("/token").with(httpBasic(ACTOR_CLIENT_ID, ACTOR_CLIENT_SECRET)) + .param("grant_type", TOKEN_EXCHANGE_GRANT_TYPE) + .param("subject_token", subjectToken) + .param("subject_token_type", "urn:ietf:params:oauth:token-type:jwt")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("invalid_request")) + .andExpect(jsonPath("$.error_description", containsString("scope parameter is required"))); + } + @Test public void testWlcgProfileUserIdentityTokenExchange() throws Exception { @@ -443,6 +465,8 @@ public void testWlcgProfileUserIdentityTokenExchange() throws Exception { .audience(ACTOR_CLIENT_ID) .getAccessTokenValue(); + // Exchange the token to client 'token-exchange-actor', reduce a bit the scopes + // but request a refresh token String tokenResponse = mvc .perform(post("/token").with(httpBasic(ACTOR_CLIENT_ID, ACTOR_CLIENT_SECRET)) @@ -479,25 +503,24 @@ public void testWlcgProfileUserIdentityTokenExchange() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.active", equalTo(true))); - tokenResponse = - mvc - .perform(post("/token").with(httpBasic(ACTOR_CLIENT_ID, ACTOR_CLIENT_SECRET)) - .param("grant_type", TOKEN_EXCHANGE_GRANT_TYPE) - .param("subject_token", tokenResponseObject.getValue()) - .param("subject_token_type", "urn:ietf:params:oauth:token-type:jwt") - .param("scope", - "storage.read:/subpath storage.write:/subpath/test openid offline_access") - .param("audience", "se4.example")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.access_token").exists()) - .andExpect(jsonPath("$.refresh_token").exists()) - .andExpect(jsonPath("$.scope", - allOf(containsString("storage.read:/subpath "), containsString("offline_access"), - containsString("storage.write:/subpath/test"), containsString("openid"), - containsString("offline_access")))) - .andReturn() - .getResponse() - .getContentAsString(); + // Check that the token can be further exchange by the same actor client + // without the offline_access scope + tokenResponse = mvc + .perform(post("/token").with(httpBasic(ACTOR_CLIENT_ID, ACTOR_CLIENT_SECRET)) + .param("grant_type", TOKEN_EXCHANGE_GRANT_TYPE) + .param("subject_token", tokenResponseObject.getValue()) + .param("subject_token_type", "urn:ietf:params:oauth:token-type:jwt") + .param("scope", "storage.read:/subpath storage.write:/subpath/test openid") + .param("audience", "se4.example")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.access_token").exists()) + .andExpect(jsonPath("$.refresh_token").doesNotExist()) + .andExpect(jsonPath("$.scope", + allOf(containsString("storage.read:/subpath "), + containsString("storage.write:/subpath/test"), containsString("openid")))) + .andReturn() + .getResponse() + .getContentAsString(); DefaultOAuth2AccessToken tokenResponseObject2 = mapper.readValue(tokenResponse, DefaultOAuth2AccessToken.class); @@ -744,5 +767,4 @@ public void attributesAreIncludedInAccessTokenWhenNotRequested() throws Exceptio assertThat(claims.getJSONObjectClaim("attr").getAsString("test"), is("test")); } - } diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/pdp/ScopePolicyApiIntegrationTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/pdp/ScopePolicyApiIntegrationTests.java index 39eb4ba26..df6b58a37 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/pdp/ScopePolicyApiIntegrationTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/pdp/ScopePolicyApiIntegrationTests.java @@ -28,6 +28,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import java.nio.file.Files; +import java.nio.file.Paths; import java.util.List; import java.util.UUID; @@ -288,6 +290,18 @@ public void invalidScopePolicyScopesLengthLowerBound() throws Exception { .equalTo("Invalid scope policy: scope length must be >= 1 and < 255 characters"))); } + @Test + @WithMockOAuthUser(user = "admin", authorities = {"ROLE_USER", "ROLE_ADMIN"}) + public void invalidScopePolicyRepresentationTest() throws Exception { + final String INVALID_POLICY = new String( + Files.readAllBytes(Paths.get("src/test/resources/api/scope_policy/invalid_policy.json"))); + + mvc.perform(post("/iam/scope_policies").content(INVALID_POLICY).contentType(APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value(Matchers + .equalTo("Invalid scope policy: could not parse the policy JSON representation"))); + } + @Test @WithMockOAuthUser(user = "admin", authorities = {"ROLE_USER", "ROLE_ADMIN"}) public void invalidScopePolicyScopesLengthUpperBound() throws Exception { diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/group/ScimGroupProvisioningListTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/group/ScimGroupProvisioningListTests.java index 569d1e366..fd4041c18 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/group/ScimGroupProvisioningListTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/group/ScimGroupProvisioningListTests.java @@ -63,8 +63,6 @@ public class ScimGroupProvisioningListTests { private final static String GROUP_URI = ScimUtils.getGroupsLocation(); - private final Integer pageSize = 100; - private Integer totalResults = 0; @Before diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/group/patch/ScimGroupPatchUtils.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/group/patch/ScimGroupPatchUtils.java index 4cdddd5db..d15c4ecf1 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/group/patch/ScimGroupPatchUtils.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/group/patch/ScimGroupPatchUtils.java @@ -107,7 +107,7 @@ protected void addMembers(ScimGroup group, List members) throws Except mvc.perform(patch(group.getMeta().getLocation()).contentType(SCIM_CONTENT_TYPE) .content(objectMapper.writeValueAsString(patchAddReq))).andExpect(status().isNoContent()); - ScimGroup g = getGroup(group.getMeta().getLocation()); + getGroup(group.getMeta().getLocation()); mvc.perform(get(group.getMeta().getLocation() + "/members")) .andExpect(status().isOk()) diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/updater/AccountUpdatersTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/updater/AccountUpdatersTests.java index 8b846551d..245ca79bc 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/updater/AccountUpdatersTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/updater/AccountUpdatersTests.java @@ -136,10 +136,6 @@ private IamGroup newGroup(String name) { return groupService.createGroup(group); } - private Adders otherAdders() { - return AccountUpdaters.adders(accountRepo, accountService, encoder, other); - } - private Adders accountAdders() { return AccountUpdaters.adders(accountRepo, accountService, encoder, account); } diff --git a/iam-login-service/src/test/resources/api/scope_policy/invalid_policy.json b/iam-login-service/src/test/resources/api/scope_policy/invalid_policy.json new file mode 100644 index 000000000..0e000d144 --- /dev/null +++ b/iam-login-service/src/test/resources/api/scope_policy/invalid_policy.json @@ -0,0 +1,6 @@ +{ + "description": "Invalid policy (scopes is a string instead of a list of strings)", + "rule": "DENY", + "scopes": "openid", + "matchingPolicy": "EQ" +} diff --git a/iam-persistence/src/main/java/it/infn/mw/iam/persistence/model/IamGroup.java b/iam-persistence/src/main/java/it/infn/mw/iam/persistence/model/IamGroup.java index d48ecb74c..19fecff24 100644 --- a/iam-persistence/src/main/java/it/infn/mw/iam/persistence/model/IamGroup.java +++ b/iam-persistence/src/main/java/it/infn/mw/iam/persistence/model/IamGroup.java @@ -37,6 +37,8 @@ import javax.persistence.Temporal; import javax.persistence.TemporalType; +import com.google.common.base.Preconditions; + @Entity @Table(name = "iam_group") public class IamGroup implements Serializable { @@ -57,14 +59,14 @@ public class IamGroup implements Serializable { private String description; @Temporal(TemporalType.TIMESTAMP) - @Column(name="creationtime", nullable = false) + @Column(name = "creationtime", nullable = false) Date creationTime; @Temporal(TemporalType.TIMESTAMP) - @Column(name="lastupdatetime", nullable = false) + @Column(name = "lastupdatetime", nullable = false) Date lastUpdateTime; - - @Column(name="default_group", nullable = false) + + @Column(name = "default_group", nullable = false) boolean defaultGroup; @ManyToOne @@ -79,21 +81,18 @@ public class IamGroup implements Serializable { @OneToMany(mappedBy = "group", cascade = CascadeType.REMOVE) private Set groupRequests = new HashSet<>(); - + @ElementCollection - @CollectionTable( - indexes= {@Index(columnList="name"), @Index(columnList="name,val")}, - name="iam_group_attrs", - joinColumns=@JoinColumn(name="group_id")) + @CollectionTable(indexes = {@Index(columnList = "name"), @Index(columnList = "name,val")}, + name = "iam_group_attrs", joinColumns = @JoinColumn(name = "group_id")) private Set attributes = new HashSet<>(); @ElementCollection @CollectionTable( - indexes= {@Index(columnList="prefix,name,val"), @Index(columnList="prefix,name")}, - name="iam_group_labels", - joinColumns=@JoinColumn(name="group_id")) + indexes = {@Index(columnList = "prefix,name,val"), @Index(columnList = "prefix,name")}, + name = "iam_group_labels", joinColumns = @JoinColumn(name = "group_id")) private Set labels = new HashSet<>(); - + public IamGroup() { // empty constructor } @@ -178,11 +177,31 @@ public Set getGroupRequests() { public void setGroupRequests(Set groupRequests) { this.groupRequests = groupRequests; } - + public boolean isDefaultGroup() { return defaultGroup; } + public boolean isSubgroupOf(IamGroup otherGroup) { + Preconditions.checkNotNull(otherGroup, "Cannot check subgroup status of a null group"); + + if (this.equals(otherGroup)) { + return false; + } + + IamGroup aParentGroup = this.getParentGroup(); + + while (aParentGroup != null) { + if (aParentGroup.equals(otherGroup)) { + return true; + } else { + aParentGroup = aParentGroup.getParentGroup(); + } + } + + return false; + } + public void setDefaultGroup(boolean defaultGroup) { this.defaultGroup = defaultGroup; } @@ -194,11 +213,11 @@ public Set getAttributes() { public void setAttributes(Set attributes) { this.attributes = attributes; } - + public Set getLabels() { return labels; } - + public void setLabels(Set labels) { this.labels = labels; } @@ -207,7 +226,7 @@ public void touch(Clock c) { setLastUpdateTime(Date.from(c.instant())); } - + @Override public int hashCode() { diff --git a/iam-persistence/src/main/java/it/infn/mw/iam/persistence/repository/IamAccountRepository.java b/iam-persistence/src/main/java/it/infn/mw/iam/persistence/repository/IamAccountRepository.java index e04a02107..f1ea4ca27 100644 --- a/iam-persistence/src/main/java/it/infn/mw/iam/persistence/repository/IamAccountRepository.java +++ b/iam-persistence/src/main/java/it/infn/mw/iam/persistence/repository/IamAccountRepository.java @@ -21,6 +21,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.PagingAndSortingRepository; import org.springframework.data.repository.query.Param; @@ -76,6 +77,26 @@ Optional findByEmailWithDifferentUUID(@Param("emailAddress") String @Query("select a from IamAccount a join a.groups ag where ag.group.uuid = :groupUuid order by a.username ASC") Page findByGroupUuid(@Param("groupUuid") String uuid, Pageable op); + @Query("select a from IamAccount a join a.groups ag join a.userInfo ui where ag.group.uuid = :groupUuid" + + " and lower(ui.email) LIKE lower(concat('%', :filter, '%'))" + + " or lower(a.username) LIKE lower(concat('%', :filter, '%'))" + + " or lower(concat(ui.givenName, ' ', ui.familyName)) LIKE lower(concat('%', :filter, '%'))" + + " order by a.username ASC") + Page findByGroupUuidWithFilter(@Param("groupUuid") String uuid, + @Param("filter") String filter, + Pageable op); + + @Query("select a from IamAccount a where not exists (select m from IamAccountGroupMembership m where m.account = a and m.group.uuid = :groupUuid ) order by a.username ASC") + Page findNotInGroup(@Param("groupUuid") String uuid, Pageable op); + + @Query("select a from IamAccount a join a.userInfo ui where not exists (select m from IamAccountGroupMembership m where m.account = a and m.group.uuid = :groupUuid )" + + " and lower(ui.email) LIKE lower(concat('%', :filter, '%'))" + + " or lower(a.username) LIKE lower(concat('%', :filter, '%'))" + + " or lower(concat(ui.givenName, ' ', ui.familyName)) LIKE lower(concat('%', :filter, '%'))" + + " order by a.username ASC") + Page findNotInGroupWithFilter(@Param("groupUuid") String uuid, + @Param("filter") String filter, Pageable op); + @Query("select a from IamAccount a join a.groups ag where ag.group.name = :groupName order by a.username ASC") Page findByGroupName(@Param("groupName") String name, Pageable op); @@ -120,4 +141,9 @@ Page findByLabelNameAndValue(@Param("name") String name, @Param("val @Query("select a from IamAccount a where a.active = TRUE") Page findActiveAccounts(Pageable op); + + @Modifying + @Query("delete from IamAccountGroupMembership") + void deleteAllAccountGroupMemberships(); + } diff --git a/iam-persistence/src/main/java/it/infn/mw/iam/persistence/repository/IamGroupRepository.java b/iam-persistence/src/main/java/it/infn/mw/iam/persistence/repository/IamGroupRepository.java index 70404b2e0..8bc17b507 100644 --- a/iam-persistence/src/main/java/it/infn/mw/iam/persistence/repository/IamGroupRepository.java +++ b/iam-persistence/src/main/java/it/infn/mw/iam/persistence/repository/IamGroupRepository.java @@ -42,9 +42,9 @@ Optional findByNameWithDifferentId(@Param("name") String name, @Query("select g from IamGroup g where g.parentGroup = :parentGroup order by g.name ASC") Page findSubgroups(@Param("parentGroup") IamGroup parentGroup, Pageable op); - + List findByNameIgnoreCaseContaining(String name); - + List findByUuidNotIn(Set uuids); Page findByNameIgnoreCaseContainingOrUuidIgnoreCaseContaining( @@ -69,9 +69,19 @@ Page findGroupsByMemberAccountUuid(@Param("accountUuid") String accoun @Query("select m.group from IamAccountGroupMembership m where m.account.uuid = :accountUuid and m.group.uuid = :groupUuid") Optional findGroupByMemberAccountUuidAndGroupUuid( - @Param("accountUuid") String accountUuid, - @Param("groupUuid") String groupUuid); + @Param("accountUuid") String accountUuid, @Param("groupUuid") String groupUuid); @Query("select count(m) from IamAccountGroupMembership m where m.group.uuid= :groupUuid") Long countGroupMembersByGroupUuid(@Param("groupUuid") String groupUuid); + + + @Query("select g from IamGroup g where g not in (select m.group from IamAccountGroupMembership m where m.account.uuid = :accountUuid) order by g.name ASC") + Page findUnsubscribedGroupsForAccount(@Param("accountUuid") String accountUuid, + Pageable op); + + + @Query("select g from IamGroup g where lower(g.name) LIKE lower(concat('%', :groupName, '%')) and g not in (select m.group from IamAccountGroupMembership m where m.account.uuid = :accountUuid) order by g.name ASC") + Page findUnsubscribedGroupsForAccountWithNameLike( + @Param("accountUuid") String accountUuid, @Param("groupName") String groupName, Pageable op); + } diff --git a/pom.xml b/pom.xml index 5879fd51c..7bc64781c 100644 --- a/pom.xml +++ b/pom.xml @@ -14,24 +14,24 @@ UTF-8 UTF-8 + 1.8 3.3.2 - -Xms2048m -Xmx2048m + -Xmx2500m 2.5.2 - 1.3.5.cnaf.20200119 + 1.3.5.cnaf.20210803 1.0.1.RELEASE 1.3.8.RELEASE 0.32 - 1.5.3 - 1.3.3 - 1.0.0-beta.3 - 0.19.6 + 1.6.1 + 2.5.0 + 1.0.20 + 0.19.8 1.3.11 4.7.0 - ?? 2.1.1 3.3.7 1.12.0