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()) {