diff --git a/.gitignore b/.gitignore index b8265dfc..43f397b5 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ provisioning.local.json IgnoreMeTest.java dependency.tree NOTES.txt +private_key_pkcs8.pem diff --git a/client/src/__tests__/utils/Utils.test.js b/client/src/__tests__/utils/Utils.test.js index 2bd413ca..791cfbca 100644 --- a/client/src/__tests__/utils/Utils.test.js +++ b/client/src/__tests__/utils/Utils.test.js @@ -1,8 +1,18 @@ import React from "react"; import { - sanitizeURL + sanitizeURL,distinctValues } from "../../utils/Utils"; test("Test sanitizeURL", () => { expect(sanitizeURL("https://invite.test2.surfconext.nl")).toEqual("https://invite.test2.surfconext.nl"); }); + +test("Test distinctValues", () => { + const res = distinctValues([{id:"1", val: "val1"}, {id:"1", val: "valX"}, {id:"2", val: "val2"}, {id:"3", val: "val3"}], + "id"); + expect(res.length).toEqual(3); + + const resId = distinctValues([{id:1, val: "val1"}, {id:1, val: "valX"}, {id:2, val: "val2"}, {id:3, val: "val3"}], + "id"); + expect(res.length).toEqual(3); +}); \ No newline at end of file diff --git a/client/src/locale/en.js b/client/src/locale/en.js index 4cb70a53..56f5fce5 100644 --- a/client/src/locale/en.js +++ b/client/src/locale/en.js @@ -323,7 +323,7 @@ const en = { userRoleIcon: "User role accepted by {{name}} at {{createdAt}}", invitationIcon: "Invitation for {{email}} sent at {{createdAt}} with expiration date {{expiryDate}}", roleShortName: "The unique short name of the role within a provisioning. It is used to format the urn and therefore not all characters are allowed.", - roleUrn: "The urn of the role. It is based on the sanitized name and the application identifier. It is used as the unique global identifier of this role and therefore not all characters are allowed.", + roleUrn: "The urn of the role. It is based on the sanitized name and the role identifier. It is used as the unique global identifier of this role and therefore not all characters are allowed.", manageService: "The required service from SURFconext with may have an optional provisioning", defaultExpiryDays: "The default number of days the role will expiry when a use accepts a invitation for this role", enforceEmailEqualityTooltip: "When checked the invitee must accept the invitation with the email where the invitation was sent to", diff --git a/client/src/locale/nl.js b/client/src/locale/nl.js index 14971149..a4d2d67e 100644 --- a/client/src/locale/nl.js +++ b/client/src/locale/nl.js @@ -323,7 +323,7 @@ const nl = { userRoleIcon: "Gebruikersrol geaccepteerd door {{name}} op {{createdAt}}", invitationIcon: "Uitnodiging aan {{email}} verstuurd op {{createdAt}} met verloopdatum {{expiryDate}}", roleShortName: "Een unieke korte naam voor de rol binnen een provisioning. Gebruikt in de urn, dus daarom zijn niet alle tekens toegestaan.", - roleUrn: "De urn van deze rol. Deze is gebaseerd op de opgeschoonde naam en de applicatie-identifier. Hij wordt gebruikt als de unieke globale identifier van deze rol en daarom zijn niet alle tekens toegestaan.", + roleUrn: "De urn van deze rol. Deze is gebaseerd op de opgeschoonde naam en de rol-identifier. Hij wordt gebruikt als de unieke globale identifier van deze rol en daarom zijn niet alle tekens toegestaan.", manageService: "De vereiste dienst uit SURFconext die optioneel een provisioning heeft.", defaultExpiryDays: "Het default aantal dagen waarna de rol verloopt, gerekend vanaf het moment dat de gebruiker de uitnodiging voor de rol accepteert.", enforceEmailEqualityTooltip: "Indien ingeschakeld moet de genodigde de uitnodiging accepteren met een account dat hetzelfde e-mailadres voert als waarheen deze uitnodiging gestuurd is", diff --git a/client/src/pages/Role.js b/client/src/pages/Role.js index 7481f171..6a3e1834 100644 --- a/client/src/pages/Role.js +++ b/client/src/pages/Role.js @@ -163,7 +163,7 @@ export const Role = () => { - {`${role.applicationName}`} + {`${role.applicationNames}`} {role.applicationOrganizationName && {` (${role.applicationOrganizationName})`}} diff --git a/client/src/pages/RoleForm.js b/client/src/pages/RoleForm.js index 225fd384..8bc312f7 100644 --- a/client/src/pages/RoleForm.js +++ b/client/src/pages/RoleForm.js @@ -10,7 +10,7 @@ import {UnitHeader} from "../components/UnitHeader"; import {ReactComponent as RoleIcon} from "@surfnet/sds/icons/illustrative-icons/hierarchy.svg"; import InputField from "../components/InputField"; import {constructShortName} from "../validations/regExps"; -import {isEmpty} from "../utils/Utils"; +import {distinctValues, isEmpty} from "../utils/Utils"; import ErrorIndicator from "../components/ErrorIndicator"; import SelectField from "../components/SelectField"; import {providersToOptions, singleProviderToOption} from "../utils/Manage"; @@ -26,7 +26,7 @@ export const RoleForm = () => { const required = ["name", "description", "applications"]; const {user, setFlash, config} = useAppStore(state => state); - const [role, setRole] = useState({name: "", shortName: "", defaultExpiryDays: 0}); + const [role, setRole] = useState({name: "", shortName: "", defaultExpiryDays: 0, identifier: crypto.randomUUID()}); const [providers, setProviders] = useState([]); const [isNewRole, setNewRole] = useState(true); const [loading, setLoading] = useState(true); @@ -57,9 +57,11 @@ export const RoleForm = () => { if (user.superUser) { setProviders(res[newRole ? 0 : 1]); } else if (user.institutionAdmin) { - setProviders(user.applications.concat(user.userRoles.map(userRole => userRole.role.application))); + setProviders(distinctValues(user.applications + .concat(user.userRoles.map(userRole => userRole.role.applicationMaps) + .flat()), "id")); } else { - setProviders(user.userRoles.map(userRole => userRole.role.application)); + setProviders(distinctValues(user.userRoles.map(userRole => userRole.role.applicationMaps).flat(), "id")); } setNewRole(newRole); const name = newRole ? "" : res[0].name; @@ -69,7 +71,7 @@ export const RoleForm = () => { ]; if (newRole) { const providerOption = singleProviderToOption(user.superUser ? res[0][0] : - user.institutionAdmin ? user.applications[0] : user.userRoles[0].role.application); + user.institutionAdmin ? user.applications[0] : user.userRoles[0].role.applicationMaps[0]); setManagementOption([providerOption]); setRole({...role, applications: [providerOption]}) } else { diff --git a/client/src/tabs/Roles.js b/client/src/tabs/Roles.js index 5dff6f84..de8ffbf7 100644 --- a/client/src/tabs/Roles.js +++ b/client/src/tabs/Roles.js @@ -7,7 +7,7 @@ import {Button, ButtonSize, Chip, Loader} from "@surfnet/sds"; import {useNavigate} from "react-router-dom"; import {AUTHORITIES, highestAuthority, isUserAllowed, markAndFilterRoles} from "../utils/UserRole"; import {rolesByApplication, searchRoles} from "../api"; -import {isEmpty, stopEvent} from "../utils/Utils"; +import {distinctValues, isEmpty, stopEvent} from "../utils/Utils"; import debounce from "lodash.debounce"; import {chipTypeForUserRole} from "../utils/Authority"; import {ReactComponent as VoidImage} from "../icons/undraw_void_-3-ggu.svg"; @@ -19,6 +19,7 @@ export const Roles = () => { const user = useAppStore(state => state.user); const {roleSearchRequired} = useAppStore(state => state.config); const navigate = useNavigate(); + const [loading, setLoading] = useState(true); const [searching, setSearching] = useState(false); const [roles, setRoles] = useState([]); @@ -34,7 +35,12 @@ export const Roles = () => { } else { rolesByApplication() .then(res => { - const newRoles = markAndFilterRoles(user, res, I18n.locale, I18n.t("roles.multiple"), I18n.t("forms.and")); + const newRoles = markAndFilterRoles( + user, + distinctValues(res, "id"), + I18n.locale, + I18n.t("roles.multiple"), + I18n.t("forms.and")); setRoles(newRoles); initFilterValues(newRoles); setLoading(false); @@ -55,11 +61,12 @@ export const Roles = () => { value: allValue }]; const reducedRoles = userRoles.reduce((acc, role) => { - const option = acc.find(opt => opt.manageId === role.manageId); + const manageIdentifiers = role.applicationMaps.map(m => m.id); + const option = acc.find(opt => manageIdentifiers.includes(opt.manageId)); if (option) { ++option.nbr; } else { - acc.push({manageId: role.manageId, nbr: 1, name: role.applicationName}) + role.applicationMaps.forEach(app => acc.push({manageId: app.id, nbr: 1, name: app[`name:${I18n.locale}`] || app[0]["name:en"]})) } return acc; }, []); @@ -199,7 +206,7 @@ export const Roles = () => { ) } const filteredRoles = filterValue.value === allValue ? roles : - roles.filter(role => role.manageId=== filterValue.value); + roles.filter(role => role.applicationMaps.map(m => m.id).includes(filterValue.value)); return (
diff --git a/client/src/utils/Manage.js b/client/src/utils/Manage.js index 9f434df2..83dce7dc 100644 --- a/client/src/utils/Manage.js +++ b/client/src/utils/Manage.js @@ -30,10 +30,14 @@ export const deriveApplicationAttributes = (role, locale, multiple, separator) = if (!isEmpty(applications)) { if (applications.length === 1) { role.applicationName = applications[0][`name:${locale}`] || applications[0]["name:en"]; + role.applicationNames = role.applicationName; role.applicationOrganizationName = applications[0][`OrganizationName:${locale}`] || applications[0]["OrganizationName:en"]; role.logo = applications[0].logo; } else { role.applicationName = multiple; + const appNames = new Set(applications + .map(app => app[`name:${locale}`] || app["name:en"])); + role.applicationNames = splitListSemantically([...appNames], separator); const orgNames = new Set(applications .map(app => app[`OrganizationName:${locale}`] || app["OrganizationName:en"])); role.applicationOrganizationName = splitListSemantically([...orgNames], separator); diff --git a/client/src/utils/UserRole.js b/client/src/utils/UserRole.js index 3a49f242..6f4580c6 100644 --- a/client/src/utils/UserRole.js +++ b/client/src/utils/UserRole.js @@ -97,7 +97,7 @@ export const allowedToRenewUserRole = (user, userRole) => { } } -export const urnFromRole = (groupUrnPrefix, role) => `${groupUrnPrefix}:${role.manageId}:${role.shortName}`; +export const urnFromRole = (groupUrnPrefix, role) => `${groupUrnPrefix}:${role.identifier}:${role.shortName}`; export const markAndFilterRoles = (user, allRoles, locale, multiple, separator) => { allRoles.forEach(role => { @@ -121,6 +121,8 @@ export const markAndFilterRoles = (user, allRoles, locale, multiple, separator) userRole.enforceEmailEquality = role.enforceEmailEquality; userRole.applicationName = role.applicationName; userRole.applicationOrganizationName = role.applicationOrganizationName; + userRole.applicationMaps = role.applicationMaps; + userRole.applications = role.applications; userRole.logo = role.logo; userRole.userRoleCount = role.userRoleCount; }) diff --git a/client/src/utils/Utils.js b/client/src/utils/Utils.js index cfcfbad0..042a43c7 100644 --- a/client/src/utils/Utils.js +++ b/client/src/utils/Utils.js @@ -39,3 +39,14 @@ export const sanitizeURL = url => { const protocol = new URL(url).protocol; return ["https:", "http:"].includes(protocol) ? url : "about:blank"; } + +export const distinctValues = (arr, attribute) => { + const distinctList = {}; + return arr.filter(obj => { + if (distinctList[obj[attribute]]) { + return false; + } + distinctList[obj[attribute]] = true; + return true; + }) +} diff --git a/server/pom.xml b/server/pom.xml index 0b68ae4a..ceb24b64 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -107,17 +107,23 @@ azure-identity 1.9.1 - - org.projectlombok - lombok - 1.18.30 - commons-codec commons-codec 1.16.0 + + org.openconext + java-crypto + 1.0.3 + + + org.projectlombok + lombok + 1.18.30 + provided + org.springframework.boot spring-boot-starter-test diff --git a/server/src/main/java/access/api/InvitationController.java b/server/src/main/java/access/api/InvitationController.java index aaef5f0c..e05bcd0e 100644 --- a/server/src/main/java/access/api/InvitationController.java +++ b/server/src/main/java/access/api/InvitationController.java @@ -228,9 +228,12 @@ public ResponseEntity> accept(@Validated @RequestBody Accept .filter(userRole -> userRole.getRole().getId().equals(role.getId())).findFirst(); if (optionalUserRole.isPresent()) { UserRole userRole = optionalUserRole.get(); - if (!userRole.getAuthority().hasEqualOrHigherRights(invitation.getIntendedAuthority())) { + if (invitation.getIntendedAuthority().hasHigherRights(userRole.getAuthority())) { userRole.setAuthority(invitation.getIntendedAuthority()); userRole.setEndDate(invitation.getRoleExpiryDate()); + if (invitation.getIntendedAuthority().equals(Authority.GUEST)) { + userRole.setGuestRoleIncluded(true); + } } } else { UserRole userRole = new UserRole( diff --git a/server/src/main/java/access/api/RoleController.java b/server/src/main/java/access/api/RoleController.java index 8d665419..bdc685de 100644 --- a/server/src/main/java/access/api/RoleController.java +++ b/server/src/main/java/access/api/RoleController.java @@ -26,6 +26,7 @@ import org.springframework.web.bind.annotation.*; import java.util.*; +import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; import static access.SwaggerOpenIdConfig.API_TOKENS_SCHEME_NAME; @@ -157,12 +158,18 @@ private ResponseEntity saveOrUpdate(Role role, User user) { UserPermissions.assertManagerRole(role.getApplicationMaps(), user); boolean isNew = role.getId() == null; + AtomicReference roleAtomicReference = new AtomicReference<>(); if (!isNew) { - role.setShortName(roleRepository.findById(role.getId()).orElseThrow(NotFoundException::new).getShortName()); + Role previousRole = roleRepository.findById(role.getId()).orElseThrow(NotFoundException::new); + //We don't allow shortName changes after creation + role.setShortName(previousRole.getShortName()); + roleAtomicReference.set(previousRole); } Role saved = roleRepository.save(role); if (isNew) { provisioningService.newGroupRequest(saved); + } else { + provisioningService.updateGroupRequest(roleAtomicReference.get(), saved); } AccessLogger.role(LOG, isNew ? Event.Created : Event.Updated, user, role); diff --git a/server/src/main/java/access/api/UserController.java b/server/src/main/java/access/api/UserController.java index 0cfb4dbf..4935351e 100644 --- a/server/src/main/java/access/api/UserController.java +++ b/server/src/main/java/access/api/UserController.java @@ -13,6 +13,7 @@ import access.security.UserPermissions; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import crypto.KeyStore; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import jakarta.servlet.http.HttpServletRequest; @@ -65,6 +66,7 @@ public UserController(Config config, Manage manage, ObjectMapper objectMapper, RemoteProvisionedUserRepository remoteProvisionedUserRepository, + KeyStore keyStore, @Value("${config.eduid-idp-schac-home-organization}") String eduidIdpSchacHomeOrganization, @Value("${config.server-url}") String serverBaseURL, @Value("${voot.group_urn_domain}") String groupUrnPrefix) { @@ -74,7 +76,7 @@ public UserController(Config config, this.objectMapper = objectMapper; this.manage = manage; this.remoteProvisionedUserRepository = remoteProvisionedUserRepository; - this.graphClient = new GraphClient(serverBaseURL, eduidIdpSchacHomeOrganization); + this.graphClient = new GraphClient(serverBaseURL, eduidIdpSchacHomeOrganization, keyStore); } @GetMapping("config") diff --git a/server/src/main/java/access/manage/LocalManage.java b/server/src/main/java/access/manage/LocalManage.java index b737533f..7738ca34 100644 --- a/server/src/main/java/access/manage/LocalManage.java +++ b/server/src/main/java/access/manage/LocalManage.java @@ -5,11 +5,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import lombok.SneakyThrows; import org.springframework.core.io.ClassPathResource; +import org.springframework.util.CollectionUtils; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; +import java.util.*; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -70,7 +68,10 @@ public Map providerById(EntityType entityType, String id) { } @Override - public List> provisioning(List ids) { + public List> provisioning(Collection ids) { + if (CollectionUtils.isEmpty(ids)) { + return Collections.emptyList(); + } return providers(EntityType.PROVISIONING).stream() .filter(map -> { List> applications = (List>) map.get("applications"); diff --git a/server/src/main/java/access/manage/Manage.java b/server/src/main/java/access/manage/Manage.java index 8f84578b..1400685c 100644 --- a/server/src/main/java/access/manage/Manage.java +++ b/server/src/main/java/access/manage/Manage.java @@ -16,7 +16,7 @@ public interface Manage { List> providersByIdIn(EntityType entityType, List identifiers); - List> provisioning(List ids); + List> provisioning(Collection ids); List> providersByInstitutionalGUID(String organisationGUID); @@ -71,6 +71,7 @@ default Map transformProvider(Map provider) { application.put("applications", data.get("applications")); application.put("entityid", data.get("entityid")); application.put("logo", metaDataFields.get("logo:0:url")); + application.put("url", metaDataFields.get("coin:application_url")); application.put("OrganizationName:en", metaDataFields.get("OrganizationName:en")); application.put("OrganizationName:nl", metaDataFields.get("OrganizationName:nl")); application.put("name:en", metaDataFields.get("name:en")); diff --git a/server/src/main/java/access/manage/ManageConf.java b/server/src/main/java/access/manage/ManageConf.java index ca12e114..e110a9a6 100644 --- a/server/src/main/java/access/manage/ManageConf.java +++ b/server/src/main/java/access/manage/ManageConf.java @@ -1,12 +1,16 @@ package access.manage; + import com.fasterxml.jackson.databind.ObjectMapper; +import crypto.KeyStore; +import crypto.RSAKeyStore; +import io.micrometer.core.instrument.util.IOUtils; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.Resource; import java.io.IOException; - @Configuration public class ManageConf { @@ -20,5 +24,11 @@ public Manage manage(@Value("${manage.url}") String url, return enabled ? new RemoteManage(url, user, password, objectMapper) : new LocalManage(objectMapper, local); } + @Bean + public KeyStore keyStore(@Value("${crypto.development-mode}") Boolean developmentMode, + @Value("${crypto.private-key-location}") Resource privateKey) throws IOException { + return developmentMode ? new RSAKeyStore() : new RSAKeyStore(IOUtils.toString(privateKey.getInputStream())); + } + } diff --git a/server/src/main/java/access/manage/RemoteManage.java b/server/src/main/java/access/manage/RemoteManage.java index 29549e46..9baef45d 100644 --- a/server/src/main/java/access/manage/RemoteManage.java +++ b/server/src/main/java/access/manage/RemoteManage.java @@ -4,14 +4,13 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.core.io.ClassPathResource; import org.springframework.http.client.support.BasicAuthenticationInterceptor; +import org.springframework.util.CollectionUtils; import org.springframework.web.client.RestTemplate; import java.io.IOException; import java.net.URLEncoder; import java.nio.charset.Charset; -import java.util.List; -import java.util.Map; -import java.util.Optional; +import java.util.*; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -38,6 +37,9 @@ public List> providers(EntityType... entityTypes) { @Override public List> providersByIdIn(EntityType entityType, List identifiers) { + if (CollectionUtils.isEmpty(identifiers)) { + return Collections.emptyList(); + } String param = identifiers.stream().map(id -> String.format("\"%s\"", id)).collect(Collectors.joining(",")); String query = URLEncoder.encode(String.format("{ \"id\": { $in: [%s]}}", param), Charset.defaultCharset()); String queryUrl = String.format("%s/manage/api/internal/rawSearch/%s?query=%s", url, entityType.collectionName(), query); @@ -60,7 +62,10 @@ public Map providerById(EntityType entityType, String id) { @Override - public List> provisioning(List ids) { + public List> provisioning(Collection ids) { + if (CollectionUtils.isEmpty(ids)) { + return Collections.emptyList(); + } String queryUrl = String.format("%s/manage/api/internal/provisioning", url); return transformProvider(restTemplate.postForObject(queryUrl, ids, List.class)); } diff --git a/server/src/main/java/access/model/UserRole.java b/server/src/main/java/access/model/UserRole.java index 6130ecab..3464a8ef 100644 --- a/server/src/main/java/access/model/UserRole.java +++ b/server/src/main/java/access/model/UserRole.java @@ -36,6 +36,9 @@ public class UserRole implements Serializable { @Column(name = "expiry_notifications") private int expiryNotifications; + @Column(name = "guest_role_included") + private boolean guestRoleIncluded; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) diff --git a/server/src/main/java/access/provision/ProvisioningService.java b/server/src/main/java/access/provision/ProvisioningService.java index e20bfd99..8fb70791 100644 --- a/server/src/main/java/access/provision/ProvisioningService.java +++ b/server/src/main/java/access/provision/ProvisioningService.java @@ -16,6 +16,7 @@ public interface ProvisioningService { void updateGroupRequest(UserRole userRole, OperationType operationType); + void updateGroupRequest(Role previousRole, Role newRole); void deleteGroupRequest(Role role); } diff --git a/server/src/main/java/access/provision/ProvisioningServiceDefault.java b/server/src/main/java/access/provision/ProvisioningServiceDefault.java index 1ab4437f..8d9a5caa 100644 --- a/server/src/main/java/access/provision/ProvisioningServiceDefault.java +++ b/server/src/main/java/access/provision/ProvisioningServiceDefault.java @@ -12,6 +12,7 @@ import access.repository.RemoteProvisionedUserRepository; import access.repository.UserRoleRepository; import com.fasterxml.jackson.databind.ObjectMapper; +import crypto.KeyStore; import lombok.SneakyThrows; import okhttp3.OkHttpClient; import org.apache.commons.logging.Log; @@ -55,6 +56,7 @@ public class ProvisioningServiceDefault implements ProvisioningService { private final String groupUrnPrefix; private final GraphClient graphClient; private final EvaClient evaClient; + private final KeyStore keyStore; @Autowired public ProvisioningServiceDefault(UserRoleRepository userRoleRepository, @@ -62,6 +64,7 @@ public ProvisioningServiceDefault(UserRoleRepository userRoleRepository, RemoteProvisionedGroupRepository remoteProvisionedGroupRepository, Manage manage, ObjectMapper objectMapper, + KeyStore keyStore, @Value("${voot.group_urn_domain}") String groupUrnPrefix, @Value("${config.eduid-idp-schac-home-organization}") String eduidIdpSchacHomeOrganization, @Value("${config.server-url}") String serverBaseURL) { @@ -70,9 +73,10 @@ public ProvisioningServiceDefault(UserRoleRepository userRoleRepository, this.remoteProvisionedGroupRepository = remoteProvisionedGroupRepository; this.manage = manage; this.objectMapper = objectMapper; + this.keyStore = keyStore; this.groupUrnPrefix = groupUrnPrefix; - this.graphClient = new GraphClient(serverBaseURL, eduidIdpSchacHomeOrganization); - this.evaClient = new EvaClient(); + this.graphClient = new GraphClient(serverBaseURL, eduidIdpSchacHomeOrganization, keyStore); + this.evaClient = new EvaClient(keyStore); // Otherwise, we can't use method PATCH OkHttpClient.Builder builder = new OkHttpClient.Builder(); builder.connectTimeout(1, TimeUnit.MINUTES); @@ -144,7 +148,7 @@ public void newGroupRequest(Role role) { @Override public void updateGroupRequest(UserRole userRole, OperationType operationType) { - if (!userRole.getAuthority().equals(Authority.GUEST)) { + if (!userRole.getAuthority().equals(Authority.GUEST) && !userRole.isGuestRoleIncluded()) { //We only provision GUEST users return; } @@ -156,9 +160,13 @@ public void updateGroupRequest(UserRole userRole, OperationType operationType) { Optional provisionedGroupOptional = this.remoteProvisionedGroupRepository .findByManageProvisioningIdAndRole(provisioning.getId(), role); provisionedGroupOptional.ifPresentOrElse(provisionedGroup -> { + List userRoles = new ArrayList<>(); if (provisioning.isScimUpdateRolePutMethod()) { - //We need all userRoles for a PUT - List userRoles = userRoleRepository.findByRole(userRole.getRole()); + //We need all userRoles for a PUT and we only provision guests + userRoles = userRoleRepository.findByRole(userRole.getRole()) + .stream() + .filter(userRoleDB -> userRoleDB.getAuthority().equals(Authority.GUEST) || userRoleDB.isGuestRoleIncluded()) + .toList(); boolean userRolePresent = userRoles.stream().anyMatch(dbUserRole -> dbUserRole.getId().equals(userRole.getId())); if (operationType.equals(OperationType.Add) && !userRolePresent) { userRoles.add(userRole); @@ -167,47 +175,10 @@ public void updateGroupRequest(UserRole userRole, OperationType operationType) { .filter(dbUserRole -> !dbUserRole.getId().equals(userRole.getId())) .toList(); } - List userScimIdentifiers = userRoles.stream() - .map(ur -> { - Optional provisionedUser = this.remoteProvisionedUserRepository.findByManageProvisioningIdAndUser(provisioning.getId(), ur.getUser()); - //Should not happen, but try to provision anyway - if (provisionedUser.isEmpty()) { - this.newUserRequest(ur.getUser()); - provisionedUser = this.remoteProvisionedUserRepository.findByManageProvisioningIdAndUser(provisioning.getId(), ur.getUser()); - } - return provisionedUser; - }) - .filter(Optional::isPresent) - .map(Optional::get) - .map(RemoteProvisionedUser::getRemoteIdentifier) - .toList(); - //We only provision GUEST users - if (!userScimIdentifiers.isEmpty()) { - String groupRequest = constructGroupRequest( - role, - provisionedGroup.getRemoteIdentifier(), - userScimIdentifiers); - this.updateRequest(provisioning, groupRequest, GROUP_API, provisionedGroup.getRemoteIdentifier(), HttpMethod.PUT); - - } } else { - Optional provisionedUserOptional = this.remoteProvisionedUserRepository - .findByManageProvisioningIdAndUser(provisioning.getId(), userRole.getUser()) - .or(() -> { - this.newUserRequest(userRole.getUser()); - return this.remoteProvisionedUserRepository - .findByManageProvisioningIdAndUser(provisioning.getId(), userRole.getUser()); - }); - //Should not be empty, but avoid error on this - provisionedUserOptional.ifPresent(provisionedUser -> { - String groupRequest = patchGroupRequest( - role, - provisionedUser.getRemoteIdentifier(), - provisionedGroup.getRemoteIdentifier(), - operationType); - this.updateRequest(provisioning, groupRequest, GROUP_API, provisionedGroup.getRemoteIdentifier(), HttpMethod.PATCH); - }); + userRoles.add(userRole); } + sendGroupPutRequest(provisioning, provisionedGroup, userRoles, role, operationType); }, () -> { this.newGroupRequest(role); this.updateGroupRequest(userRole, operationType); @@ -216,9 +187,78 @@ public void updateGroupRequest(UserRole userRole, OperationType operationType) { }); } + private void sendGroupPutRequest(Provisioning provisioning, + RemoteProvisionedGroup provisionedGroup, + List userRoles, + Role role, + OperationType operationType) { + List userScimIdentifiers = userRoles.stream() + .map(userRole -> this.remoteProvisionedUserRepository + .findByManageProvisioningIdAndUser(provisioning.getId(), userRole.getUser()) + .or(() -> { + this.newUserRequest(userRole.getUser()); + return this.remoteProvisionedUserRepository + .findByManageProvisioningIdAndUser(provisioning.getId(), userRole.getUser()); + })) + .filter(Optional::isPresent) + .map(Optional::get) + .map(RemoteProvisionedUser::getRemoteIdentifier) + .toList(); + if (!userScimIdentifiers.isEmpty()) { + if (provisioning.isScimUpdateRolePutMethod()) { + String groupRequest = constructGroupRequest( + role, + provisionedGroup.getRemoteIdentifier(), + userScimIdentifiers); + this.updateRequest(provisioning, groupRequest, GROUP_API, provisionedGroup.getRemoteIdentifier(), HttpMethod.PUT); + } else { + String groupRequest = patchGroupRequest( + role, + userScimIdentifiers, + provisionedGroup.getRemoteIdentifier(), + operationType); + this.updateRequest(provisioning, groupRequest, GROUP_API, provisionedGroup.getRemoteIdentifier(), HttpMethod.PATCH); + } + } + + } + + @Override + public void updateGroupRequest(Role previousRole, Role newRole) { + List previousManageIdentifiers = this.getManageIdentifiers(previousRole); + List newManageIdentifiers = this.getManageIdentifiers(newRole); + if (previousManageIdentifiers.equals(newManageIdentifiers)) { + return; + } + List addedManageIdentifiers = newManageIdentifiers.stream().filter(id -> !previousManageIdentifiers.contains(id)).toList(); + List deletedManageIdentifiers = previousManageIdentifiers.stream().filter(id -> !newManageIdentifiers.contains(id)).toList(); + + manage.provisioning(addedManageIdentifiers).stream().map(Provisioning::new) + .forEach(provisioning -> { + Optional provisionedGroupOptional = this.remoteProvisionedGroupRepository + .findByManageProvisioningIdAndRole(provisioning.getId(), newRole); + if (provisionedGroupOptional.isEmpty()) { + //Ensure the group is provisioned just in time + this.newGroupRequest(newRole); + provisionedGroupOptional = this.remoteProvisionedGroupRepository + .findByManageProvisioningIdAndRole(provisioning.getId(), newRole); + } + provisionedGroupOptional.ifPresent(provisionedGroup -> { + List userRoles = userRoleRepository.findByRole(newRole); + this.sendGroupPutRequest(provisioning, provisionedGroup, userRoles, newRole, OperationType.Add); + }); + }); + List provisionings = manage.provisioning(deletedManageIdentifiers).stream().map(Provisioning::new).toList(); + deleteGroupRequest(newRole, provisionings); + } + @Override public void deleteGroupRequest(Role role) { List provisionings = getProvisionings(role); + deleteGroupRequest(role, provisionings); + } + + private void deleteGroupRequest(Role role, List provisionings) { //Delete the group to all provisionings in Manage where the group is known provisionings.forEach(provisioning -> this.remoteProvisionedGroupRepository @@ -242,12 +282,12 @@ private String constructGroupRequest(Role role, String remoteGroupScimIdentifier } private String patchGroupRequest(Role role, - String remoteScimProvisionedUser, + List remoteScimProvisionedUsers, String remoteScimProvisionedGroup, OperationType operationType) { String externalId = GroupURN.urnFromRole(groupUrnPrefix, role); GroupPatchRequest request = new GroupPatchRequest(externalId, remoteScimProvisionedGroup, - new Operation(operationType, List.of(remoteScimProvisionedUser))); + new Operation(operationType, remoteScimProvisionedUsers)); return prettyJson(request); } @@ -299,10 +339,14 @@ private List getProvisionings(User user) { } private List getProvisionings(Role role) { - List manageIdentifiers = role.getApplications().stream().map(Application::getManageId).toList(); + List manageIdentifiers = getManageIdentifiers(role); return manage.provisioning(manageIdentifiers).stream().map(Provisioning::new).toList(); } + private List getManageIdentifiers(Role role) { + return role.getApplications().stream().map(Application::getManageId).distinct().sorted().toList(); + } + @SneakyThrows private void deleteRequest(Provisioning provisioning, String request, @@ -317,7 +361,7 @@ private void deleteRequest(Provisioning provisioning, } else if (hasScimHook(provisioning)) { URI uri = this.provisioningUri(provisioning, apiType, Optional.ofNullable(remoteIdentifier)); HttpHeaders headers = new HttpHeaders(); - headers.setBasicAuth(provisioning.getScimUser(), provisioning.getScimPassword()); + headers.setBasicAuth(provisioning.getScimUser(), this.decryptScimPassword(provisioning)); requestEntity = new RequestEntity<>(request, headers, HttpMethod.DELETE, uri); } else if (hasGraphHook(provisioning) && isUser) { this.graphClient.deleteUser((User) provisionable, provisioning, remoteIdentifier); @@ -376,11 +420,16 @@ private String prettyJson(Object obj) { return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(obj); } + private String decryptScimPassword(Provisioning provisioning) { + String scimPassword = provisioning.getScimPassword(); + return keyStore.isEncryptedSecret(scimPassword) ? keyStore.decodeAndDecrypt(scimPassword) : scimPassword; + } + private HttpHeaders httpHeaders(Provisioning provisioning) { HttpHeaders headers = new HttpHeaders(); switch (provisioning.getProvisioningType()) { case scim -> { - headers.setBasicAuth(provisioning.getScimUser(), provisioning.getScimPassword()); + headers.setBasicAuth(provisioning.getScimUser(), decryptScimPassword(provisioning)); headers.setContentType(MediaType.APPLICATION_JSON); headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); } diff --git a/server/src/main/java/access/provision/eva/EvaClient.java b/server/src/main/java/access/provision/eva/EvaClient.java index 61ad5aee..25d29e34 100644 --- a/server/src/main/java/access/provision/eva/EvaClient.java +++ b/server/src/main/java/access/provision/eva/EvaClient.java @@ -2,6 +2,8 @@ import access.model.User; import access.provision.Provisioning; +import crypto.KeyStore; +import lombok.SneakyThrows; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; @@ -12,11 +14,21 @@ public class EvaClient { + + private final KeyStore keyStore; + + public EvaClient(KeyStore keyStore) { + this.keyStore= keyStore; + } + + @SneakyThrows @SuppressWarnings("unchecked") public RequestEntity newUserRequest(Provisioning provisioning, User user) { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); - headers.add("X-Api-Key", provisioning.getEvaToken()); + String encryptedEvaToken = provisioning.getEvaToken(); + String evaToken = keyStore.isEncryptedSecret(encryptedEvaToken) ? keyStore.decodeAndDecrypt(encryptedEvaToken) : encryptedEvaToken; + headers.add("X-Api-Key", evaToken); MultiValueMap map = new GuestAccount(user, provisioning).getRequest(); String url = provisioning.getEvaUrl() + "/api/v1/guest/create"; diff --git a/server/src/main/java/access/provision/graph/GraphClient.java b/server/src/main/java/access/provision/graph/GraphClient.java index 54ab9d53..f3001660 100644 --- a/server/src/main/java/access/provision/graph/GraphClient.java +++ b/server/src/main/java/access/provision/graph/GraphClient.java @@ -11,6 +11,7 @@ import com.microsoft.graph.requests.GraphServiceClient; import com.microsoft.graph.requests.InvitationCollectionRequest; import com.microsoft.graph.requests.UserRequest; +import crypto.KeyStore; import okhttp3.Request; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -26,10 +27,12 @@ public class GraphClient { private final String serverUrl; private final String eduidIdpSchacHomeOrganization; + private final KeyStore keyStore; - public GraphClient(String serverUrl, String eduidIdpSchacHomeOrganization) { + public GraphClient(String serverUrl, String eduidIdpSchacHomeOrganization, KeyStore keyStore) { this.serverUrl = serverUrl; this.eduidIdpSchacHomeOrganization = eduidIdpSchacHomeOrganization; + this.keyStore= keyStore; } @SuppressWarnings("unchecked") @@ -110,10 +113,12 @@ public void deleteUser(User user, Provisioning provisioning, String remoteUserId } private GraphServiceClient getRequestGraphServiceClient(Provisioning provisioning) { + String encryptedGraphSecret = provisioning.getGraphSecret(); + String graphSecret = keyStore.isEncryptedSecret(encryptedGraphSecret) ? keyStore.decodeAndDecrypt(encryptedGraphSecret) : encryptedGraphSecret; ClientSecretCredential credential = new ClientSecretCredentialBuilder() .clientId(provisioning.getGraphClientId()) .tenantId(provisioning.getGraphTenant()) - .clientSecret(provisioning.getGraphSecret()).build(); + .clientSecret(graphSecret).build(); TokenCredentialAuthProvider authProvider = new TokenCredentialAuthProvider(credential); GraphServiceClient graphClient = GraphServiceClient.builder().authenticationProvider(authProvider).buildClient(); diff --git a/server/src/main/java/access/provision/scim/GroupRequest.java b/server/src/main/java/access/provision/scim/GroupRequest.java index 00b33233..81ed7d61 100644 --- a/server/src/main/java/access/provision/scim/GroupRequest.java +++ b/server/src/main/java/access/provision/scim/GroupRequest.java @@ -17,8 +17,8 @@ public class GroupRequest implements Serializable { public GroupRequest(String externalId, String remoteScimIdentifier, String displayName, List members) { this.externalId = externalId; + this.id = remoteScimIdentifier; this.displayName = displayName; this.members = members; - this.id = remoteScimIdentifier; } } diff --git a/server/src/main/java/access/repository/UserRepository.java b/server/src/main/java/access/repository/UserRepository.java index 7ecc456d..7fb7d93a 100644 --- a/server/src/main/java/access/repository/UserRepository.java +++ b/server/src/main/java/access/repository/UserRepository.java @@ -14,7 +14,7 @@ public interface UserRepository extends JpaRepository { Optional findBySubIgnoreCase(String sub); - List findByOrganizationGUIDAndAndInstitutionAdmin(String organizationGUID, boolean institutionAdmin); + List findByOrganizationGUIDAndInstitutionAdmin(String organizationGUID, boolean institutionAdmin); List findByUserRoles_role_id(Long roleId); diff --git a/server/src/main/java/access/repository/UserRoleRepository.java b/server/src/main/java/access/repository/UserRoleRepository.java index 918ff356..76e7d2e2 100644 --- a/server/src/main/java/access/repository/UserRoleRepository.java +++ b/server/src/main/java/access/repository/UserRoleRepository.java @@ -1,5 +1,6 @@ package access.repository; +import access.model.Authority; import access.model.Role; import access.model.UserRole; import org.springframework.data.jpa.repository.EntityGraph; diff --git a/server/src/main/java/access/security/UserHandlerMethodArgumentResolver.java b/server/src/main/java/access/security/UserHandlerMethodArgumentResolver.java index 4711a6cc..71a928dc 100755 --- a/server/src/main/java/access/security/UserHandlerMethodArgumentResolver.java +++ b/server/src/main/java/access/security/UserHandlerMethodArgumentResolver.java @@ -71,7 +71,7 @@ public User resolveArgument(MethodParameter methodParameter, APIToken apiToken = apiTokenRepository.findByHashedValue(hashedToken) .orElseThrow(UserRestrictionException::new); String organizationGuid = apiToken.getOrganizationGUID(); - List institutionAdmins = userRepository.findByOrganizationGUIDAndAndInstitutionAdmin(organizationGuid, true); + List institutionAdmins = userRepository.findByOrganizationGUIDAndInstitutionAdmin(organizationGuid, true); if (institutionAdmins.isEmpty()) { //we don't want to return null as this is not part of the happy-path throw new UserRestrictionException(); diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index 06f36d84..27b98fe6 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -63,6 +63,12 @@ spring: host: localhost port: 3025 +crypto: + development-mode: True + private-key-location: classpath:nope +# development-mode: False +# private-key-location: classpath:/private_key_pkcs8.pem + cron: node-cron-job-responsible: true user-cleaner-expression: "0 0/30 * * * *" diff --git a/server/src/main/resources/db/mysql/migration/V17_0__guest_user_role.sql b/server/src/main/resources/db/mysql/migration/V17_0__guest_user_role.sql new file mode 100644 index 00000000..aee6860e --- /dev/null +++ b/server/src/main/resources/db/mysql/migration/V17_0__guest_user_role.sql @@ -0,0 +1,3 @@ +ALTER TABLE `user_roles` + add `guest_role_included` bool DEFAULT 0; + diff --git a/server/src/main/resources/manage/query_templates.json b/server/src/main/resources/manage/query_templates.json index 44576212..006461cf 100644 --- a/server/src/main/resources/manage/query_templates.json +++ b/server/src/main/resources/manage/query_templates.json @@ -1,6 +1,7 @@ { "base_query": { "REQUESTED_ATTRIBUTES": [ - "metaDataFields.logo:0:url" + "metaDataFields.logo:0:url", + "metaDataFields.coin:application_url" ] }} diff --git a/server/src/test/java/access/api/InvitationControllerTest.java b/server/src/test/java/access/api/InvitationControllerTest.java index 1834d66a..98d2d890 100644 --- a/server/src/test/java/access/api/InvitationControllerTest.java +++ b/server/src/test/java/access/api/InvitationControllerTest.java @@ -139,10 +139,10 @@ void accept() throws Exception { .statusCode(201); User user = userRepository.findBySubIgnoreCase("user@new.com").get(); assertEquals(1, user.getUserRoles().size()); - //one roles provisioned to 1 remote SCIM + //one role provisioned to 1 remote SCIM assertEquals(1, remoteProvisionedGroupRepository.count()); - //two users provisioned to 1 remote SCIM - the inviter and one existing user with the userRole - assertEquals(2, remoteProvisionedUserRepository.count()); + //one user provisioned to 1 remote SCIM - the invitee. The one existing user is not provisioned because only Guests are provisioned + assertEquals(1, remoteProvisionedUserRepository.count()); } @Test diff --git a/server/src/test/java/access/provision/graph/GraphClientTest.java b/server/src/test/java/access/provision/graph/GraphClientTest.java index 3f1158dd..c9669103 100644 --- a/server/src/test/java/access/provision/graph/GraphClientTest.java +++ b/server/src/test/java/access/provision/graph/GraphClientTest.java @@ -6,9 +6,11 @@ import access.manage.LocalManage; import access.model.User; import access.provision.Provisioning; -import com.fasterxml.jackson.databind.ObjectMapper; +import crypto.KeyStore; +import crypto.RSAKeyStore; import org.junit.jupiter.api.Test; +import java.security.NoSuchAlgorithmException; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -16,7 +18,7 @@ //We test the non-happy paths here and the happy-paths through the controllers class GraphClientTest { - final GraphClient graphClient = new GraphClient("http://localhost:8080", "test.eduid.nl"); + final GraphClient graphClient = new GraphClient("http://localhost:8080", "test.eduid.nl", new RSAKeyStore()); final LocalManage localManage = new LocalManage( ObjectMapperHolder.objectMapper, false); @Test diff --git a/welcome/src/utils/Manage.js b/welcome/src/utils/Manage.js index ca3b78f4..183757d9 100644 --- a/welcome/src/utils/Manage.js +++ b/welcome/src/utils/Manage.js @@ -6,7 +6,8 @@ export const organisationName = apps => { return ` (${apps[0]["OrganizationName:en"]})`; } else { const set = new Set(apps.map(app => app["OrganizationName:en"]).sort()); - return splitListSemantically([...set], I18n.t("forms.and")); + const orgNames = splitListSemantically([...set], I18n.t("forms.and")); + return ` (${orgNames})`; } }