Merge branch 'stable-3.1' into stable-3.2

* stable-3.1:
  Update Okta instructions

Change-Id: I040b7b95d498542286683ae23c6cd61153d2d309
diff --git a/README.md b/README.md
index 0425eaf..9a01e03 100644
--- a/README.md
+++ b/README.md
@@ -190,6 +190,22 @@
 
 Default is not set.
 
+**saml.memberOfAttr**: Gerrit will look for an attribute with this name in the
+assertion to find the groups the user is member of.
+
+The user will receive these groups prefixed with `saml/` in gerrit.  When the
+groups do not exist, they will be created.  When a user its membership is removed
+this group will also be removed from this user on his next login.
+
+As group membership is only updated when a user logs in on the UI, so when a
+user loses membership to a group in SAML, he will still be able to execute his
+rights as if he is part of that group as long as he does not log in to the UI.
+So enabling this feature can be seen as a security risk in certain environments.
+
+When this attribute is not set or empty, SAML membership synchronization is disabled.
+
+Default is not set.
+
 **saml.useNameQualifier**: By SAML specification, the authentication request must not contain a NameQualifier, if the SP entity is in the format nameid-format:entity. However, some IdP require that information to be present. You can force a NameQualifier in the request with the useNameQualifier parameter. For ADFS 3.0 support, set this to `false`.
 
 Default is true.
diff --git a/src/main/java/com/googlesource/gerrit/plugins/saml/SamlConfig.java b/src/main/java/com/googlesource/gerrit/plugins/saml/SamlConfig.java
index 98f09ea..45b26f3 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/saml/SamlConfig.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/saml/SamlConfig.java
@@ -38,6 +38,7 @@
   private final String lastNameAttr;
   private final int maxAuthLifetimeDefault = 24 * 60 * 60; // 24h;
   private final boolean useNameQualifier;
+  private final String memberOfAttr;
 
   @Inject
   SamlConfig(@GerritServerConfig Config cfg) {
@@ -55,6 +56,7 @@
     firstNameAttr = getGetStringWithDefault(cfg, "firstNameAttr", "FirstName");
     lastNameAttr = getGetStringWithDefault(cfg, "lastNameAttr", "LastName");
     useNameQualifier = cfg.getBoolean(SAML_SECTION, "useNameQualifier", true);
+    memberOfAttr = getString(cfg, "memberOfAttr");
   }
 
   public String getMetadataPath() {
@@ -124,4 +126,8 @@
   public String getIdentityProviderEntityId() {
     return identityProviderEntityId;
   }
+
+  public String getMemberOfAttr() {
+    return memberOfAttr;
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/saml/SamlMembership.java b/src/main/java/com/googlesource/gerrit/plugins/saml/SamlMembership.java
new file mode 100644
index 0000000..e1ba2c0
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/saml/SamlMembership.java
@@ -0,0 +1,187 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// 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.googlesource.gerrit.plugins.saml;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.account.*;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.group.db.GroupsUpdate;
+import com.google.gerrit.server.group.db.InternalGroupCreation;
+import com.google.gerrit.server.group.db.InternalGroupUpdate;
+import com.google.gerrit.server.notedb.Sequences;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.lang.invoke.MethodHandles;
+import java.util.*;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.pac4j.saml.profile.SAML2Profile;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+/**
+ * This class maps the membership attributes in the SAML document onto Internal groups prefixed with
+ * the saml group prefix.
+ */
+public class SamlMembership {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+  private static final String GROUP_PREFIX = "saml/";
+
+  private final String memberAttr;
+  private final PersonIdent serverIdent;
+  private final AccountManager accountManager;
+  private final GroupCache groupCache;
+  private final IdentifiedUser.GenericFactory userFactory;
+  private final Provider<GroupsUpdate> groupsUpdateProvider;
+  private final Sequences sequences;
+
+  @Inject
+  SamlMembership(
+      SamlConfig samlConfig,
+      @GerritPersonIdent PersonIdent serverIdent,
+      AccountManager accountManager,
+      GroupCache groupCache,
+      IdentifiedUser.GenericFactory userFactory,
+      @ServerInitiated Provider<GroupsUpdate> groupsUpdateProvider,
+      Sequences sequences) {
+    this.memberAttr = samlConfig.getMemberOfAttr();
+    this.serverIdent = serverIdent;
+    this.accountManager = accountManager;
+    this.groupCache = groupCache;
+    this.userFactory = userFactory;
+    this.groupsUpdateProvider = groupsUpdateProvider;
+    this.sequences = sequences;
+  }
+
+  /**
+   * Synchronises the groups of a user with those in LDAP.
+   *
+   * @param user gerrit user
+   * @param profile SAML profile
+   */
+  public void sync(AuthenticatedUser user, SAML2Profile profile) throws IOException {
+    Set<AccountGroup.UUID> samlMembership =
+        Optional.ofNullable((List<?>) profile.getAttribute(memberAttr, List.class))
+            .orElse(Collections.emptyList()).stream()
+            .map(m -> getOrCreateGroup(m.toString()))
+            .filter(Optional::isPresent)
+            .map(Optional::get)
+            .collect(Collectors.toSet());
+    IdentifiedUser identifiedUser = userFactory.create(getOrCreateAccountId(user));
+    Set<AccountGroup.UUID> userMembership =
+        identifiedUser.getEffectiveGroups().getKnownGroups().stream()
+            .filter(
+                uuid ->
+                    groupCache
+                        .get(uuid)
+                        .filter(g -> g.getName().startsWith(GROUP_PREFIX))
+                        .isPresent())
+            .collect(Collectors.toSet());
+
+    log.debug(
+        "User {} is member of {} in saml and {} in gerrit",
+        user.getUsername(),
+        samlMembership,
+        userMembership);
+
+    Set<Account.Id> accountIdSet = ImmutableSet.of(identifiedUser.getAccountId());
+    samlMembership.stream()
+        .filter(g -> !userMembership.contains(g))
+        .forEach(g -> this.updateMembers(g, members -> Sets.union(members, accountIdSet)));
+    userMembership.stream()
+        .filter(g -> !samlMembership.contains(g))
+        .forEach(
+            g ->
+                this.updateMembers(
+                    g,
+                    members ->
+                        Sets.difference(members, ImmutableSet.of(identifiedUser.getAccountId()))));
+  }
+
+  /**
+   * test if membership syncing is enabled.
+   *
+   * @return true when it is enabled.
+   */
+  public boolean isEnabled() {
+    return !Strings.isNullOrEmpty(memberAttr);
+  }
+
+  private void updateMembers(
+      AccountGroup.UUID group, InternalGroupUpdate.MemberModification memberModification) {
+    InternalGroupUpdate update =
+        InternalGroupUpdate.builder().setMemberModification(memberModification).build();
+    try {
+      groupsUpdateProvider.get().updateGroup(group, update);
+    } catch (Exception e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  private Optional<AccountGroup.UUID> getOrCreateGroup(String samlGroup) {
+    return samlGroupToName(samlGroup)
+        .map(name -> groupCache.get(name).orElseGet(() -> createGroup(name, samlGroup)))
+        .map(InternalGroup::getGroupUUID);
+  }
+
+  private InternalGroup createGroup(AccountGroup.NameKey name, String samlGroup) {
+    try {
+      AccountGroup.Id groupId = AccountGroup.id(sequences.nextGroupId());
+      AccountGroup.UUID uuid = GroupUuid.make(name.get(), serverIdent);
+      InternalGroupCreation groupCreation =
+          InternalGroupCreation.builder()
+              .setGroupUUID(uuid)
+              .setNameKey(name)
+              .setId(groupId)
+              .build();
+      InternalGroupUpdate.Builder groupUpdateBuilder =
+          InternalGroupUpdate.builder()
+              .setVisibleToAll(false)
+              .setDescription(samlGroup + " (imported by the SAML plugin)");
+      return groupsUpdateProvider.get().createGroup(groupCreation, groupUpdateBuilder.build());
+    } catch (Exception e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  private Optional<AccountGroup.NameKey> samlGroupToName(String samlGroup) {
+    return Optional.of(samlGroup)
+        .filter(s -> !s.isEmpty())
+        .map(GROUP_PREFIX::concat)
+        .map(AccountGroup::nameKey);
+  }
+
+  private Account.Id getOrCreateAccountId(AuthenticatedUser user) throws IOException {
+    AuthRequest authRequest = AuthRequest.forUser(user.getUsername());
+    authRequest.setUserName(user.getUsername());
+    authRequest.setEmailAddress(user.getEmail());
+    authRequest.setDisplayName(user.getDisplayName());
+    try {
+      return accountManager.authenticate(authRequest).getAccountId();
+    } catch (AccountException e) {
+      throw new RuntimeException(e);
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/saml/SamlWebFilter.java b/src/main/java/com/googlesource/gerrit/plugins/saml/SamlWebFilter.java
index ee4c72f..357ff24 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/saml/SamlWebFilter.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/saml/SamlWebFilter.java
@@ -73,11 +73,17 @@
   private final String httpExternalIdHeader;
   private final HashSet<String> authHeaders;
   private final boolean userNameToLowerCase;
+  private final SamlMembership samlMembership;
 
   @Inject
-  SamlWebFilter(@GerritServerConfig Config gerritConfig, SitePaths sitePaths, SamlConfig samlConfig)
+  SamlWebFilter(
+      @GerritServerConfig Config gerritConfig,
+      SitePaths sitePaths,
+      SamlConfig samlConfig,
+      SamlMembership samlMembership)
       throws IOException {
     this.samlConfig = samlConfig;
+    this.samlMembership = samlMembership;
     log.debug("Max Authentication Lifetime: " + samlConfig.getMaxAuthLifetimeAttr());
     SAML2Configuration samlClientConfig =
         new SAML2Configuration(
@@ -185,13 +191,16 @@
           getUserName(user),
           user.getAttributes());
       HttpSession s = context.getRequest().getSession();
-      s.setAttribute(
-          SESSION_ATTR_USER,
+      AuthenticatedUser authenticatedUser =
           new AuthenticatedUser(
               getUserName(user),
               getDisplayName(user),
               getEmailAddress(user),
-              String.format("%s/%s", SAML, user.getId())));
+              String.format("%s/%s", SAML, user.getId()));
+      s.setAttribute(SESSION_ATTR_USER, authenticatedUser);
+      if (samlMembership.isEnabled()) {
+        samlMembership.sync(authenticatedUser, user);
+      }
 
       String redirectUri = context.getRequest().getParameter("RelayState");
       if (null == redirectUri || redirectUri.isEmpty()) {