/******************************************************************************* * Cloud Foundry * Copyright (c) [2009-2016] Pivotal Software, Inc. All Rights Reserved. * * This product is licensed to you under the Apache License, Version 2.0 (the "License"). * You may not use this product except in compliance with the License. * * This product includes a number of subcomponents with * separate copyright notices and license terms. Your use of these * subcomponents is subject to the terms and conditions of the * subcomponent's license, as noted in the LICENSE file. *******************************************************************************/ package org.cloudfoundry.identity.uaa.scim.endpoints; import com.jayway.jsonpath.JsonPathException; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.cloudfoundry.identity.uaa.resources.AttributeNameMapper; import org.cloudfoundry.identity.uaa.resources.SearchResults; import org.cloudfoundry.identity.uaa.resources.SearchResultsFactory; import org.cloudfoundry.identity.uaa.resources.SimpleAttributeNameMapper; import org.cloudfoundry.identity.uaa.scim.ScimCore; import org.cloudfoundry.identity.uaa.scim.ScimGroup; import org.cloudfoundry.identity.uaa.scim.ScimGroupExternalMember; import org.cloudfoundry.identity.uaa.scim.ScimGroupMember; import org.cloudfoundry.identity.uaa.scim.ScimGroupMembershipManager; import org.cloudfoundry.identity.uaa.scim.ScimGroupProvisioning; import org.cloudfoundry.identity.uaa.scim.exception.InvalidScimResourceException; import org.cloudfoundry.identity.uaa.scim.exception.MemberAlreadyExistsException; import org.cloudfoundry.identity.uaa.scim.exception.ScimException; import org.cloudfoundry.identity.uaa.scim.exception.ScimResourceNotFoundException; import org.cloudfoundry.identity.uaa.scim.jdbc.JdbcScimGroupExternalMembershipManager; import org.cloudfoundry.identity.uaa.security.DefaultSecurityContextAccessor; import org.cloudfoundry.identity.uaa.security.SecurityContextAccessor; import org.cloudfoundry.identity.uaa.util.UaaPagingUtils; import org.cloudfoundry.identity.uaa.web.ConvertingExceptionView; import org.cloudfoundry.identity.uaa.web.ExceptionReport; import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; import org.springframework.dao.IncorrectResultSizeDataAccessException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.stereotype.Controller; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.client.RestTemplate; import org.springframework.web.servlet.View; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; import static org.cloudfoundry.identity.uaa.constants.OriginKeys.LDAP; import static org.cloudfoundry.identity.uaa.zone.ZoneManagementScopes.ZONE_MANAGING_SCOPE_REGEX; import static org.springframework.util.StringUtils.hasText; @Controller public class ScimGroupEndpoints { public static final String E_TAG = "ETag"; private final ScimGroupProvisioning dao; private ScimGroupMembershipManager membershipManager; private JdbcScimGroupExternalMembershipManager externalMembershipManager; private Map<Class<? extends Exception>, HttpStatus> statuses = new HashMap<>(); private HttpMessageConverter<?>[] messageConverters = new RestTemplate().getMessageConverters().toArray( new HttpMessageConverter<?>[0]); private final Log logger = LogFactory.getLog(getClass()); private SecurityContextAccessor securityContextAccessor = new DefaultSecurityContextAccessor(); public void setSecurityContextAccessor(SecurityContextAccessor securityContextAccessor) { this.securityContextAccessor = securityContextAccessor; } public void setStatuses(Map<Class<? extends Exception>, HttpStatus> statuses) { this.statuses = statuses; } public void setMessageConverters(HttpMessageConverter<?>[] messageConverters) { this.messageConverters = messageConverters; } public JdbcScimGroupExternalMembershipManager getExternalMembershipManager() { return externalMembershipManager; } public void setExternalMembershipManager(JdbcScimGroupExternalMembershipManager externalMembershipManager) { this.externalMembershipManager = externalMembershipManager; } public ScimGroupEndpoints(ScimGroupProvisioning scimGroupProvisioning, ScimGroupMembershipManager membershipManager) { this.dao = scimGroupProvisioning; this.membershipManager = membershipManager; } private boolean isMember(ScimGroup group, String userId, ScimGroupMember.Role role) { if (null == userId) { return true; } for (ScimGroupMember member : group.getMembers()) { if (member.getMemberId().equals(userId) && member.getRoles().contains(role)) { return true; } } return false; } private List<ScimGroup> filterForCurrentUser(List<ScimGroup> input, int startIndex, int count) { List<ScimGroup> response = new ArrayList<ScimGroup>(); int expectedResponseSize = Math.min(count, input.size()); boolean needMore = response.size() < expectedResponseSize; while (needMore && startIndex <= input.size()) { for (ScimGroup group : UaaPagingUtils.subList(input, startIndex, count)) { group.setMembers(membershipManager.getMembers(group.getId(), null, false)); response.add(group); needMore = response.size() < expectedResponseSize; if (!needMore) { break; } } startIndex += count; } return response; } @RequestMapping(value = { "/Groups" }, method = RequestMethod.GET) @ResponseBody public SearchResults<?> listGroups( @RequestParam(value = "attributes", required = false) String attributesCommaSeparated, @RequestParam(required = false, defaultValue = "id pr") String filter, @RequestParam(required = false, defaultValue = "created") String sortBy, @RequestParam(required = false, defaultValue = "ascending") String sortOrder, @RequestParam(required = false, defaultValue = "1") int startIndex, @RequestParam(required = false, defaultValue = "100") int count) { List<ScimGroup> result; try { result = dao.query(filter, sortBy, "ascending".equalsIgnoreCase(sortOrder)); } catch (IllegalArgumentException e) { throw new ScimException("Invalid filter expression: [" + filter + "]", HttpStatus.BAD_REQUEST); } List<ScimGroup> input = filterForCurrentUser(result, startIndex, count); if (!StringUtils.hasLength(attributesCommaSeparated)) { return new SearchResults<>(Arrays.asList(ScimCore.SCHEMAS), input, startIndex, count, result.size()); } AttributeNameMapper mapper = new SimpleAttributeNameMapper(Collections.emptyMap()); String[] attributes = attributesCommaSeparated.split(","); try { return SearchResultsFactory.buildSearchResultFrom(input, startIndex, count, result.size(), attributes, mapper,Arrays.asList(ScimCore.SCHEMAS)); } catch (JsonPathException e) { throw new ScimException("Invalid attributes: [" + attributesCommaSeparated + "]", HttpStatus.BAD_REQUEST); } } @RequestMapping(value = { "/Groups/External/list" }, method = RequestMethod.GET) @ResponseBody @Deprecated public SearchResults<?> listExternalGroups( @RequestParam(required = false, defaultValue = "1") int startIndex, @RequestParam(required = false, defaultValue = "100") int count, @RequestParam(required = false, defaultValue = "") String filter) { return getExternalGroups(startIndex, count, filter); } @RequestMapping(value = { "/Groups/External" }, method = RequestMethod.GET) @ResponseBody public SearchResults<?> getExternalGroups( @RequestParam(required = false, defaultValue = "1") int startIndex, @RequestParam(required = false, defaultValue = "100") int count, @RequestParam(required = false, defaultValue = "") String filter) { List<ScimGroupExternalMember> result; try { result = externalMembershipManager.query(filter); } catch (IllegalArgumentException e) { throw new ScimException("Invalid filter expression: [" + filter + "]", e, HttpStatus.BAD_REQUEST); } return SearchResultsFactory.cropAndBuildSearchResultFrom( result, startIndex, count, result.size(), new String[]{"groupId", "displayName", "externalGroup", "origin"}, Arrays.asList(ScimCore.SCHEMAS)); } @RequestMapping(value = { "/Groups/External" }, method = RequestMethod.POST) @ResponseBody @ResponseStatus(HttpStatus.CREATED) public ScimGroupExternalMember mapExternalGroup(@RequestBody ScimGroupExternalMember sgm) { try { String displayName = sgm.getDisplayName(); String groupId = hasText(sgm.getGroupId()) ? sgm.getGroupId() : getGroupId(displayName); String externalGroup = hasText(sgm.getExternalGroup()) ? sgm.getExternalGroup().trim() : sgm.getExternalGroup(); String origin = hasText(sgm.getOrigin()) ? sgm.getOrigin() : LDAP; return externalMembershipManager.mapExternalGroup(groupId, externalGroup, origin); } catch (IllegalArgumentException e) { throw new ScimException(e.getMessage(), HttpStatus.BAD_REQUEST); } catch (ScimResourceNotFoundException e) { throw new ScimException(e.getMessage(), HttpStatus.NOT_FOUND); } catch (MemberAlreadyExistsException e) { throw new ScimException(e.getMessage(), HttpStatus.CONFLICT); } } @RequestMapping(value = { "/Groups/External/groupId/{groupId}/externalGroup/{externalGroup}" }, method = RequestMethod.DELETE) @ResponseBody @ResponseStatus(HttpStatus.OK) @Deprecated public ScimGroupExternalMember deprecated2UnmapExternalGroup(@PathVariable String groupId, @PathVariable String externalGroup) { return unmapExternalGroup(groupId, externalGroup, null); } @RequestMapping(value = { "/Groups/External/groupId/{groupId}/externalGroup/{externalGroup}/origin/{origin}" }, method = RequestMethod.DELETE) @ResponseBody @ResponseStatus(HttpStatus.OK) public ScimGroupExternalMember unmapExternalGroup(@PathVariable String groupId, @PathVariable String externalGroup, @PathVariable String origin) { try { if (!hasText(origin)) { origin = LDAP; } return externalMembershipManager.unmapExternalGroup(groupId, externalGroup.trim(), origin); } catch (IllegalArgumentException e) { throw new ScimException(e.getMessage(), HttpStatus.BAD_REQUEST); } catch (ScimResourceNotFoundException e) { throw new ScimException(e.getMessage(), HttpStatus.NOT_FOUND); } catch (MemberAlreadyExistsException e) { throw new ScimException(e.getMessage(), HttpStatus.CONFLICT); } } @RequestMapping(value = { "/Groups/External/id/{groupId}/{externalGroup}" }, method = RequestMethod.DELETE) @ResponseBody @ResponseStatus(HttpStatus.OK) @Deprecated public ScimGroupExternalMember deprecatedUnmapExternalGroup(@PathVariable String groupId, @PathVariable String externalGroup) { return unmapExternalGroup(groupId, externalGroup, LDAP); } @RequestMapping(value = { "/Groups/External/displayName/{displayName}/externalGroup/{externalGroup}" }, method = RequestMethod.DELETE) @ResponseBody @ResponseStatus(HttpStatus.OK) @Deprecated public ScimGroupExternalMember unmapExternalGroupUsingName(@PathVariable String displayName, @PathVariable String externalGroup) { return unmapExternalGroupUsingName(displayName, externalGroup, LDAP); } @RequestMapping(value = { "/Groups/External/displayName/{displayName}/externalGroup/{externalGroup}/origin/{origin}" }, method = RequestMethod.DELETE) @ResponseBody @ResponseStatus(HttpStatus.OK) public ScimGroupExternalMember unmapExternalGroupUsingName(@PathVariable String displayName, @PathVariable String externalGroup, @PathVariable String origin) { try { if (!hasText(origin)) { origin = LDAP; } return externalMembershipManager.unmapExternalGroup(getGroupId(displayName), externalGroup.trim(),origin); } catch (IllegalArgumentException e) { throw new ScimException(e.getMessage(), HttpStatus.BAD_REQUEST); } catch (ScimResourceNotFoundException e) { throw new ScimException(e.getMessage(), HttpStatus.NOT_FOUND); } catch (MemberAlreadyExistsException e) { throw new ScimException(e.getMessage(), HttpStatus.CONFLICT); } } @RequestMapping(value = { "/Groups/External/{displayName}/{externalGroup}" }, method = RequestMethod.DELETE) @ResponseBody @ResponseStatus(HttpStatus.OK) @Deprecated public ScimGroupExternalMember deprecatedUnmapExternalGroupUsingName(@PathVariable String displayName, @PathVariable String externalGroup) { return unmapExternalGroupUsingName(displayName, externalGroup); } private String getGroupId(String displayName) { if (displayName==null || displayName.trim().length()==0) { throw new ScimException("Group not found, not name provided", HttpStatus.NOT_FOUND); } List<ScimGroup> result = dao.query("displayName eq \""+displayName+"\""); if (result==null || result.size()==0) { throw new ScimException("Group not found:"+displayName, HttpStatus.NOT_FOUND); } return result.get(0).getId(); } @RequestMapping(value = { "/Groups/{groupId}" }, method = RequestMethod.GET) @ResponseBody public ScimGroup getGroup(@PathVariable String groupId, HttpServletResponse httpServletResponse) { logger.debug("retrieving group with id: " + groupId); ScimGroup group = dao.retrieve(groupId); group.setMembers(membershipManager.getMembers(groupId, null, false)); addETagHeader(httpServletResponse, group); return group; } @RequestMapping(value = { "/Groups" }, method = RequestMethod.POST) @ResponseStatus(HttpStatus.CREATED) @ResponseBody public ScimGroup createGroup(@RequestBody ScimGroup group, HttpServletResponse httpServletResponse) { group.setZoneId(IdentityZoneHolder.get().getId()); ScimGroup created = dao.create(group); if (group.getMembers() != null) { for (ScimGroupMember member : group.getMembers()) { try { membershipManager.addMember(created.getId(), member); } catch (ScimException ex) { logger.warn("Attempt to add invalid member: " + member.getMemberId() + " to group: " + created.getId(), ex); dao.delete(created.getId(), created.getVersion()); throw new InvalidScimResourceException("Invalid group member: " + member.getMemberId()); } } } created.setMembers(membershipManager.getMembers(created.getId(), null, false)); addETagHeader(httpServletResponse, created); return created; } @RequestMapping(value = { "/Groups/{groupId}" }, method = RequestMethod.PUT) @ResponseBody public ScimGroup updateGroup(@RequestBody ScimGroup group, @PathVariable String groupId, @RequestHeader(value = "If-Match", required = false) String etag, HttpServletResponse httpServletResponse) { if (etag == null) { throw new ScimException("Missing If-Match for PUT", HttpStatus.BAD_REQUEST); } logger.debug("updating group: " + groupId); int version = getVersion(groupId, etag); group.setVersion(version); ScimGroup existing = getGroup(groupId, httpServletResponse); try { group.setZoneId(IdentityZoneHolder.get().getId()); ScimGroup updated = dao.update(groupId, group); if (group.getMembers() != null && group.getMembers().size() > 0) { membershipManager.updateOrAddMembers(updated.getId(), group.getMembers()); } else { membershipManager.removeMembersByGroupId(updated.getId()); } updated.setMembers(membershipManager.getMembers(updated.getId(), null, false)); addETagHeader(httpServletResponse, updated); return updated; } catch (IncorrectResultSizeDataAccessException ex) { logger.error("Error updating group, restoring to previous state"); // restore to correct state before reporting error existing.setVersion(getVersion(groupId, "*")); dao.update(groupId, existing); throw new ScimException(ex.getMessage(), ex, HttpStatus.CONFLICT); } catch (ScimResourceNotFoundException ex) { logger.error("Error updating group, restoring to previous state: " + existing); // restore to correct state before reporting error existing.setVersion(getVersion(groupId, "*")); dao.update(groupId, existing); throw new ScimException(ex.getMessage(), ex, HttpStatus.BAD_REQUEST); } } @RequestMapping(value = { "/Groups/{groupId}" }, method = RequestMethod.PATCH) @ResponseBody public ScimGroup patchGroup(@RequestBody ScimGroup patch, @PathVariable String groupId, @RequestHeader(value = "If-Match", required = false) String etag, HttpServletResponse httpServletResponse) { if (etag == null) { throw new ScimException("Missing If-Match for PATCH", HttpStatus.BAD_REQUEST); } logger.debug("patching group: " + groupId); int version = getVersion(groupId, etag); patch.setVersion(version); ScimGroup current = getGroup(groupId, httpServletResponse); current.patch(patch); return updateGroup(current, groupId, etag, httpServletResponse); } @RequestMapping(value = { "/Groups/{groupId}" }, method = RequestMethod.DELETE) @ResponseBody public ScimGroup deleteGroup(@PathVariable String groupId, @RequestHeader(value = "If-Match", required = false, defaultValue = "*") String etag, HttpServletResponse httpServletResponse) { ScimGroup group = getGroup(groupId, httpServletResponse); logger.debug("deleting group: " + group); try { membershipManager.removeMembersByGroupId(groupId); membershipManager.removeMembersByMemberId(groupId); dao.delete(groupId, getVersion(groupId, etag)); } catch (IncorrectResultSizeDataAccessException ex) { logger.debug("error deleting group", ex); throw new ScimException("error deleting group: " + groupId, ex, HttpStatus.CONFLICT); } return group; } @RequestMapping(value = { "/Groups/zones" }, method = RequestMethod.POST) @ResponseStatus(HttpStatus.CREATED) @ResponseBody @Deprecated public ScimGroup addZoneManagers(@RequestBody ScimGroup group, HttpServletResponse httpServletResponse) { if (!group.getDisplayName().matches(ZONE_MANAGING_SCOPE_REGEX)) { throw new ScimException("Invalid group name.", HttpStatus.BAD_REQUEST); } if (group.getMembers()==null || group.getMembers().size()==0) { throw new ScimException("Invalid group members, you have to add at least one member.", HttpStatus.BAD_REQUEST); } try { ScimGroup existing = getGroup(getGroupId(group.getDisplayName()),httpServletResponse); List<ScimGroupMember> newMembers = new LinkedList<>(existing.getMembers()); //we have an existing group - add new memberships for (ScimGroupMember member : group.getMembers()) { if (!isMember(existing, member.getMemberId(), ScimGroupMember.Role.MEMBER)) { newMembers.add(member); } } existing.setMembers(newMembers); return updateGroup(existing, existing.getId(), String.valueOf(existing.getVersion()), httpServletResponse); } catch (ScimException ex) { if (ex.getStatus().equals(HttpStatus.NOT_FOUND)) { return createGroup(group, httpServletResponse); } else { throw ex; } } } @RequestMapping(value = { "/Groups/zones/{userId}/{zoneId}" }, method = RequestMethod.DELETE) @ResponseStatus(HttpStatus.OK) @ResponseBody @Deprecated public ScimGroup deleteZoneAdmin(@PathVariable String userId, @PathVariable String zoneId, HttpServletResponse httpServletResponse) { return deleteZoneScope(userId, zoneId, "admin", httpServletResponse); } @RequestMapping(value = { "/Groups/zones/{userId}/{zoneId}/{scope}" }, method = RequestMethod.DELETE) @ResponseStatus(HttpStatus.OK) @ResponseBody @Deprecated public ScimGroup deleteZoneScope(@PathVariable String userId, @PathVariable String zoneId, @PathVariable String scope, HttpServletResponse httpServletResponse) { String groupName = "zones."+zoneId+"."+scope; if (!groupName.matches(ZONE_MANAGING_SCOPE_REGEX)) { throw new ScimException("Invalid group name.", HttpStatus.BAD_REQUEST); } String groupId = getGroupId(groupName); ScimGroup group = getGroup(groupId, httpServletResponse); if (!hasText(userId) || !hasText(zoneId)) { throw new ScimException("User ID and Zone ID are required.", HttpStatus.BAD_REQUEST); } if (!isMember(group, userId, ScimGroupMember.Role.MEMBER)) { throw new ScimException("User is not a zone admin.", HttpStatus.NOT_FOUND); } List<ScimGroupMember> newZoneAdmins = new LinkedList<>(); for (ScimGroupMember member : group.getMembers()) { if (!member.getMemberId().equals(userId)) { newZoneAdmins.add(member); } } group.setMembers(newZoneAdmins); return updateGroup(group, group.getId(), String.valueOf(group.getVersion()), httpServletResponse); } @RequestMapping("/Groups/{groupId}/members/{memberId}") public ResponseEntity<ScimGroupMember> getGroupMembership(@PathVariable String groupId, @PathVariable String memberId) { ScimGroupMember membership = membershipManager.getMemberById(groupId, memberId); return new ResponseEntity<>(membership, HttpStatus.OK); } @RequestMapping("/Groups/{groupId}/members") public ResponseEntity<List<ScimGroupMember>> listGroupMemberships(@PathVariable String groupId, @RequestParam(required = false, defaultValue = "false") boolean returnEntities, @RequestParam(required = false, defaultValue = "") String filter) { dao.retrieve(groupId); List<ScimGroupMember> members = membershipManager.getMembers(groupId, filter, returnEntities); return new ResponseEntity<>(members, HttpStatus.OK); } @RequestMapping(value = "/Groups/{groupId}/members", method = RequestMethod.PUT) @ResponseBody @Deprecated public ScimGroupMember editMemberInGroup(@PathVariable String groupId, @RequestBody ScimGroupMember member) { return membershipManager.updateMember(groupId, member); } @RequestMapping(value = "/Groups/{groupId}/members", method = RequestMethod.POST) @ResponseStatus(HttpStatus.CREATED) @ResponseBody public ScimGroupMember addMemberToGroup(@PathVariable String groupId, @RequestBody ScimGroupMember member) { return membershipManager.addMember(groupId, member); } @RequestMapping(value = "/Groups/{groupId}/members/{memberId}", method = RequestMethod.DELETE) @ResponseBody @ResponseStatus(HttpStatus.OK) public ScimGroupMember deleteGroupMembership(@PathVariable String groupId, @PathVariable String memberId) { ScimGroupMember membership = membershipManager.removeMemberById(groupId, memberId); return membership; } @ExceptionHandler public View handleException(Exception t, HttpServletRequest request) throws ScimException { ScimException e = new ScimException("Unexpected error", t, HttpStatus.INTERNAL_SERVER_ERROR); if (t instanceof ScimException) { e = (ScimException) t; } else { Class<?> clazz = t.getClass(); for (Class<?> key : statuses.keySet()) { if (key.isAssignableFrom(clazz)) { e = new ScimException(t.getMessage(), t, statuses.get(key)); break; } } } // User can supply trace=true or just trace (unspecified) to get stack // traces boolean trace = request.getParameter("trace") != null && !request.getParameter("trace").equals("false"); return new ConvertingExceptionView(new ResponseEntity<ExceptionReport>(new ExceptionReport(e, trace), e.getStatus()), messageConverters); } private int getVersion(String groupId, String etag) { String value = etag.trim(); while (value.startsWith("\"")) { value = value.substring(1); } while (value.endsWith("\"")) { value = value.substring(0, value.length() - 1); } if (value.equals("*")) { return dao.retrieve(groupId).getVersion(); } try { return Integer.valueOf(value); } catch (NumberFormatException e) { throw new ScimException("Invalid version match header (should be a version number): " + etag, HttpStatus.BAD_REQUEST); } } private void addETagHeader(HttpServletResponse httpServletResponse, ScimGroup scimGroup) { httpServletResponse.setHeader(E_TAG, "\"" + scimGroup.getVersion() + "\""); } }