Author: astaric
Date: Mon Jun 17 11:57:40 2013
New Revision: 1493719

URL: http://svn.apache.org/r1493719
Log:
Implemented support for FineGrainedPermissions in bhsearch.

If AuthzPolicy is enabled, aditional filters are applied to
search query that filter out the results that are not 
viewable by the user.

Refs: #559 

Modified:
    bloodhound/trunk/bloodhound_search/bhsearch/api.py
    bloodhound/trunk/bloodhound_search/bhsearch/security.py
    bloodhound/trunk/bloodhound_search/bhsearch/tests/security.py
    bloodhound/trunk/bloodhound_search/bhsearch/utils.py
    bloodhound/trunk/bloodhound_search/bhsearch/whoosh_backend.py

Modified: bloodhound/trunk/bloodhound_search/bhsearch/api.py
URL: 
http://svn.apache.org/viewvc/bloodhound/trunk/bloodhound_search/bhsearch/api.py?rev=1493719&r1=1493718&r2=1493719&view=diff
==============================================================================
--- bloodhound/trunk/bloodhound_search/bhsearch/api.py (original)
+++ bloodhound/trunk/bloodhound_search/bhsearch/api.py Mon Jun 17 11:57:40 2013
@@ -38,7 +38,7 @@ class IndexFields(object):
     CONTENT = 'content'
     STATUS = 'status'
     PRODUCT = 'product'
-    SECURITY = 'security'
+    REQUIRED_PERMISSION = 'required_permission'
     NAME = 'name'
 
 class QueryResult(object):

Modified: bloodhound/trunk/bloodhound_search/bhsearch/security.py
URL: 
http://svn.apache.org/viewvc/bloodhound/trunk/bloodhound_search/bhsearch/security.py?rev=1493719&r1=1493718&r2=1493719&view=diff
==============================================================================
--- bloodhound/trunk/bloodhound_search/bhsearch/security.py (original)
+++ bloodhound/trunk/bloodhound_search/bhsearch/security.py Mon Jun 17 11:57:40 
2013
@@ -17,20 +17,22 @@
 #  KIND, either express or implied.  See the License for the
 #  specific language governing permissions and limitations
 #  under the License.
+from itertools import groupby
+import os
 
 from trac.core import Component, implements, ExtensionPoint
 from trac.perm import PermissionSystem
+from tracopt.perm.authz_policy import AuthzPolicy
 from whoosh import query
 
+from multiproduct.env import ProductEnvironment
+
 from bhsearch.api import (IDocIndexPreprocessor, IndexFields,
                           IQueryPreprocessor, ISearchParticipant)
-from bhsearch.utils import get_product, instance_for_every_env
-from multiproduct.env import ProductEnvironment
+from bhsearch.utils import get_product, instance_for_every_env, is_enabled
 
 
 class SecurityPreprocessor(Component):
-    implements(IDocIndexPreprocessor, IQueryPreprocessor)
-
     participants = ExtensionPoint(ISearchParticipant)
 
     def __init__(self):
@@ -40,33 +42,67 @@ class SecurityPreprocessor(Component):
             doc_type = participant.get_participant_type()
             self._required_permissions[doc_type] = permission
 
+    def check_permission(self, doc, context):
+        product, doctype, id = doc['product'], doc['type'], doc['id']
+        username = context.req.authname
+        env = self.env
+        if product:
+            env = ProductEnvironment(self.env, product)
+        perm = PermissionSystem(env)
+        action = self._required_permissions[doctype]
+        return perm.check_permission(action, username, id)
+
+    def update_security_filter(self, query_parameters, allowed=(), denied=()):
+        security_filter = self.create_security_filter(query_parameters)
+        security_filter.allowed.extend(allowed)
+        security_filter.denied.extend(denied)
+
+    def create_security_filter(self, query_parameters):
+        security_filter = self.find_security_filter(query_parameters['filter'])
+        if not security_filter:
+            security_filter = SecurityFilter()
+            if query_parameters['filter']:
+                query_parameters['filter'] = 
query.And([query_parameters['filter'],
+                                                        security_filter])
+            else:
+                query_parameters['filter'] = security_filter
+        return security_filter
+
+    def find_security_filter(self, existing_query):
+        queue = [existing_query]
+        while queue:
+            token = queue.pop(0)
+            if isinstance(token, SecurityFilter):
+                return token
+            if isinstance(token, query.CompoundQuery):
+                queue.extend(token.subqueries)
+
+
+class DefaultSecurityPreprocessor(SecurityPreprocessor):
+    implements(IDocIndexPreprocessor, IQueryPreprocessor)
+
     # IDocIndexPreprocessor methods
     def pre_process(self, doc):
         permission = self._required_permissions[doc[IndexFields.TYPE]]
-        if doc.get(IndexFields.PRODUCT, ''):
-            doc[IndexFields.SECURITY] = '%s/%s' % (doc[IndexFields.PRODUCT],
-                                                   permission)
-        else:
-            doc[IndexFields.SECURITY] = permission
+        doc[IndexFields.REQUIRED_PERMISSION] = permission
 
     # IQueryPreprocessor methods
     def query_pre_process(self, query_parameters, context=None):
         if context is None:
             return
 
-        permissions = self._get_all_user_permissions(context)
-        #todo: add special case handling for trac_admin and product_owner
-        if permissions:
-            security_filter = query.Or([query.Term('security', perm)
-                                        for perm in permissions])
-        else:
-            security_filter = query.NullQuery
-        if query_parameters['filter'] is None:
-            query_parameters['filter'] = security_filter
-        else:
-            original_filters = query_parameters['filter']
-            query_parameters['filter'] = query.And(
-                [original_filters, security_filter])
+        def allowed_documents():
+            #todo: add special case handling for trac_admin and product_owner
+            for product, perm in self._get_all_user_permissions(context):
+                if product:
+                    prod_term = query.Term(IndexFields.PRODUCT, product)
+                else:
+                    prod_term = query.Not(query.Every(IndexFields.PRODUCT))
+                perm_term = query.Term(IndexFields.REQUIRED_PERMISSION, perm)
+                yield query.And([prod_term, perm_term])
+
+        self.update_security_filter(query_parameters,
+                                    allowed=allowed_documents())
 
     def _get_all_user_permissions(self, context):
         username = context.req.authname
@@ -75,17 +111,137 @@ class SecurityPreprocessor(Component):
             prefix = get_product(perm.env).prefix
             for action in self._required_permissions.itervalues():
                 if perm.check_permission(action, username):
-                    permissions.append(
-                        '%s/%s' % (prefix, action) if prefix else action
-                    )
+                    permissions.append((prefix, action))
         return permissions
 
-    def check_permission(self, doc, context):
-        product, doctype, id = doc['product'], doc['type'], doc['id']
-        username = context.req.authname
-        env = self.env
-        if product:
-            env = ProductEnvironment(self.env, product)
-        perm = PermissionSystem(env)
-        action = self._required_permissions[doctype]
-        return perm.check_permission(action, username, id)
+
+class AuthzSecurityPreprocessor(SecurityPreprocessor):
+    implements(IQueryPreprocessor)
+
+    def __init__(self):
+        SecurityPreprocessor.__init__(self)
+        ps = PermissionSystem(self.env)
+        self.enabled = (is_enabled(self.env, AuthzPolicy)
+                        and any(isinstance(policy, AuthzPolicy)
+                                for policy in ps.policies))
+
+    # IQueryPreprocessor methods
+    def query_pre_process(self, query_parameters, context=None):
+        if not self.enabled:
+            return
+
+        permissions = self.get_user_permissions(context.req.authname)
+        allowed_docs, denied_docs = [], []
+        for product, doc_type, doc_id, perm, denied in permissions:
+            term_spec = []
+            if product:
+                term_spec.append(query.Term(IndexFields.PRODUCT, product))
+            else:
+                term_spec.append(query.Not(query.Every(IndexFields.PRODUCT)))
+
+            if doc_type != '*':
+                term_spec.append(query.Term(IndexFields.TYPE, doc_type))
+            if doc_id != '*':
+                term_spec.append(query.Term(IndexFields.ID, doc_id))
+            term_spec.append(query.Term(IndexFields.REQUIRED_PERMISSION, perm))
+            term_spec = query.And(term_spec)
+            if denied:
+                denied_docs.append(term_spec)
+            else:
+                allowed_docs.append(term_spec)
+        self.update_security_filter(query_parameters, allowed_docs, 
denied_docs)
+
+    def get_user_permissions(self, username):
+        for policy in instance_for_every_env(self.env, AuthzPolicy):
+            product = get_product(policy.env).prefix
+            self.refresh_config(policy)
+
+            for doc_type, doc_id, perm, denied in 
self.get_relevant_permissions(policy, username):
+                yield product, doc_type, doc_id, perm, denied
+
+    def get_relevant_permissions(self, policy, username):
+        ps = PermissionSystem(self.env)
+        relevant_permissions = set(self._required_permissions.itervalues())
+        user_permissions = self.get_all_user_permissions(policy, username)
+        for doc_type, doc_id, permissions in user_permissions:
+            for deny, perms in groupby(permissions,
+                                       key=lambda p: p.startswith('!')):
+                if deny:
+                    for p in ps.expand_actions([p[1:] for p in perms]):
+                        if p in relevant_permissions:
+                            yield doc_type, doc_id, p, True
+                else:
+                    for p in ps.expand_actions(perms):
+                        if p in relevant_permissions:
+                            yield doc_type, doc_id, p, False
+
+    def get_all_user_permissions(self, policy, username):
+        relevant_users = self.get_relevant_users(username)
+        for doc_type, doc_id, section in self.get_all_permissions(policy):
+            for who, permissions in section.iteritems():
+                if who in relevant_users or \
+                        who in policy.groups_by_user.get(username, []):
+                    if isinstance(permissions, basestring):
+                        permissions = [permissions]
+                    yield doc_type, doc_id, permissions
+
+    def get_all_permissions(self, policy):
+        for section_name in policy.authz.sections:
+            if section_name == 'groups':
+                continue
+            if '/' in section_name:
+                continue  # advanced permissions are not supported at the 
moment
+
+            type_id = section_name.split('@', 1)[0]
+            if ':' in type_id:
+                doc_type, doc_id = type_id.split(':')
+            else:
+                doc_type, doc_id = '**'
+            yield doc_type, doc_id, policy.authz[section_name]
+
+    def get_relevant_users(self, username):
+        if username and username != 'anonymous':
+            return ['*', 'authenticated', username]
+        else:
+            return ['*', 'anonymous']
+
+    def refresh_config(self, policy):
+        if (
+            policy.authz_file and not policy.authz_mtime
+            or os.path.getmtime(policy.get_authz_file()) > policy.authz_mtime
+        ):
+            policy.parse_authz()
+
+
+class SecurityFilter(query.AndNot):
+    def __init__(self, allowed=(), denied=()):
+        self.allowed = list(allowed)
+        self.denied = list(denied)
+        super(SecurityFilter, self).__init__(self.allowed, self.denied)
+
+    _subqueries = ()
+    @property
+    def subqueries(self):
+        self.finalize()
+        return self._subqueries
+
+    @subqueries.setter
+    def subqueries(self, value):
+        pass
+
+    def finalize(self):
+        self._subqueries = []
+        if self.allowed:
+            self.a = query.Or(self.allowed)
+        else:
+            self.a = query.NullQuery
+        if self.denied:
+            self.b = query.Or(self.denied)
+        else:
+            self.b = query.NullQuery
+        self._subqueries = (self.a, self.b)
+
+    def __repr__(self):
+        r = "%s(allow=%r, deny=%r)" % (self.__class__.__name__,
+                                       self.a, self.b)
+        return r

Modified: bloodhound/trunk/bloodhound_search/bhsearch/tests/security.py
URL: 
http://svn.apache.org/viewvc/bloodhound/trunk/bloodhound_search/bhsearch/tests/security.py?rev=1493719&r1=1493718&r2=1493719&view=diff
==============================================================================
--- bloodhound/trunk/bloodhound_search/bhsearch/tests/security.py (original)
+++ bloodhound/trunk/bloodhound_search/bhsearch/tests/security.py Mon Jun 17 
11:57:40 2013
@@ -23,6 +23,7 @@ This module contains tests of search sec
 system backend.
 """
 import contextlib
+import os
 import unittest
 from sqlite3 import OperationalError
 
@@ -40,11 +41,10 @@ from trac.wiki import web_ui
 from bhsearch import security
 
 
-
-class MultiProductSecurityTestSuite(BaseBloodhoundSearchTest):
-    def setUp(self):
-        super(MultiProductSecurityTestSuite, self).setUp(
-            enabled=['trac.*', 'trac.wiki.*', 'bhsearch.*', 'multiproduct.*'],
+class SecurityTest(BaseBloodhoundSearchTest):
+    def setUp(self, enabled=[]):
+        super(SecurityTest, self).setUp(
+            enabled=enabled + ['trac.*', 'trac.wiki.*', 'bhsearch.*', 
'multiproduct.*'],
             create_req=True,
             enable_security=True,
         )
@@ -59,6 +59,51 @@ class MultiProductSecurityTestSuite(Base
         self.search_api = BloodhoundSearchApi(self.env)
         self._add_products('p1', 'p2')
 
+    def _setup_multiproduct(self):
+        try:
+            MultiProductSystem(self.env)\
+                .upgrade_environment(self.env.db_transaction)
+        except OperationalError:
+            # table remains but content is deleted
+            self._add_products('@')
+        self.env.enable_multiproduct_schema()
+
+    def _disable_trac_caches(self):
+        DefaultPermissionPolicy.CACHE_EXPIRY = 0
+        self._clear_permission_caches()
+
+    def _create_whoosh_index(self):
+        WhooshBackend(self.env).recreate_index()
+
+    def _add_products(self, *products, **kwargs):
+        owner = kwargs.pop('owner', '')
+        with self.env.db_direct_transaction as db:
+            for product in products:
+                db("INSERT INTO bloodhound_product (prefix, owner) "
+                   " VALUES ('%s', '%s')" % (product, owner))
+                product = ProductEnvironment(self.env, product)
+                self.product_envs.append(product)
+
+    @contextlib.contextmanager
+    def product(self, prefix=''):
+        global_env = self.env
+        self.env = ProductEnvironment(global_env, prefix)
+        yield
+        self.env = global_env
+
+    def _add_permission(self, username='', permission='', product=''):
+        with self.env.db_direct_transaction as db:
+            db("INSERT INTO permission (username, action, product)"
+               "VALUES ('%s', '%s', '%s')" %
+               (username, permission, product))
+        self._clear_permission_caches()
+
+    def _clear_permission_caches(self):
+        for env in [self.env] + self.product_envs:
+            del PermissionSystem(env).store._all_permissions
+
+
+class MultiProductSecurityTestSuite(SecurityTest):
     def test_applies_security(self):
         self.insert_ticket('ticket 1')
 
@@ -210,48 +255,131 @@ class MultiProductSecurityTestSuite(Base
 
         self.assertEqual(results.hits, 1)
 
-    def _setup_multiproduct(self):
-        try:
-            MultiProductSystem(self.env)\
-                .upgrade_environment(self.env.db_transaction)
-        except OperationalError:
-            # table remains but content is deleted
-            self._add_products('@')
-        self.env.enable_multiproduct_schema()
 
-    def _disable_trac_caches(self):
-        DefaultPermissionPolicy.CACHE_EXPIRY = 0
-        self._clear_permission_caches()
+class AuthzSecurityTestCase(SecurityTest):
+    def setUp(self, enabled=()):
+        SecurityTest.setUp(self, enabled=['tracopt.perm.authz_policy.*'])
+        self.authz_config = os.path.join(self.env.path, 'authz.conf')
+        self.env.config['authz_policy'].set('authz_file', self.authz_config)
+        self.env.config['trac'].set('permission_policies',
+                                    'AuthzPolicy,DefaultPermissionPolicy,'
+                                    'LegacyAttachmentPolicy')
 
-    def _create_whoosh_index(self):
-        WhooshBackend(self.env).recreate_index()
+        # Create some dummy objects
+        self.insert_ticket('ticket 1')
+        self.insert_wiki('page 1', 'content')
+        with self.product('p1'):
+            self.insert_ticket('ticket 2')
+            self.insert_wiki('page 1', 'content')
 
-    def _add_products(self, *products, **kwargs):
-        owner = kwargs.pop('owner', '')
-        with self.env.db_direct_transaction as db:
-            for product in products:
-                db("INSERT INTO bloodhound_product (prefix, owner) "
-                   " VALUES ('%s', '%s')" % (product, owner))
-                product = ProductEnvironment(self.env, product)
-                self.product_envs.append(product)
+    def test_authz_permissions(self):
+        self._add_permission('x', 'WIKI_VIEW')
+        self.write_authz_config('\n'.join([
+            '[*]',
+            '* = TICKET_VIEW, !WIKI_VIEW',
+        ]))
 
-    @contextlib.contextmanager
-    def product(self, prefix=''):
-        global_env = self.env
-        self.env = ProductEnvironment(global_env, prefix)
-        yield
-        self.env = global_env
+        results = self.search_api.query("type:ticket", context=self.context)
+        self.assertEqual(results.hits, 2)
+        results = self.search_api.query("type:wiki", context=self.context)
+        self.assertEqual(results.hits, 0)
 
-    def _add_permission(self, username='', permission='', product=''):
-        with self.env.db_direct_transaction as db:
-            db("INSERT INTO permission (username, action, product)"
-               "VALUES ('%s', '%s', '%s')" %
-               (username, permission, product))
-        self._clear_permission_caches()
+    def test_granular_permissions(self):
+        self.write_authz_config("""
+            [ticket:1]
+            * = TICKET_VIEW
+        """)
 
-    def _clear_permission_caches(self):
-        for env in [self.env] + self.product_envs:
-            del PermissionSystem(env).store._all_permissions
+        results = self.search_api.query("type:ticket", context=self.context)
+        self.assertEqual(results.hits, 1)
+        self.assertEqual(results.docs[0]['id'], u'1')
+
+    def test_deny_overrides_default_permissions(self):
+        self._add_permission('x', 'TICKET_VIEW')
+        self.write_authz_config("""
+            [*]
+            x = !TICKET_VIEW
+        """)
+
+        results = self.search_api.query("type:ticket", context=self.context)
+        self.assertEqual(results.hits, 0)
+
+    def test_includes_wildcard_rows_for_registred_users(self):
+        self.write_authz_config("""
+            [*]
+            * = TICKET_VIEW
+            [ticket:1]
+            * = !TICKET_VIEW
+        """)
+
+        results = self.search_api.query("type:ticket", context=self.context)
+        self.assertEqual(results.hits, 1)
+
+
+    def test_includes_wildcard_rows_for_anonymous_users(self):
+        self.req.authname='anonymous'
+        self.write_authz_config("""
+            [*]
+            * = TICKET_VIEW
+            [ticket:1]
+            * = !TICKET_VIEW
+        """)
+
+        results = self.search_api.query("type:ticket", context=self.context)
+        self.assertEqual(results.hits, 1)
+
+    def test_includes_authenticated_rows_for_registred_users(self):
+        self.write_authz_config("""
+            [*]
+            * = TICKET_VIEW
+            [ticket:1]
+            authenticated = !TICKET_VIEW
+        """)
+
+        results = self.search_api.query("type:ticket", context=self.context)
+        self.assertEqual(results.hits, 1)
+
+    def test_includes_named_rows_for_registred_users(self):
+        self.write_authz_config("""
+            [*]
+            * = TICKET_VIEW
+            [ticket:1]
+            x = !TICKET_VIEW
+        """)
+
+        results = self.search_api.query("type:ticket", context=self.context)
+        self.assertEqual(results.hits, 1)
+
+    def test_includes_named_rows_for_anonymous_users(self):
+        self.req.authname = 'anonymous'
+        self.write_authz_config("""
+            [*]
+            * = TICKET_VIEW
+            [ticket:1]
+            anonymous = !TICKET_VIEW
+        """)
+
+        results = self.search_api.query("type:ticket", context=self.context)
+        self.assertEqual(results.hits, 1)
+
+    def test_understands_groups(self):
+        self.write_authz_config("""
+            [groups]
+            admins = x
+
+            [*]
+            @admins = TICKET_VIEW
+
+            [ticket:1]
+            * = !TRAC_ADMIN
+        """)
+
+        results = self.search_api.query("type:ticket", context=self.context)
+        self.assertEqual(results.hits, 1)
+
+    def write_authz_config(self, content):
+        with open(self.authz_config, 'w') as authz_config:
+            authz_config.write(content)
 
 
 def suite():

Modified: bloodhound/trunk/bloodhound_search/bhsearch/utils.py
URL: 
http://svn.apache.org/viewvc/bloodhound/trunk/bloodhound_search/bhsearch/utils.py?rev=1493719&r1=1493718&r2=1493719&view=diff
==============================================================================
--- bloodhound/trunk/bloodhound_search/bhsearch/utils.py (original)
+++ bloodhound/trunk/bloodhound_search/bhsearch/utils.py Mon Jun 17 11:57:40 
2013
@@ -50,3 +50,18 @@ def instance_for_every_env(env, cls):
         global_env = get_global_env(env)
         return [cls(global_env)] + \
                [cls(env) for env in global_env.all_product_envs()]
+
+
+# Compatibility code for `ComponentManager.is_enabled`
+# (available since Trac 0.12)
+def is_enabled(env, cls):
+    """Return whether the given component class is enabled.
+
+    For Trac 0.11 the missing algorithm is included as fallback.
+    """
+    try:
+        return env.is_enabled(cls)
+    except AttributeError:
+        if cls not in env.enabled:
+            env.enabled[cls] = env.is_component_enabled(cls)
+        return env.enabled[cls]

Modified: bloodhound/trunk/bloodhound_search/bhsearch/whoosh_backend.py
URL: 
http://svn.apache.org/viewvc/bloodhound/trunk/bloodhound_search/bhsearch/whoosh_backend.py?rev=1493719&r1=1493718&r2=1493719&view=diff
==============================================================================
--- bloodhound/trunk/bloodhound_search/bhsearch/whoosh_backend.py (original)
+++ bloodhound/trunk/bloodhound_search/bhsearch/whoosh_backend.py Mon Jun 17 
11:57:40 2013
@@ -109,7 +109,7 @@ class WhooshBackend(Component):
                       analyzer=analysis.SimpleAnalyzer()),
         message=TEXT(stored=True,
                      analyzer=analysis.SimpleAnalyzer()),
-        security=ID(),
+        required_permission=ID(),
         name=TEXT(stored=True,
                   analyzer=analysis.SimpleAnalyzer()),
         query_suggestion_basket=TEXT(analyzer=analysis.SimpleAnalyzer(),
@@ -545,7 +545,7 @@ class WhooshEmptyFacetErrorWorkaround(Co
         if isinstance(filter_condition, whoosh.query.CompoundQuery):
             sub_queries = list(filter_condition.subqueries)
             for i, subquery in enumerate(sub_queries):
-                term_to_replace =  self._find_and_fix_condition(subquery)
+                term_to_replace = self._find_and_fix_condition(subquery)
                 if term_to_replace:
                     filter_condition.subqueries[i] = term_to_replace
         elif isinstance(filter_condition, whoosh.query.Not):


Reply via email to