Thursday, February 28, 2013

Lucene permissions and Hibernate Search / Compass

In our application we have some permissions system, restricting access to various entities for particular users. The permissions system consists of:
  1. Users - entity contains a list of users that may access it.
  2. Groups - entity contains a list of groups that may access it. Each group contains lists of users belonging to the group and can have subgroups.
  3. Roles - entity contains a list of roles that may access it. Each roles contains list of users having a role, and may have parent roles.
To make it simple and efficient, the User, Group and Role shares one Actor table and have unique ID. The base Actor entity represents Hibernate relation to permitted actors:

public class SecuredEntity {
  public Set<Actor> getAllPermittedActors() {
    return ...;
  }
}

How the permissions filtering works internally for DB queries is not a subject of this article, but one can imagine it easily. Additionally we calculate all Id-s for given Actor, and use them in filters:

public class Actor {
  public Collection<Long> getAllActorIds() {
    return ...;
  }
}

Now, all important entities are indexed by Lucene using Hibernate Search. It is crucial from security point of view to not to return in search result objects, that the current actor can't access, but Lucene doesn't provide any security mechanism. Hovewer it can be done easily in following way.

First we introduce indexed property, containing all permitted Id-s for given object. Let's define the appropriate interface, returning Id-s for Lucene:

public interface ISearchPerms {

  public static final String FIELD_SEARCHPERMS = "searchPerms";

  public static class Helper {

    public static Set<Long> getDefaultSearchPerms() {
      Set<Long> set = new HashSet<Long>();
      set.add(0l);
      return set;
    }

    public static Set<Long> getSearchPermsForActors(Collection<Actor> actors) {
      Set<Long> set = new HashSet<Long>();
      for (Actor a: actors)
        set.add(a.getId());
      return set;
    }

  }

  Set<Long> getSearchPerms();

}
If you have different Id-s in your Actor classes, you may return strings representing Users, Groups and Roles: eg. "U1", "U2", "G1", "G2", "R1", "R2".

Now this interface needs to be implemented in your @Indexed entities, with @Field definition for Hibernate Search (or @SearchableProperty for Compass).

@Indexed
public class SecuredEntity implements ISearchPerms {

  @Override
  @Field(store = Store.YES, name = ISearchPerms.FIELD_SEARCHPERMS, 
    index = Index.UN_TOKENIZED)
  @FieldBridge(impl = LongCollectionFieldBridge.class)
  public Set<Long> getSearchPerms() {
    return ISearchPerms.Helper.getSearchPermsForActors(getAllPermittedActors());
  }

}

For non-secured objects we need the same, but we use common "non-secured-id", ie. 0, what means that the object is not secured, but should be always returned in the search results for all:

@Indexed
public class NonSecuredEntity implements ISearchPerms {

  @Override
  @Field(store = Store.YES, name = ISearchPerms.FIELD_SEARCHPERMS, 
    index = Index.UN_TOKENIZED)
  @FieldBridge(impl = LongCollectionFieldBridge.class)
  public Set<Long> getSearchPerms() {
    return ISearchPerms.Helper.getDefaultSearchPerms();
  }

}

As you can see, we need some field bridge (LongCollectionFieldBridge) to implement. This is due to Hibernate Search can't map the Set<Long> using default bridges (while Compass has it out-of-the-box; it's unsupported for more than two years from now, though). We map particular Long from collection to the separate lucene field, with the same name:

public class LongCollectionFieldBridge implements FieldBridge, StringBridge {
        
  @Override
  public void set(String name, Object value, Document document, 
      LuceneOptions luceneOptions) {
    Collection<Long> collection = (Collection<Long>) value;
    if (collection == null && luceneOptions.indexNullAs() != null)
      luceneOptions.addFieldToDocument(name, luceneOptions.indexNullAs(), 
        document);
    else if (collection != null)
      for (Long l: collection)
        luceneOptions.addFieldToDocument(name, l+"", document);
  }

  @Override
  public String objectToString(Object object) {
    if (object instanceof Long)
      return ((Long) object).toString();
    return null;
  }

}

And this is how we have all required to perform Lucene query with permissions filtering (below the example using Hibernate Search QueryBuilder):

// query builder
QueryBuilder qb = fullTextSession.getSearchFactory().buildQueryBuilder().
  forEntity(SecuredEntity.class).get();
BooleanJunction bool = qb.bool();

// add query
bool = bool.must(qb.keyword().onField(SomeMyField).
  matching("my keywords to search").createQuery());

// filter perms
BooleanJunction permBool = qb.bool().should( // default perms = no perms, always
  qb.keyword().onField(ISearchPerms.FIELD_SEARCHPERMS).matching(0l).createQuery());
for (Long id: currentUser.getAllActorIds()) // current user perms
  permBool = permBool.should(
    qb.keyword().onField(ISearchPerms.FIELD_SEARCHPERMS).matching(id).
      createQuery());
bool = bool.must(permBool.createQuery());

return bool.createQuery();