Статьи

Пользовательская фильтрация безопасности в Solr: на основе списков контроля доступа

Йоник недавно писал о «Расширенном кэшировании фильтров в Solr», где говорил о дорогих и пользовательских фильтрах; он был оставлен читателю в качестве упражнения на деталях реализации. В этой статье я приведу конкретный пример пользовательской пост-фильтрации для случая фильтрации документов на основе списков контроля доступа.

Резюме фильтрации и кэширования Solr

Сначала давайте рассмотрим возможности фильтрации и кэширования Solr. Запросы к Solr включают полнотекстовый запрос с оценкой релевантности (печально известный параметр q). По мере навигации пользователи будут просматривать аспекты. Приложение поиска генерирует параметры запроса фильтра (fq) для многогранной навигации (например, fq = color: red, как в статье, упомянутой выше). Запросы фильтра не участвуют в оценке документов и служат только для сокращения пространства поиска. Solr использует кэш фильтров, кэшируя наборы документов каждого уникального запроса фильтра. Эти наборы документов создаются заранее, кэшируются и сокращают количество документов, рассматриваемых основным запросом. Кэширование может быть отключено для каждого фильтра; когда фильтры не кэшируются, они используются параллельно с основным запросом, чтобы «перепрыгнуть» на документы для рассмотрения,и стоимость может быть связана с каждым фильтром для того, чтобы расставить приоритеты в скачкообразной лягушке (наименьший набор вначале сведет к минимуму документы, рассматриваемые на предмет соответствия).

Постфильтрация

Даже без кеширования фильтр устанавливает параметры по умолчанию для генерации заранее В некоторых случаях создание фильтра может быть чрезвычайно дорогим и непозволительным. Одним из примеров этого является фильтрация контроля доступа, которая должна учитывать контекст запросов пользователей, чтобы знать, какие документы разрешено возвращать или нет. В идеале для обеспечения безопасности доступа должны оцениваться только совпадающие документы, документы, соответствующие запросу, и простые фильтры. Бесполезно оценивать любые другие документы, которые в любом случае не соответствовали бы. Итак, давайте рассмотрим пример … надуманный пример, чтобы показать, как работает постфильтрация Solr. Вот дизайн:

  • С документами связан «список контроля доступа», в котором указаны разрешенные и запрещенные пользователи, а также разрешенные и запрещенные группы.
  • Список контроля доступа — это упорядоченный список разрешенных / запрещенных пользователей и групп. Порядок имеет значение, так что первое правило соответствия определяет доступ.
  • Если разрешающий доступ не найден, документ не разрешен.

Например, документ может иметь строку управления доступом, указанную как «+ u: пользователь1 + g: группа1 -g: группа2 + u: пользователь2 -u: пользователь3 ″ . Запросы запросов к Solr будут включать имя пользователя и членство в группе пользователей. Учитывая этот пример строки управления доступом, вот как должен реагировать этот придуманный дизайн:

user='user1', groups=null: allowed
user='user2', groups=null: allowed
user='user1', groups=[group1]: allowed
user='user2', groups=[group2]: NOT ALLOWED
user='user3', groups=[group1]: allowed
user='user3', groups=[group2]: NOT ALLOWED
user='user3', groups=[group1, group2]: allowed

То есть, если пользователь2, как член группы group2, ищет, ему не должно быть разрешено находить этот конкретный документ (-g: group2 предшествует + u: user2 в правилах, и порядок имеет значение). Я знаю, я знаю, это довольно надумано, но не совсем нереально, учитывая некоторую работу с клиентами, которую мы недавно проделали.

Поскольку эти правила зависят от порядка и запроса запроса, выполнить простой запрос Lucene для фильтрации разрешенных документов невозможно. Подыграйте мне в этом примере, я попытался сделать его достаточно сложным, чтобы согласиться с этим. Solr имеет относительно новую возможность PostFilter, которая позволяет эту последнюю проверку фильтрации документов на лету. Требуется некоторое ноу-хау для правильной реализации PostFilter, поэтому приведенный здесь пример кода станет хорошей отправной точкой для вашей собственной пользовательской пост-фильтрации. PostFilter получает возможность использовать Solr QParserPlugin. Вот мой пользовательский AccessControlQParserPlugin:

public class AccessControlQParserPlugin extends QParserPlugin {
  public static String NAME = "acl";

  public void init(NamedList args) {
  }

  @Override
  public QParser createParser(String qstr, SolrParams localParams,
                              SolrParams params, SolrQueryRequest req) {
    return new QParser(qstr, localParams, params, req) {

      @Override
      public Query parse() throws ParseException {
        return new AccessControlQuery(localParams.get("user"), localParams.get("groups"));
      }
    };
  }
}

И затем это подключается к solrconfig.xml следующим образом:

<queryParser name="acl"/>

Все это просто необходимо, чтобы подключить реализацию PostFilter. Вот мой пример реализации:

/**
 * Note that this Query implementation can _only_ be used as an fq, not as a q (it would need to implement createWeight).
 */
class AccessControlQuery extends ExtendedQueryBase implements PostFilter {

  private String user;
  private String[] groups;

  public AccessControlQuery(String user, String groups) {
    this.user = user;
    this.groups = groups.split(",");
  }

  public static boolean isAllowed(String acl, String user, String[] groups) {
    // acl is in the form of a series of whitespace separated [+|-][u|g]:name
    // allowed is determined by any explicit user or group mentions, plus or minus
    // order matters
    // if nothing matches, it is not allowed

    if (user == null && groups == null) return false;

    String[] permissions = acl.split(" ");

    for(String p : permissions) {
      boolean allowed = p.charAt(0) == '+';
      String name = p.substring(3);
      if (p.charAt(1) == 'u') { // user
        if (user != null && user.equals(name)) return allowed;
      } else { // group
        if (groups != null) {
          for (String g : groups) {
             if (g.equals(name)) return allowed;
          }
        }
      }
    }

    return false;
  }

  @Override
  public boolean getCache() {
    return false;  // never cache
  }

  @Override
  public int getCost() {
    return Math.max(super.getCost(), 100);  // never return less than 100 since we only support post filtering
  }

  public DelegatingCollector getFilterCollector(IndexSearcher searcher) {
    return new DelegatingCollector() {
      String[] acls;

      @Override
      public void collect(int doc) throws IOException {
        if (isAllowed(acls[doc], user, groups)) super.collect(doc);
      }

      @Override
      public void setNextReader(IndexReader reader, int docBase) throws IOException {
        acls = FieldCache.DEFAULT.getStrings(reader, "acl");  
        super.setNextReader(reader, docBase);
      }
    };
  }

  // NOTE: it is very important to implement proper equals and hashCode methods for this class, as it is used with
  // *result* caching (not filter caching, which is explicitly disabled here).

  @Override
  public String toString() {
    return "AccessControlQuery{" +
        "user='" + user + '\'' +
        ", groups=" + (groups == null ? null : Arrays.asList(groups)) +
        '}';
  }

  @Override
  public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    if (!super.equals(o)) return false;

    AccessControlQuery that = (AccessControlQuery) o;

    if (!Arrays.equals(groups, that.groups)) return false;
    if (user != null ? !user.equals(that.user) : that.user != null) return false;

    return true;
  }

  @Override
  public int hashCode() {
    int result = super.hashCode();
    result = 31 * result + (user != null ? user.hashCode() : 0);
    result = 31 * result + (groups != null ? Arrays.hashCode(groups) : 0);
    return result;
  }

  public static void main(String[] args) {
    String acl = "+u:user1 +g:group1 -g:group2 +u:user2 -u:user3";

    System.out.println("acl = " + acl);

    test(acl, "user1", null);
    test(acl, "user2", null);
    test(acl, "user1", new String[] {"group1"});
    test(acl, "user2", new String[] {"group2"});
    test(acl, "user3", new String[] {"group1"});
    test(acl, "user3", new String[] {"group2"});
    test(acl, "user3", new String[] {"group1","group2"});
  }

  private static void test(String acl, String user, String[] groups) {
    System.out.println("user='" + user + '\'' +
        ", groups=" + (groups == null ? null : Arrays.asList(groups)) +
        ": " + (isAllowed(acl, user, groups) ? "allowed" : "NOT ALLOWED"));
  }
}

Метод main () использовался для генерации вышеуказанных результатов обработки правил. Несколько замечаний, которые следует подчеркнуть из этого кода:

  • Эта реализация может использоваться только как параметр запроса фильтра (fq), а не как параметр aq.
  • hashCode / equals очень важны для получения правильных результатов, в противном случае могут возникнуть неожиданные / неправильные результаты.
  • Кэширование явно отключено, поэтому нет необходимости устанавливать cache = false.
  • Solr имеет логику, которая срабатывает только в PostFilter, когда стоимость> = 100 , поэтому метод getCost такой, какой он есть.
  • Пользовательская логика фильтрации находится внутри единственного метода isAllowed ().
  • Этот пример был построен с использованием кодовой базы Lucene / Solr 3.x. Некоторые небольшие изменения необходимы для настройки кода 4.x.

В этой реализации правила контроля доступа полностью указываются для каждого документа в поле acl . Чтобы эффективно фильтровать по этим правилам во время запроса, используется FieldCache Lucene. При построении структуры данных FieldCache требуются предварительные затраты времени и оперативной памяти, что ускоряет доступ к ним во время запроса; когда используется FieldCache (сортировка, некоторые реализации огранки, запросы функций и этот пользовательский анализатор запросов), целесообразно помещать в соответствующие запросы разогрева, чтобы записи FieldCache создавались во время фиксации, а не для конечных пользователей, ожидающих дольше во время запроса.

Итак, со всей этой реализацией позади, вот как мы наконец ее используем: индексировать некоторые документы, создавать запросы, которые фильтруются с использованием анализатора запросов «acl». Вот документы в формате CSV:

id,acl
1,+u:bob
2,-g:sales +g:engineering
3,+g:hr -g:engineering
4,-u:alice +g:hr
5,+g:hr -u:alice
6,+g:sales +g:engineering -u:bob
7,+g:hr -u:alice +g:sales
8,+g:sales
9,+g:engineering
10,+g:hr

Это было проиндексировано на примере Solr post.jar:

java -Dtype=text/csv -Durl=http://localhost:8983/solr/update/csv -jar post.jar example_docs.csv

где поле acl определено как <field name = ”acl” type = ”string” indexed = ”true” сохранено = ”true” multiValued = ”false” />.

Чтобы упростить представление, в каталог conf / speed был добавлен быстрый и грязный шаблон Velocity, ids.vm:

Matching ids:
#if($page.results_found > 0)
  #foreach($doc in $response.results)
    $doc.id
  #end
#else
  None
#end

И, наконец, давайте посмотрим на результаты, используя базовый запрос http: // localhost: 8983 / solr / select? Q = *: * & wt = speed & v.template = ids, который сам по себе выдает «Соответствующие идентификаторы: 1 2 3 4 5 6 7 8 9 10 ″. При добавлении параметра fq с использованием синтаксиса & fq = {! Acl user = ‘username’ groups = ‘group1, group2 ′} применяется фильтр безопасности. Вот несколько вариантов пользователей и групп, а также результаты:

&fq={!acl user=’alice‘ groups=”}: Matching ids: None

&fq={!acl user=’bob‘ groups=”}: Matching ids: 1

&fq={!acl user=’alice‘ groups=’hr‘}: Matching ids: 3 5 7 10

&fq={!acl user=’alice‘ groups=’hr,sales‘}: Matching ids: 3 5 6 7 8 10

&fq={!acl user=’alice‘ groups=’hr,sales,engineering‘}: Matching ids: 3 5 6 7 8 9 10

&fq={!acl user=’bob‘ groups=’hr‘}: Matching ids: 1 3 4 5 7 10

 

Хорошая печать

It’s important to note that PostFilter is a last resort for implementing document filtering.  Don’t make the solution more complicated than it needs to be.  More often than not, even access control filtering can be implemented using plain ol’ search techniques, by indexing allowed users and groups onto documents and using the lucene (or another) query parser to do the trick.  Only when the rules are too complicated, or external information is needed, does a custom PostFilter make sense.   Performance is key here, and the internal #collect() method will be called for every matching document; a *:* query was used in this example causing every document in the index to be post-filter evaluated and this may be prohibitive on a large index, and as such your application may need to require a narrowing query or another filter constraint involved before kicking in a PostFilter.  What happens in #collect needs to be highly optimized.  DO NOT, I repeat, DO NOT fetch the document from the Lucene index in that method (I won’t even mention what methods to steer clear of)!  If you need to get at field data, have it on single-valued indexed fields and use the FieldCache (and eventually the 4.x doc-values feature will be handy here as well).  And if you use FieldCache, add a representative query using your PostFilter to your warming queries in solrconfig.xml.