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):