/* * Copyright © 2017 Google Inc. * * 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 com.google.enterprise.cloudsearch.sdk.identity; import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkNotNull; import static com.google.common.base.Preconditions.checkState; import com.google.api.services.cloudidentity.v1.model.EntityKey; import com.google.api.services.cloudidentity.v1.model.Group; import com.google.api.services.cloudidentity.v1.model.Membership; import com.google.api.services.cloudidentity.v1.model.Operation; import com.google.api.services.cloudidentity.v1.model.Status; import com.google.common.base.Function; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Iterables; import com.google.common.collect.Sets; import com.google.common.util.concurrent.AsyncFunction; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.Executor; import java.util.function.Supplier; import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Collectors; import javax.annotation.Nullable; /** Represents a third-party group to be synced with Cloud identity Groups API. */ public class IdentityGroup extends IdentityPrincipal<IdentityGroup> { private static final Logger logger = Logger.getLogger(IdentityGroup.class.getName()); /** Group label to be added for each synced group as required by Cloud Identity Groups API. */ public static final ImmutableMap<String, String> GROUP_LABELS = ImmutableMap.of("system/groups/external", ""); private final ConcurrentMap<EntityKey, Membership> members; private final EntityKey groupKey; private final Optional<String> groupResourceName; /** Creates an instance of {@link IdentityGroup}. */ public IdentityGroup(Builder buider) { super(checkNotNullOrEmpty(buider.groupIdentity, "group identity can not be null")); Set<Membership> memberships = checkNotNull(buider.members.get(), "members can not be null"); this.members = memberships .stream() .collect(Collectors.toConcurrentMap(k -> k.getPreferredMemberKey(), v -> v)); this.groupKey = checkNotNull(buider.groupKey, "groupKey can not be null"); this.groupResourceName = checkNotNull(buider.groupResourceName, "group resource name can not be null"); } /** * Gets {@link Membership}s under identity group. * * @return memberships under identity group. */ public ImmutableSet<Membership> getMembers() { return ImmutableSet.copyOf(members.values()); } /** * Gets group key for identity group. * * @return group key for identity group. */ public EntityKey getGroupKey() { return groupKey; } /** * Get kind for {@link IdentityPrincipal}. This is always {@link IdentityPrincipal.Kind#GROUP} for * {@link IdentityGroup}. */ @Override public Kind getKind() { return Kind.GROUP; } /** Removes {@link IdentityGroup} from Cloud Identity Groups API using {@code service}. */ @Override public ListenableFuture<Boolean> unmap(IdentityService service) throws IOException { ListenableFuture<Operation> deleteGroup = service.deleteGroup(groupResourceName.get()); return Futures.transform( deleteGroup, new Function<Operation, Boolean>() { @Override @Nullable public Boolean apply(@Nullable Operation input) { try { validateOperation(input); return true; } catch (IOException e) { logger.log(Level.WARNING, String.format("Failed to delete Group %s", groupKey), e); return false; } } }, getExecutor()); } /** * Syncs {@link IdentityGroup} with Cloud Identity Groups API using {@code service} including * group memberships. */ @Override public ListenableFuture<IdentityGroup> sync( IdentityGroup previouslySynced, IdentityService service) throws IOException { IdentityGroup identityGroupToReturn; if (previouslySynced != null) { checkArgument( Objects.equals(previouslySynced.groupKey, groupKey), "mismatch between current group %s and previous group %s", groupKey, previouslySynced.groupKey); checkArgument( previouslySynced.groupResourceName.isPresent(), "missing group resource name for previously synced group"); identityGroupToReturn = previouslySynced; } else { IdentityGroup.Builder syncedGroup = new IdentityGroup.Builder() .setGroupIdentity(identity) .setGroupKey(groupKey) .setMembers(new HashSet<>()); // Syncing group first time. Create Group and extract group resource name // for memberships calls. logger.log(Level.FINE, "Creating group {0}", groupKey); ListenableFuture<Operation> createGroup = service.createGroup(toGroup()); try { Operation result = createGroup.get(); syncedGroup.setGroupResourceName(extractResourceName(result)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IOException("Interrupted while creating Group", e); } catch (ExecutionException e) { throw new IOException("Error creating group", e.getCause()); } identityGroupToReturn = syncedGroup.build(); } Map<EntityKey, Membership> previousMembers = previouslySynced == null ? Collections.emptyMap() : previouslySynced.members; Set<EntityKey> newlyAdded = Sets.difference(members.keySet(), previousMembers.keySet()); Set<EntityKey> removed = Sets.difference(previousMembers.keySet(), members.keySet()); List<ListenableFuture<Membership>> membershipInsertions = new ArrayList<>(); // Insert new memberships for (EntityKey memberKey : newlyAdded) { ListenableFuture<Membership> addMember = identityGroupToReturn.addMember(members.get(memberKey), service); membershipInsertions.add( wrapAsCatchingFuture( addMember, getExecutor(), null, String.format("Error inserting member %s under group %s", memberKey, groupKey))); } // Remove previously synced memberships which are no longer available. List<ListenableFuture<Boolean>> membershipDeletions = new ArrayList<>(); for (EntityKey memberKey : removed) { ListenableFuture<Boolean> removeMember = identityGroupToReturn.removeMember(memberKey, service); membershipDeletions.add( wrapAsCatchingFuture( removeMember, getExecutor(), false, String.format("Error removing member %s from group %s", memberKey, groupKey))); } ListenableFuture<List<Object>> membershipResults = Futures.allAsList(Iterables.concat(membershipInsertions, membershipDeletions)); return Futures.transform( membershipResults, new Function<List<Object>, IdentityGroup>() { @Override @Nullable public IdentityGroup apply(@Nullable List<Object> input) { return identityGroupToReturn; } }, getExecutor()); } @Override public int hashCode() { return Objects.hash(identity, groupKey, members, groupResourceName); } @Override public boolean equals(Object obj) { if (this == obj) { return true; } if (!(obj instanceof IdentityGroup)) { return false; } IdentityGroup other = (IdentityGroup) obj; return Objects.equals(identity, other.identity) && Objects.equals(groupKey, other.groupKey) && Objects.equals(groupResourceName, other.groupResourceName) && Objects.equals(members, other.members); } private Executor getExecutor() { return MoreExecutors.directExecutor(); } ListenableFuture<Membership> addMember(Membership m, IdentityService identityService) throws IOException { Membership existing = members.get(m.getPreferredMemberKey()); if (existing != null) { return Futures.immediateFuture(existing); } String groupId = groupResourceName.get(); ListenableFuture<Operation> created = identityService.createMembership(groupId, m); return Futures.transformAsync( created, new AsyncFunction<Operation, Membership>() { @Override @Nullable public ListenableFuture<Membership> apply(@Nullable Operation input) { try { Membership memberCreated = m.setName(extractResourceName(input)); addMember(memberCreated); logger.log(Level.FINE, "Successfully created membership {0}", memberCreated); return Futures.immediateFuture(memberCreated); } catch (IOException e) { logger.log( Level.WARNING, String.format("Failed to create membership %s under group %s", m, groupId), e); return Futures.immediateFailedFuture(e); } } }, getExecutor()); } ListenableFuture<Boolean> removeMember(EntityKey memberKey, IdentityService identityService) throws IOException { Membership existing = members.get(memberKey); if (existing == null) { return Futures.immediateFuture(true); } String groupId = groupResourceName.get(); String memberId = existing.getName(); ListenableFuture<Operation> created = identityService.deleteMembership(memberId); return Futures.transform( created, new Function<Operation, Boolean>() { @Override @Nullable public Boolean apply(@Nullable Operation input) { try { validateOperation(input); removeMember(memberKey); return true; } catch (IOException e) { logger.log( Level.WARNING, String.format("Failed to delete membership %s under group %s", existing, groupId), e); return false; } } }, getExecutor()); } private synchronized void addMember(Membership m) { members.put(m.getPreferredMemberKey(), m); } private synchronized void removeMember(EntityKey memberKey) { members.remove(memberKey); } /** Builder for {@link IdentityGroup} */ public static class Builder { private String groupIdentity; private EntityKey groupKey; private Supplier<Set<Membership>> members; private Optional<String> groupResourceName = Optional.empty(); /** * Sets external group identity. Mapped to display name of {@link Group}. * * @param groupIdentity external group identity. */ public Builder setGroupIdentity(String groupIdentity) { this.groupIdentity = groupIdentity; return this; } /** * Sets group key. Mapped to {@link Group#getGroupKey} * * @param groupKey group key */ public Builder setGroupKey(EntityKey groupKey) { this.groupKey = groupKey; return this; } /** * Sets {@link Membership}s to be synced under identity group. * * @param members {@link Membership}s to be synced */ public Builder setMembers(Set<Membership> members) { this.members = () -> members; return this; } /** * Sets {@link Membership}s to be synced under identity group. * * @param membershipSupplier supplier for {@link Membership}s to be synced */ public Builder setMembers(Supplier<Set<Membership>> membershipSupplier) { this.members = membershipSupplier; return this; } /** * Sets resource identifier assigned by Cloud Identity Groups API. Extracted from {@link * Group#getName}. * * @param groupResourceName resource identifier assigned by Cloud Identity Groups API */ public Builder setGroupResourceName(String groupResourceName) { this.groupResourceName = Optional.ofNullable(groupResourceName); return this; } /** Builds an instance of {@link IdentityGroup}. */ public IdentityGroup build() { return new IdentityGroup(this); } } private Group toGroup() { return new Group() .setGroupKey(groupKey) .setLabels(GROUP_LABELS) .setParent(groupKey.getNamespace()) .setDisplayName(identity); } private static String extractResourceName(Operation op) throws IOException { validateOperation(op); Map<String, Object> response = op.getResponse(); if (response == null) { throw new IOException(String.format("Operation failed with empty response %s", op)); } return (String) response.get("name"); } private static void validateOperation(Operation op) throws IOException { checkNotNull(op, "operation can not be null"); Status error = op.getError(); if ((error != null) && (error.getCode() != 0)) { throw new IOException(String.format("Operation failed with Error %s", error)); } checkState(op.getDone(), "Operation not completed yet"); } private static <T> ListenableFuture<T> wrapAsCatchingFuture( ListenableFuture<T> result, Executor executor, T defaultValue, String errorMessage) { return Futures.catching( result, IOException.class, new Function<IOException, T>() { @Override @Nullable public T apply(@Nullable IOException input) { checkNotNull(input); logger.log(Level.WARNING, errorMessage, input); return defaultValue; } }, executor); } }