Add transform for Principals in ACLs

This is not part of the transform pipeline because the pipeline doesn't
handle arbitrary objects well. This ACL transform support does not allow
arbitrary ACL transforms but instead only simple replacements.

The ACL transforming is intended to be a mostly hidden feature that is
only used when absolutely necessary. That won't quite be the case, but
the intention is to 1) learn the cases that people are trying to solve
and then 2) find better solutions for those cases. This likely won't
ever go away, but we should consider transforming ACLs here a hack.

Note that transforming ACLs does not change late binding behavior, so
the two can be out-of-sync.
diff --git a/src/com/google/enterprise/adaptor/AclTransform.java b/src/com/google/enterprise/adaptor/AclTransform.java
new file mode 100644
index 0000000..af740b1
--- /dev/null
+++ b/src/com/google/enterprise/adaptor/AclTransform.java
@@ -0,0 +1,190 @@
+// Copyright 2013 Google Inc. All Rights Reserved.
+//
+// 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.adaptor;
+
+import com.google.common.base.Objects;
+import com.google.enterprise.adaptor.Principal.ParsedPrincipal;
+
+import java.util.*;
+
+/**
+ * Transforms Principals in ACLs based on provided rules.
+ */
+final class AclTransform {
+  private final List<Rule> rules;
+
+  public AclTransform(List<Rule> rules) {
+    this.rules = Collections.unmodifiableList(new ArrayList<Rule>(rules));
+  }
+
+  public Acl transform(Acl acl) {
+    if (acl == null) {
+      return null;
+    }
+    if (rules.isEmpty()) {
+      return acl;
+    }
+    return new Acl.Builder(acl)
+        .setPermits(transform(acl.getPermits()))
+        .setDenies(transform(acl.getDenies()))
+        .build();
+  }
+
+  private Set<Principal> transform(Set<Principal> principals) {
+    Set<Principal> newPrincipals = new TreeSet<Principal>();
+    for (Principal principal : principals) {
+      ParsedPrincipal parsed = principal.parse();
+      for (Rule rule : rules) {
+        if (rule.match.matches(parsed)) {
+          parsed = rule.replace.replace(parsed);
+        }
+      }
+      newPrincipals.add(parsed.toPrincipal());
+    }
+    return Collections.unmodifiableSet(newPrincipals);
+  }
+
+  @Override
+  public String toString() {
+    return "AclTransform(rules=" + rules + ")";
+  }
+
+  @Override
+  public int hashCode() {
+    return rules.hashCode();
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (!(o instanceof AclTransform)) {
+      return false;
+    }
+    AclTransform a = (AclTransform) o;
+    return rules.equals(a.rules);
+  }
+
+  public static final class Rule {
+    private final MatchData match;
+    private final MatchData replace;
+
+    public Rule(MatchData match, MatchData replace) {
+      if (match == null || replace == null) {
+        throw new NullPointerException();
+      }
+      if (replace.isGroup != null) {
+        throw new IllegalArgumentException(
+            "isGroup must be null for replacements");
+      }
+      this.match = match;
+      this.replace = replace;
+    }
+
+    @Override
+    public String toString() {
+      return "Rule(match=" + match + ",replace=" + replace + ")";
+    }
+
+    @Override
+    public int hashCode() {
+      return Arrays.hashCode(new Object[] {match, replace});
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (!(o instanceof Rule)) {
+        return false;
+      }
+      Rule r = (Rule) o;
+      return match.equals(r.match) && replace.equals(r.replace);
+    }
+  }
+
+  public static final class MatchData {
+    // Visible for GsaCommunicationHandler
+    final Boolean isGroup;
+    private final String name;
+    private final String domain;
+    private final String namespace;
+
+    /**
+     * For matching, non-{@code null} fields must be equal on Principal. For
+     * replacing, non-{@code null} fields will get set on Principal.
+     * {@code isGroup} must be {@code null} for replacements.
+     */
+    public MatchData(Boolean isGroup, String name, String domain,
+        String namespace) {
+      this.isGroup = isGroup;
+      this.name = name;
+      this.domain = domain;
+      this.namespace = namespace;
+    }
+
+    private boolean matches(ParsedPrincipal principal) {
+      boolean matches = true;
+      if (isGroup != null) {
+        matches = matches && isGroup.equals(principal.isGroup);
+      }
+      if (name != null) {
+        matches = matches && name.equals(principal.plainName);
+      }
+      if (domain != null) {
+        matches = matches && domain.equals(principal.domain);
+      }
+      if (namespace != null) {
+        matches = matches && namespace.equals(principal.namespace);
+      }
+      return matches;
+    }
+
+    private ParsedPrincipal replace(ParsedPrincipal principal) {
+      if (isGroup != null) {
+        throw new AssertionError();
+      }
+      if (name != null) {
+        principal = principal.plainName(name);
+      }
+      if (domain != null) {
+        principal = principal.domain(domain);
+      }
+      if (namespace != null) {
+        principal = principal.namespace(namespace);
+      }
+      return principal;
+    }
+
+    @Override
+    public String toString() {
+      return "MatchData(isGroup=" + isGroup + ",name=" + name
+          + ",domain=" + domain + ",namespace=" + namespace + ")";
+    }
+
+    @Override
+    public int hashCode() {
+      return Arrays.hashCode(new Object[] {isGroup, name, domain, namespace});
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (!(o instanceof MatchData)) {
+        return false;
+      }
+      MatchData m = (MatchData) o;
+      return Objects.equal(isGroup, m.isGroup)
+          && Objects.equal(name, m.name)
+          && Objects.equal(domain, m.domain)
+          && Objects.equal(namespace, m.namespace);
+    }
+  }
+}
diff --git a/src/com/google/enterprise/adaptor/Config.java b/src/com/google/enterprise/adaptor/Config.java
index 0dd9f83..419b504 100644
--- a/src/com/google/enterprise/adaptor/Config.java
+++ b/src/com/google/enterprise/adaptor/Config.java
@@ -119,6 +119,8 @@
  *     checking. Defaults to false
  * <tr><td> </td><td>server.useCompression </td><td> compress retrieval
  *     responses. Defaults to true
+ * <tr><td> </td><td>transform.acl.X </td><td> where X is an integer, match
+ *     and modify principals as described. Defaults no modifications
  * <tr><td> </td><td>transform.pipeline </td><td> sequence of
  *     transformation steps.  Defaults to no-pipeline
  * </table>
diff --git a/src/com/google/enterprise/adaptor/DocumentHandler.java b/src/com/google/enterprise/adaptor/DocumentHandler.java
index 04ba831..813fa87 100644
--- a/src/com/google/enterprise/adaptor/DocumentHandler.java
+++ b/src/com/google/enterprise/adaptor/DocumentHandler.java
@@ -62,6 +62,7 @@
       = new HashSet<InetAddress>();
   private final SamlServiceProvider samlServiceProvider;
   private final TransformPipeline transform;
+  private final AclTransform aclTransform;
   private final boolean useCompression;
   private final boolean sendDocControls;
   private final long headerTimeoutMillis;
@@ -76,14 +77,14 @@
                          AuthzAuthority authzAuthority,
                          String gsaHostname, String[] fullAccessHosts,
                          SamlServiceProvider samlServiceProvider,
-                         TransformPipeline transform,
+                         TransformPipeline transform, AclTransform aclTransform,
                          boolean useCompression,
                          Watchdog watchdog, AsyncPusher pusher,
                          boolean sendDocControls, long headerTimeoutMillis,
                          long contentTimeoutMillis, String scoringType) {
     if (docIdDecoder == null || docIdEncoder == null || journal == null
-        || adaptor == null || watchdog == null || pusher == null
-        || scoringType == null) {
+        || adaptor == null || aclTransform == null || watchdog == null
+        || pusher == null || scoringType == null) {
       throw new NullPointerException();
     }
     this.docIdDecoder = docIdDecoder;
@@ -93,6 +94,7 @@
     this.authzAuthority = authzAuthority;
     this.samlServiceProvider = samlServiceProvider;
     this.transform = transform;
+    this.aclTransform = aclTransform;
     this.useCompression = useCompression;
     this.watchdog = watchdog;
     this.pusher = pusher;
@@ -777,6 +779,7 @@
       if (transform != null) {
         transform();  
       } 
+      acl = aclTransform.transform(acl);
       if (requestIsFromFullyTrustedClient(ex)) {
         // Always specify metadata and ACLs, even when empty, to replace
         // previous values.
diff --git a/src/com/google/enterprise/adaptor/GsaCommunicationHandler.java b/src/com/google/enterprise/adaptor/GsaCommunicationHandler.java
index 4d8fa24..1980a42 100644
--- a/src/com/google/enterprise/adaptor/GsaCommunicationHandler.java
+++ b/src/com/google/enterprise/adaptor/GsaCommunicationHandler.java
@@ -14,6 +14,7 @@
 
 package com.google.enterprise.adaptor;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.util.concurrent.ThreadFactoryBuilder;
 
 import com.sun.net.httpserver.Filter;
@@ -308,7 +309,7 @@
         docIdCodec, docIdCodec, journal, adaptor, authzAuthority,
         config.getGsaHostname(),
         config.getServerFullAccessHosts(),
-        samlServiceProvider, createTransformPipeline(),
+        samlServiceProvider, createTransformPipeline(), createAclTransform(),
         config.isServerToUseCompression(), watchdog,
         asyncDocIdSender, 
         config.doesGsaAcceptDocControlsHeader(),
@@ -372,6 +373,7 @@
     return createTransformPipeline(config.getTransformPipelineSpec());
   }
 
+  @VisibleForTesting
   static TransformPipeline createTransformPipeline(
       List<Map<String, String>> pipelineConfig) {
     List<DocumentTransform> elements = new LinkedList<DocumentTransform>();
@@ -427,6 +429,93 @@
     return elements.size() > 0 ? new TransformPipeline(elements, names) : null;
   }
 
+  private AclTransform createAclTransform() {
+    return createAclTransform(config.getValuesWithPrefix("transform.acl."));
+  }
+
+  @VisibleForTesting
+  static AclTransform createAclTransform(Map<String, String> aclConfigRaw) {
+    Map<Integer, String> aclConfig = new TreeMap<Integer, String>();
+    for (Map.Entry<String, String> me : aclConfigRaw.entrySet()) {
+      try {
+        aclConfig.put(Integer.parseInt(me.getKey()), me.getValue());
+      } catch (NumberFormatException ex) {
+        // Don't insert into map.
+        log.log(Level.FINE, "Ignorning transform.acl.{0} because {0} is not an "
+            + "integer", me.getKey());
+      }
+    }
+
+    List<AclTransform.Rule> rules = new LinkedList<AclTransform.Rule>();
+    for (String value : aclConfig.values()) {
+      String[] parts = value.split(";", 2);
+      if (parts.length != 2) {
+        log.log(Level.WARNING, "Could not find semicolon in acl transform: {0}",
+            value);
+        continue;
+      }
+      AclTransform.MatchData search = parseAclTransformMatchData(parts[0]);
+      AclTransform.MatchData replace = parseAclTransformMatchData(parts[1]);
+      if (search == null || replace == null) {
+        log.log(Level.WARNING,
+            "Could not parse acl transform rule: {0}", value);
+        continue;
+      }
+      if (replace.isGroup != null) {
+        log.log(Level.WARNING,
+            "Replacement cannot change type. Failed in rule: {0}", value);
+        continue;
+      }
+      rules.add(new AclTransform.Rule(search, replace));
+    }
+    return new AclTransform(rules);
+  }
+
+  private static AclTransform.MatchData parseAclTransformMatchData(String s) {
+    String[] decls = s.split(",", -1);
+    if (decls.length == 1 && decls[0].trim().equals("")) {
+      // No declarations are required
+      return new AclTransform.MatchData(null, null, null, null);
+    }
+    Boolean isGroup = null;
+    String name = null;
+    String domain = null;
+    String namespace = null;
+    for (String decl : decls) {
+      String parts[] = decl.split("=", 2);
+      if (parts.length != 2) {
+        log.log(Level.WARNING,
+            "Could not find \"=\" in \"{0}\" as part of \"{1}\"",
+            new Object[] {decl, s});
+        return null;
+      }
+      String key = parts[0].trim();
+      String value = parts[1];
+      if (key.equals("type")) {
+        if (value.equals("group")) {
+          isGroup = true;
+        } else if (value.equals("user")) {
+          isGroup = false;
+        } else {
+          log.log(Level.WARNING, "Unknown type \"{0}\" as part of \"{1}\"",
+              new Object[] {value, s});
+          return null;
+        }
+      } else if (key.equals("name")) {
+        name = value;
+      } else if (key.equals("domain")) {
+        domain = value;
+      } else if (key.equals("namespace")) {
+        namespace = value;
+      } else {
+        log.log(Level.WARNING, "Unknown key \"{0}\" as part of \"{1}\"",
+            new Object[] {key, s});
+        return null;
+      }
+    }
+    return new AclTransform.MatchData(isGroup, name, domain, namespace);
+  }
+
   /**
    * Retrieve our default KeyPair from the default keystore. The key should have
    * the same password as the keystore.
diff --git a/src/com/google/enterprise/adaptor/Principal.java b/src/com/google/enterprise/adaptor/Principal.java
index 3b32777..043fbd8 100644
--- a/src/com/google/enterprise/adaptor/Principal.java
+++ b/src/com/google/enterprise/adaptor/Principal.java
@@ -95,4 +95,168 @@
 
     return name.compareTo(other.name);
   }
+
+  ParsedPrincipal parse() {
+    for (int i = 0; i < name.length(); i++) {
+      char c = name.charAt(i);
+      switch (c) {
+        case '\\':
+          return new ParsedPrincipal(isGroup(), name.substring(i + 1),
+              name.substring(0, i), DomainFormat.NETBIOS, namespace);
+        case '/':
+          return new ParsedPrincipal(isGroup(), name.substring(i + 1),
+              name.substring(0, i), DomainFormat.NETBIOS_FORWARDSLASH,
+              namespace);
+        case '@':
+          return new ParsedPrincipal(isGroup(), name.substring(0, i),
+              name.substring(i + 1), DomainFormat.DNS, namespace);
+        default:
+      }
+    }
+    return new ParsedPrincipal(isGroup(), name, "", DomainFormat.NONE,
+        namespace);
+  }
+
+  static enum DomainFormat {
+    NONE,
+    DNS,
+    NETBIOS,
+    /**
+     * Same as NETBIOS, but a forward slash is used instead of a backslash. This
+     * is to support round-tripping all Principals; if you don't modify the
+     * ParsedPrincipal, you shouldn't see any modifications.
+     */
+    NETBIOS_FORWARDSLASH,
+    ;
+  }
+
+  /**
+   * Immutable form of Principal where user's name has been split into name and
+   * domain components. This class guarantees full fidelity of Principal
+   * objects: {@code principal.equals(principal.parse().toPrincipal())} is
+   * always {@code true}.
+   */
+  static class ParsedPrincipal<T extends Principal> {
+    public final boolean isGroup;
+    public final String plainName;
+    public final String domain;
+    public final DomainFormat domainFormat;
+    public final String namespace;
+
+    public ParsedPrincipal(boolean isGroup, String plainName, String domain,
+        DomainFormat domainFormat, String namespace) {
+      if (plainName == null || domain == null || domainFormat == null
+          || namespace == null) {
+        throw new NullPointerException();
+      }
+      this.isGroup = isGroup;
+      this.plainName = plainName;
+      this.domain = domain;
+      this.domainFormat = domainFormat;
+      this.namespace = namespace;
+    }
+
+    public ParsedPrincipal plainName(String plainName) {
+      return new ParsedPrincipal(isGroup, plainName, domain, domainFormat,
+          namespace);
+    }
+
+    public ParsedPrincipal domain(String domain) {
+      return new ParsedPrincipal(isGroup, plainName, domain, domainFormat,
+          namespace);
+    }
+
+    public ParsedPrincipal domainFormat(DomainFormat domainFormat) {
+      return new ParsedPrincipal(isGroup, plainName, domain, domainFormat,
+          namespace);
+    }
+
+    public ParsedPrincipal namespace(String namespace) {
+      return new ParsedPrincipal(isGroup, plainName, domain, domainFormat,
+          namespace);
+    }
+
+    /**
+     * Determine the format that should be used for combining the name and
+     * domain together. This does not simply return domainFormat, because it
+     * needs to make sure that using such a format will not cause ambiguities.
+     */
+    private DomainFormat determineEffectiveFormat() {
+      DomainFormat format = domainFormat;
+      if (domain.equals("")) {
+        return format;
+      }
+
+      // Domain handling
+      if (format == DomainFormat.NONE) {
+        format = DomainFormat.NETBIOS;
+      }
+      if ((format == DomainFormat.NETBIOS
+            || format == DomainFormat.NETBIOS_FORWARDSLASH)
+          && containsSpecial(domain)) {
+        format = DomainFormat.DNS;
+      }
+      if (format == DomainFormat.DNS && containsSpecial(plainName)) {
+        if (containsSpecial(domain)) {
+          throw new IllegalStateException("Neither NETBIOS nor DNS formats can "
+              + "be used: plainName=" + plainName + " domain=" + domain);
+        }
+        format = DomainFormat.NETBIOS;
+      }
+      return format;
+    }
+
+    private boolean containsSpecial(String s) {
+      return s.contains("\\") || s.contains("/") || s.contains("@");
+    }
+
+    public Principal toPrincipal() {
+      String name;
+      switch (determineEffectiveFormat()) {
+        case NONE:
+          name = plainName;
+          break;
+        case DNS:
+          name = plainName + "@" + domain;
+          break;
+        case NETBIOS:
+          name = domain + "\\" + plainName;
+          break;
+        case NETBIOS_FORWARDSLASH:
+          name = domain + "/" + plainName;
+          break;
+        default:
+          throw new AssertionError();
+      }
+      if (isGroup) {
+        return new GroupPrincipal(name, namespace);
+      } else {
+        return new UserPrincipal(name, namespace);
+      }
+    }
+
+    @Override
+    public String toString() {
+      return "ParsedPrincipal(isGroup=" + isGroup + ",plainName=" + plainName
+          + ",domain=" + domain + ",domainFormat=" + domainFormat
+          + ",namespace=" + namespace + ")";
+    }
+
+    @Override
+    public int hashCode() {
+      return Arrays.hashCode(
+          new Object[] {isGroup, plainName, domain, domainFormat, namespace});
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (!(o instanceof ParsedPrincipal)) {
+        return false;
+      }
+      ParsedPrincipal p = (ParsedPrincipal) o;
+      return isGroup == p.isGroup && plainName.equals(p.plainName)
+          && domainFormat == p.domainFormat && namespace.equals(p.namespace)
+          && domain.equals(p.domain);
+    }
+  }
 }
diff --git a/test/com/google/enterprise/adaptor/AclTransformTest.java b/test/com/google/enterprise/adaptor/AclTransformTest.java
new file mode 100644
index 0000000..d23ad61
--- /dev/null
+++ b/test/com/google/enterprise/adaptor/AclTransformTest.java
@@ -0,0 +1,198 @@
+// Copyright 2013 Google Inc. All Rights Reserved.
+//
+// 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.adaptor;
+
+import static org.junit.Assert.*;
+
+import com.google.enterprise.adaptor.AclTransform.MatchData;
+import com.google.enterprise.adaptor.AclTransform.Rule;
+
+import org.junit.Test;
+
+import java.util.*;
+
+/** Unit tests for {@link AclTransform}. */
+public class AclTransformTest {
+  private static final Acl baseAcl = new Acl.Builder()
+      .setInheritFrom(new DocId("inherit place"))
+      .setEverythingCaseInsensitive()
+      .setPermits(Arrays.asList(
+          new UserPrincipal("u1"),
+          new GroupPrincipal("g1@d1"),
+          new GroupPrincipal("D1\\g1"),
+          new GroupPrincipal("D1/g1"),
+          new GroupPrincipal("u1", "ns1"),
+          new GroupPrincipal("u1@d1", "ns2")))
+      .setDenies(Arrays.asList(
+          new UserPrincipal("u1"),
+          new GroupPrincipal("g1"),
+          new GroupPrincipal("g1", "ns1")))
+      .build();
+
+  @Test
+  public void testEmptyTransform() {
+    assertSame(baseAcl,
+        new AclTransform(Arrays.<Rule>asList()).transform(baseAcl));
+  }
+
+  @Test
+  public void testNonMatchingTransform() {
+    List<Rule> rules = Arrays.asList(
+        new Rule(new MatchData(null, "nonmatching", null, null),
+          new MatchData(null, "noprincipal", "nodomain", "nons")));
+    assertEquals(baseAcl, new AclTransform(rules).transform(baseAcl));
+  }
+
+  @Test
+  public void testAllMatchingTransform() {
+    List<Rule> rules = Arrays.asList(
+        new Rule(new MatchData(null, null, null, null),
+          new MatchData(null, "anyprincipal", "anydomain", "anyns")));
+    AclTransform transform = new AclTransform(rules);
+    // Test null passthrough and re-use of AclTransform
+    assertNull(transform.transform(null));
+    assertEquals(new Acl.Builder(baseAcl)
+          .setPermits(Arrays.asList(
+            new UserPrincipal("anydomain\\anyprincipal", "anyns"),
+            new GroupPrincipal("anyprincipal@anydomain", "anyns"),
+            new GroupPrincipal("anydomain\\anyprincipal", "anyns"),
+            new GroupPrincipal("anydomain/anyprincipal", "anyns")))
+          .setDenies(Arrays.asList(
+            new UserPrincipal("anydomain\\anyprincipal", "anyns"),
+            new GroupPrincipal("anydomain\\anyprincipal", "anyns")))
+          .build(),
+        transform.transform(baseAcl));
+  }
+
+  @Test
+  public void testAllMatchingTransformNoop() {
+    List<Rule> rules = Arrays.asList(
+        new Rule(new MatchData(null, null, null, null),
+          new MatchData(null, null, null, null)));
+    assertEquals(baseAcl, new AclTransform(rules).transform(baseAcl));
+  }
+
+  @Test
+  public void testGroupMatching() {
+    List<Rule> rules = Arrays.asList(
+        new Rule(new MatchData(true, null, null, null),
+          new MatchData(null, "anygroup", null, null)));
+    assertEquals(new Acl.Builder(baseAcl)
+          .setPermits(Arrays.asList(
+            new UserPrincipal("u1"),
+            new GroupPrincipal("anygroup@d1"),
+            new GroupPrincipal("D1\\anygroup"),
+            new GroupPrincipal("D1/anygroup"),
+            new GroupPrincipal("anygroup", "ns1"),
+            new GroupPrincipal("anygroup@d1", "ns2")))
+          .setDenies(Arrays.asList(
+            new UserPrincipal("u1"),
+            new GroupPrincipal("anygroup", "ns1"),
+            new GroupPrincipal("anygroup")))
+          .build(),
+        new AclTransform(rules).transform(baseAcl));
+  }
+
+  @Test
+  public void testSpecificMatching() {
+    List<Rule> rules = Arrays.asList(
+        new Rule(new MatchData(true, "g1", "", "ns1"),
+          new MatchData(null, null, "foundit", null)));
+    assertEquals(new Acl.Builder(baseAcl)
+          .setDenies(Arrays.asList(
+            new UserPrincipal("u1"),
+            new GroupPrincipal("g1"),
+            new GroupPrincipal("foundit\\g1", "ns1")))
+          .build(),
+        new AclTransform(rules).transform(baseAcl));
+  }
+
+  @Test(expected = NullPointerException.class)
+  public void testRuleNullMatch() {
+    new Rule(null, new MatchData(null, null, null, null));
+  }
+
+  @Test(expected = NullPointerException.class)
+  public void testRuleNullReplacement() {
+    new Rule(new MatchData(null, null, null, null), null);
+  }
+
+  @Test(expected = IllegalArgumentException.class)
+  public void testRuleWithNonNullReplacementGroup() {
+    new Rule(new MatchData(null, null, null, null),
+        new MatchData(false, null, null, null));
+  }
+
+  @Test
+  public void testToString() {
+    assertEquals("AclTransform(rules=[Rule("
+        + "match=MatchData(isGroup=true,name=a,domain=b,namespace=c),"
+        + "replace=MatchData(isGroup=null,name=null,domain=null,namespace=null)"
+        + ")])",
+        new AclTransform(Arrays.asList(new Rule(
+          new MatchData(true, "a", "b", "c"),
+          new MatchData(null, null, null, null)))).toString());
+  }
+
+  @Test
+  public void testEquals() {
+    MatchData m1 = new MatchData(true, "a", "b", "c");
+    MatchData m2 = new MatchData(true, new String("a"), new String("b"),
+        new String("c"));
+    MatchData m3 = new MatchData(false, "a", "b", "c");
+    MatchData m4 = new MatchData(null, "a", "b", "c");
+    MatchData m5 = new MatchData(true, "z", "b", "c");
+    MatchData m6 = new MatchData(true, null, "b", "c");
+    MatchData m7 = new MatchData(true, "a", "z", "c");
+    MatchData m8 = new MatchData(true, "a", null, "c");
+    MatchData m9 = new MatchData(true, "a", "b", "z");
+    MatchData m10 = new MatchData(true, "a", "b", null);
+    assertEquals(m1, m2);
+    assertEquals(m1.hashCode(), m2.hashCode());
+    assertFalse(m1.equals(m3));
+    assertFalse(m1.equals(m4));
+    assertFalse(m1.equals(m5));
+    assertFalse(m1.equals(m6));
+    assertFalse(m1.equals(m7));
+    assertFalse(m1.equals(m8));
+    assertFalse(m1.equals(m9));
+    assertFalse(m1.equals(m10));
+    assertFalse(m1.equals(new Object()));
+
+    // Same as m4
+    MatchData m11 = new MatchData(null, new String("a"), new String("b"),
+        new String("c"));
+    MatchData m12 = new MatchData(null, "a", "b", null);
+    Rule r1 = new Rule(m1, m4);
+    Rule r2 = new Rule(m2, m11);
+    Rule r3 = new Rule(m3, m4);
+    Rule r4 = new Rule(m1, m12);
+    assertEquals(r1, r2);
+    assertEquals(r1.hashCode(), r2.hashCode());
+    assertFalse(r1.equals(r3));
+    assertFalse(r1.equals(r4));
+    assertFalse(r1.equals(new Object()));
+
+    AclTransform t1 = new AclTransform(Arrays.asList(r1));
+    AclTransform t2 = new AclTransform(Arrays.asList(r2));
+    AclTransform t3 = new AclTransform(Arrays.asList(r3));
+    AclTransform t4 = new AclTransform(Arrays.asList(r1, r1));
+    assertEquals(t1, t2);
+    assertEquals(t1.hashCode(), t2.hashCode());
+    assertFalse(t1.equals(t3));
+    assertFalse(t1.equals(t4));
+    assertFalse(t1.equals(new Object()));
+  }
+}
diff --git a/test/com/google/enterprise/adaptor/DocumentHandlerTest.java b/test/com/google/enterprise/adaptor/DocumentHandlerTest.java
index e33ec08..d7f6273 100644
--- a/test/com/google/enterprise/adaptor/DocumentHandlerTest.java
+++ b/test/com/google/enterprise/adaptor/DocumentHandlerTest.java
@@ -358,6 +358,36 @@
   }
 
   @Test
+  public void testAclTransform() throws Exception {
+    AclTransform aclTransform = new AclTransform(Arrays.asList(
+        new AclTransform.Rule(
+            new AclTransform.MatchData(null, "u1", null, null),
+            new AclTransform.MatchData(null, "u2", null, null))));
+    mockAdaptor = new MockAdaptor() {
+      @Override
+      public void getDocContent(Request request, Response response)
+          throws IOException, InterruptedException {
+        response.setAcl(new Acl.Builder()
+            .setPermitUsers(Arrays.asList(
+                new UserPrincipal("u1"), new UserPrincipal("u3")))
+            .build());
+        super.getDocContent(request, response);
+      }
+    };
+    String remoteIp = ex.getRemoteAddress().getAddress().getHostAddress();
+    DocumentHandler handler = createHandlerBuilder()
+        .setAdaptor(mockAdaptor)
+        .setFullAccessHosts(new String[] {remoteIp})
+        .setAclTransform(aclTransform)
+        .build();
+    mockAdaptor.documentBytes = new byte[] {1, 2, 3};
+    handler.handle(ex);
+    assertEquals(200, ex.getResponseCode());
+    assertEquals("google%3Aaclusers=u2,google%3Aaclusers=u3",
+                 ex.getResponseHeaders().get("X-Gsa-External-Metadata").get(1));
+  }
+
+  @Test
   public void testNullAuthzResponse() throws Exception {
     MockAdaptor adaptor = new MockAdaptor() {
           @Override
@@ -1376,6 +1406,8 @@
     private String[] fullAccessHosts = new String[0];
     private SamlServiceProvider samlServiceProvider;
     private TransformPipeline transform;
+    private AclTransform aclTransform
+        = new AclTransform(Arrays.<AclTransform.Rule>asList());
     private int transformMaxBytes;
     private boolean transformRequired;
     private boolean useCompression;
@@ -1433,6 +1465,11 @@
       return this;
     }
 
+    public DocumentHandlerBuilder setAclTransform(AclTransform aclTransform) {
+      this.aclTransform = aclTransform;
+      return this;
+    }
+
     public DocumentHandlerBuilder setUseCompression(boolean useCompression) {
       this.useCompression = useCompression;
       return this;
@@ -1474,8 +1511,8 @@
     public DocumentHandler build() {
       return new DocumentHandler(docIdDecoder, docIdEncoder, journal, adaptor,
           authzAuthority, gsaHostname, fullAccessHosts, samlServiceProvider,
-          transform, useCompression, watchdog, pusher, sendDocControls,
-          headerTimeoutMillis, contentTimeoutMillis, scoring);
+          transform, aclTransform, useCompression, watchdog, pusher,
+          sendDocControls, headerTimeoutMillis, contentTimeoutMillis, scoring);
     }
   }
 }
diff --git a/test/com/google/enterprise/adaptor/GsaCommunicationHandlerTest.java b/test/com/google/enterprise/adaptor/GsaCommunicationHandlerTest.java
index 7b9d513..3ede388 100644
--- a/test/com/google/enterprise/adaptor/GsaCommunicationHandlerTest.java
+++ b/test/com/google/enterprise/adaptor/GsaCommunicationHandlerTest.java
@@ -370,6 +370,41 @@
   }
 
   @Test
+  public void testCreateAclTransformNone() throws Exception {
+    Map<String, String> config = new HashMap<String, String>();
+    assertEquals(new AclTransform(Arrays.<AclTransform.Rule>asList()),
+        GsaCommunicationHandler.createAclTransform(config));
+  }
+
+  @Test
+  public void testCreateAclTransformNoOp() throws Exception {
+    Map<String, String> config = new HashMap<String, String>() {
+      {
+        put("nogood", "name=u1;name=u1");
+        put("1", " type=group; ");
+        put("3", "nosemicolon");
+        put("5", "name=u1; name=u2, namespace=ns1");
+        put("6", "type=user, domain=d1;domain=d2");
+        put("7", "unknown=key;name=u1");
+        put("8", "missingequals;name=u1");
+        put("9", "type=unknown;name=u1");
+        put("10", "name=u1;type=group");
+      }
+    };
+    assertEquals(new AclTransform(Arrays.asList(
+          new AclTransform.Rule(
+            new AclTransform.MatchData(true, null, null, null),
+            new AclTransform.MatchData(null, null, null, null)),
+          new AclTransform.Rule(
+            new AclTransform.MatchData(null, "u1", null, null),
+            new AclTransform.MatchData(null, "u2", null, "ns1")),
+          new AclTransform.Rule(
+            new AclTransform.MatchData(false, null, "d1", null),
+            new AclTransform.MatchData(null, null, "d2", null)))),
+        GsaCommunicationHandler.createAclTransform(config));
+  }
+
+  @Test
   public void testKeyStore() throws Exception {
     assertNotNull(GsaCommunicationHandler.getKeyPair("notadaptor",
         KEYSTORE_VALID_FILENAME, "JKS", "notchangeit"));
diff --git a/test/com/google/enterprise/adaptor/PrincipalTest.java b/test/com/google/enterprise/adaptor/PrincipalTest.java
index 48366a2..a723f6e 100644
--- a/test/com/google/enterprise/adaptor/PrincipalTest.java
+++ b/test/com/google/enterprise/adaptor/PrincipalTest.java
@@ -16,6 +16,9 @@
 
 import static org.junit.Assert.*;
 
+import com.google.enterprise.adaptor.Principal.DomainFormat;
+import com.google.enterprise.adaptor.Principal.ParsedPrincipal;
+
 import org.junit.*;
 import org.junit.rules.ExpectedException;
 
@@ -162,4 +165,136 @@
        assertEquals(sorted, dup);
      }
   }
+
+  @Test
+  public void testPrincipalParse() {
+    assertEquals(
+        new ParsedPrincipal(false, "user", "", DomainFormat.NONE, "Default"),
+        new UserPrincipal("user").parse());
+    assertEquals(new ParsedPrincipal(false, "user", "example.com",
+          DomainFormat.DNS, "somens"),
+        new UserPrincipal("user@example.com", "somens").parse());
+    assertEquals(new ParsedPrincipal(false, "usr", "EXAMPLE",
+          DomainFormat.NETBIOS, "Default"),
+        new UserPrincipal("EXAMPLE\\usr").parse());
+    assertEquals(new ParsedPrincipal(true, "grp", "EXAMPLE.COM",
+          DomainFormat.NETBIOS_FORWARDSLASH, "Default"),
+        new GroupPrincipal("EXAMPLE.COM/grp").parse());
+  }
+
+  @Test
+  public void testParsedPrincipalRoundtrip() {
+    // All principals should round-trip back to an identical principal.
+    List<Principal> golden = Arrays.asList(
+        new UserPrincipal("user"),
+        new UserPrincipal("user@example.com", "ns1"),
+        new UserPrincipal("EXAMPLE.COM\\user", "ns2"),
+        new UserPrincipal("EXAMPLE.COM/user"),
+        new GroupPrincipal("group1"),
+        new GroupPrincipal("group@example.com"),
+        new GroupPrincipal("\\group"),
+        new GroupPrincipal("domain\\"),
+        new GroupPrincipal("/group"),
+        new GroupPrincipal("domain/"),
+        new GroupPrincipal("@domain"),
+        new GroupPrincipal("group@"),
+        new GroupPrincipal("domain\\group@/\\extra"),
+        new GroupPrincipal("domain/group@/\\extra"),
+        new GroupPrincipal("group@domain@/\\extra"));
+    for (Principal p : golden) {
+      assertEquals(p, p.parse().toPrincipal());
+    }
+  }
+
+  @Test
+  public void testParsedPrincipalToPrincipal() {
+    assertEquals(new UserPrincipal("a", "ns"),
+        new ParsedPrincipal(false, "a", "", DomainFormat.NONE, "ns")
+          .toPrincipal());
+    assertEquals(new UserPrincipal("a@", "ns"),
+        new ParsedPrincipal(false, "a", "", DomainFormat.DNS, "ns")
+          .toPrincipal());
+    assertEquals(new UserPrincipal("DOMAIN\\a", "ns"),
+        new ParsedPrincipal(false, "a", "DOMAIN", DomainFormat.NONE, "ns")
+          .toPrincipal());
+    assertEquals(new UserPrincipal("a@DOM\\AIN", "ns"),
+        new ParsedPrincipal(false, "a", "DOM\\AIN", DomainFormat.NETBIOS, "ns")
+          .toPrincipal());
+    assertEquals(new UserPrincipal("domain.com\\a@", "ns"),
+        new ParsedPrincipal(false, "a@", "domain.com", DomainFormat.DNS, "ns")
+          .toPrincipal());
+    assertEquals(new UserPrincipal("domain.com\\a/", "ns"),
+        new ParsedPrincipal(false, "a/", "domain.com", DomainFormat.DNS, "ns")
+          .toPrincipal());
+  }
+
+  @Test(expected = IllegalStateException.class)
+  public void testParsedPrincipalToPrincipalFail() {
+    new ParsedPrincipal(false, "a/", "domain@com", DomainFormat.DNS, "ns")
+        .toPrincipal();
+  }
+
+  @Test(expected = NullPointerException.class)
+  public void testParsedPrincipalNullPlainName() {
+    new ParsedPrincipal(false, null, "b", DomainFormat.DNS, "c");
+  }
+
+  @Test(expected = NullPointerException.class)
+  public void testParsedPrincipalNullDomain() {
+    new ParsedPrincipal(false, "a", null, DomainFormat.DNS, "c");
+  }
+
+  @Test(expected = NullPointerException.class)
+  public void testParsedPrincipalNullDomainFormat() {
+    new ParsedPrincipal(false, "a", "b", null, "c");
+  }
+
+  @Test(expected = NullPointerException.class)
+  public void testParsedPrincipalNullNamespace() {
+    new ParsedPrincipal(false, "a", "b", DomainFormat.DNS, null);
+  }
+
+  @Test
+  public void testParsedPrincipalEquals() {
+    ParsedPrincipal p1 = new ParsedPrincipal(false, "a", "b", DomainFormat.DNS,
+        "c");
+    ParsedPrincipal p2 = new ParsedPrincipal(false, new String("a"),
+        new String("b"), DomainFormat.DNS, new String("c"));
+    assertEquals(p1, p2);
+    assertEquals(p1.hashCode(), p2.hashCode());
+
+    assertFalse(p1.equals(new Object()));
+    assertFalse(p1.equals(new ParsedPrincipal(true, "a", "b", DomainFormat.DNS,
+        "c")));
+    assertFalse(p1.equals(new ParsedPrincipal(false, "z", "b", DomainFormat.DNS,
+        "c")));
+    assertFalse(p1.equals(new ParsedPrincipal(false, "a", "z", DomainFormat.DNS,
+        "c")));
+    assertFalse(p1.equals(new ParsedPrincipal(false, "a", "b",
+        DomainFormat.NONE, "c")));
+    assertFalse(p1.equals(new ParsedPrincipal(false, "a", "b", DomainFormat.DNS,
+        "z")));
+  }
+
+  @Test
+  public void testParsedPrincipalToString() {
+    assertEquals("ParsedPrincipal(isGroup=true,plainName=a,domain=b,"
+          + "domainFormat=NETBIOS,namespace=ns)",
+        new ParsedPrincipal(true, "a", "b", DomainFormat.NETBIOS, "ns")
+          .toString());
+  }
+
+  @Test
+  public void testParsedPrincipalSetters() {
+    ParsedPrincipal p
+        = new ParsedPrincipal(false, "a", "b", DomainFormat.DNS, "c");
+    assertEquals(new ParsedPrincipal(false, "z", "b", DomainFormat.DNS, "c"),
+        p.plainName("z"));
+    assertEquals(new ParsedPrincipal(false, "a", "z", DomainFormat.DNS, "c"),
+        p.domain("z"));
+    assertEquals(new ParsedPrincipal(false, "a", "b", DomainFormat.NONE, "c"),
+        p.domainFormat(DomainFormat.NONE));
+    assertEquals(new ParsedPrincipal(false, "a", "b", DomainFormat.DNS, "z"),
+        p.namespace("z"));
+  }
 }