def parse(authz_file, modules): """Parse a Subversion authorization file. Return a dict of modules, each containing a dict of paths, each containing a dict mapping users to permissions. Only modules contained in `modules` are retained. """ parser = UnicodeConfigParser(ignorecase_option=False) parser.read(authz_file) groups = {} aliases = {} sections = {} for section in parser.sections(): if section == 'groups': for name, value in parser.items(section): groups.setdefault(name, set()).update(to_list(value)) elif section == 'aliases': for name, value in parser.items(section): aliases[name] = value.strip() else: for name, value in parser.items(section): parts = section.split(':', 1) module, path = parts[0] if len(parts) > 1 else '', parts[-1] if module in modules: sections.setdefault((module, path), []) \ .append((name, value)) def resolve(subject, done): if subject.startswith('@'): done.add(subject) for members in groups[subject[1:]] - done: for each in resolve(members, done): yield each elif subject.startswith('&'): yield aliases[subject[1:]] else: yield subject authz = {} for (module, path), items in sections.iteritems(): section = authz.setdefault(module, {}).setdefault(path, {}) for subject, perms in items: readable = 'r' in perms # Ordering isn't significant; any entry could grant permission section.update((user, readable) for user in resolve(subject, set()) if not section.get(user)) return authz
class UnicodeParserTestCase(unittest.TestCase): def setUp(self): self.tempdir = tempfile.mkdtemp() self.filename = os.path.join(self.tempdir, 'config.ini') _write(self.filename, [ u'[ä]', u'öption = ÿ', u'[ä]', u'optīon = 1.1', u'[č]', u'ôption = ž', u'[č]', u'optïon = 1', u'[ė]', u'optioñ = true', ]) self.parser = UnicodeConfigParser() self._read() def tearDown(self): shutil.rmtree(self.tempdir) def _write(self): with open(self.filename, 'w') as f: self.parser.write(f) def _read(self): self.parser.read(self.filename) def test_sections(self): self.assertEqual([u'ä', u'č', u'ė'], self.parser.sections()) def test_add_section(self): self.parser.add_section(u'ē') self._write() self.assertEqual( u'[ä]\n' u'öption = ÿ\n' u'optīon = 1.1\n\n' u'[č]\n' u'ôption = ž\n' u'optïon = 1\n\n' u'[ė]\n' u'optioñ = true\n\n' u'[ē]\n\n', _read(self.filename)) def test_has_section(self): self.assertTrue(self.parser.has_section(u'ä')) self.assertTrue(self.parser.has_section(u'č')) self.assertTrue(self.parser.has_section(u'ė')) self.assertFalse(self.parser.has_section(u'î')) def test_options(self): self.assertEqual([u'öption', u'optīon'], self.parser.options(u'ä')) self.assertEqual([u'ôption', u'optïon'], self.parser.options(u'č')) def test_get(self): self.assertEqual(u'ÿ', self.parser.get(u'ä', u'öption')) self.assertEqual(u'ž', self.parser.get(u'č', u'ôption')) def test_items(self): self.assertEqual([(u'öption', u'ÿ'), (u'optīon', u'1.1')], self.parser.items(u'ä')) self.assertEqual([(u'ôption', u'ž'), (u'optïon', u'1')], self.parser.items(u'č')) def test_getint(self): self.assertEqual(1, self.parser.getint(u'č', u'optïon')) def test_getfloat(self): self.assertEqual(1.1, self.parser.getfloat(u'ä', u'optīon')) def test_getboolean(self): self.assertTrue(self.parser.getboolean(u'ė', u'optioñ')) def test_has_option(self): self.assertTrue(self.parser.has_option(u'ä', u'öption')) self.assertTrue(self.parser.has_option(u'ä', u'optīon')) self.assertTrue(self.parser.has_option(u'č', u'ôption')) self.assertTrue(self.parser.has_option(u'č', u'optïon')) self.assertTrue(self.parser.has_option(u'ė', u'optioñ')) self.assertFalse(self.parser.has_option(u'î', u'optioñ')) def test_set(self): self.parser.set(u'ä', u'öption', u'ù') self.parser.set(u'ė', u'optiœn', None) self._write() self.assertEqual( u'[ä]\n' u'öption = ù\n' u'optīon = 1.1\n\n' u'[č]\n' u'ôption = ž\n' u'optïon = 1\n\n' u'[ė]\n' u'optioñ = true\n' u'optiœn = \n\n', _read(self.filename)) def test_remove_option(self): self.parser.remove_option(u'ä', u'öption') self.parser.remove_option(u'ė', u'optioñ') self._write() self.assertEqual( u'[ä]\n' u'optīon = 1.1\n\n' u'[č]\n' u'ôption = ž\n' u'optïon = 1\n\n' u'[ė]\n\n', _read(self.filename)) def test_remove_section(self): self.parser.remove_section(u'ä') self.parser.remove_section(u'ė') self._write() self.assertEqual( u'[č]\n' u'ôption = ž\n' u'optïon = 1\n\n', _read(self.filename))
class AuthzPolicy(Component): """Permission policy using an authz-like configuration file. Refer to SVN documentation for syntax of the authz file. Groups are supported. As the fine-grained permissions brought by this permission policy are often used in complement of the other permission policies (like the `DefaultPermissionPolicy`), there's no need to redefine all the permissions here. Only additional rights or restrictions should be added. === Installation === Enabling this policy requires listing it in `trac.ini`:: {{{ [trac] permission_policies = AuthzPolicy, DefaultPermissionPolicy [authz_policy] authz_file = conf/authzpolicy.conf }}} This means that the `AuthzPolicy` permissions will be checked first, and only if no rule is found will the `DefaultPermissionPolicy` be used. === Configuration === The `authzpolicy.conf` file is a `.ini` style configuration file. - Each section of the config is a glob pattern used to match against a Trac resource descriptor. These descriptors are in the form:: {{{ <realm>:<id>@<version>[/<realm>:<id>@<version> ...] }}} Resources are ordered left to right, from parent to child. If any component is inapplicable, `*` is substituted. If the version pattern is not specified explicitely, all versions (`@*`) is added implicitly Example: Match the WikiStart page:: {{{ [wiki:*] [wiki:WikiStart*] [wiki:WikiStart@*] [wiki:WikiStart] }}} Example: Match the attachment ``wiki:WikiStart@117/attachment/FOO.JPG@*`` on WikiStart:: {{{ [wiki:*] [wiki:WikiStart*] [wiki:WikiStart@*] [wiki:WikiStart@*/attachment/*] [wiki:WikiStart@117/attachment/FOO.JPG] }}} - Sections are checked against the current Trac resource '''IN ORDER''' of appearance in the configuration file. '''ORDER IS CRITICAL'''. - Once a section matches, the current username is matched, '''IN ORDER''', against the keys of the section. If a key is prefixed with a `@`, it is treated as a group. If a key is prefixed with a `!`, the permission is denied rather than granted. The username will match any of 'anonymous', 'authenticated', <username> or '*', using normal Trac permission rules. Example configuration:: {{{ [groups] administrators = athomas [*/attachment:*] * = WIKI_VIEW, TICKET_VIEW [wiki:WikiStart@*] @administrators = WIKI_ADMIN anonymous = WIKI_VIEW * = WIKI_VIEW # Deny access to page templates [wiki:PageTemplates/*] * = # Match everything else [*] @administrators = TRAC_ADMIN anonymous = BROWSER_VIEW, CHANGESET_VIEW, FILE_VIEW, LOG_VIEW, MILESTONE_VIEW, POLL_VIEW, REPORT_SQL_VIEW, REPORT_VIEW, ROADMAP_VIEW, SEARCH_VIEW, TICKET_CREATE, TICKET_MODIFY, TICKET_VIEW, TIMELINE_VIEW, WIKI_CREATE, WIKI_MODIFY, WIKI_VIEW # Give authenticated users some extra permissions authenticated = REPO_SEARCH, XML_RPC }}} """ implements(IPermissionPolicy) authz_file = PathOption('authz_policy', 'authz_file', '', "Location of authz policy configuration file. " "Non-absolute paths are relative to the " "Environment `conf` directory.") def __init__(self): self.authz = None self.authz_mtime = None self.groups_by_user = {} # IPermissionPolicy methods def check_permission(self, action, username, resource, perm): if not self.authz_mtime or \ os.path.getmtime(self.authz_file) != self.authz_mtime: self.parse_authz() resource_key = self.normalise_resource(resource) self.log.debug('Checking %s on %s', action, resource_key) permissions = self.authz_permissions(resource_key, username) if permissions is None: return None # no match, can't decide elif permissions == []: return False # all actions are denied # FIXME: expand all permissions once for all ps = PermissionSystem(self.env) for deny, perms in groupby(permissions, key=lambda p: p.startswith('!')): if deny and action in ps.expand_actions(p[1:] for p in perms): return False # action is explicitly denied elif action in ps.expand_actions(perms): return True # action is explicitly granted return None # no match for action, can't decide # Internal methods def parse_authz(self): self.log.debug("Parsing authz security policy %s", self.authz_file) if not self.authz_file: self.log.error("The `[authz_policy] authz_file` configuration " "option in trac.ini is empty or not defined.") raise ConfigurationError() try: self.authz_mtime = os.path.getmtime(self.authz_file) except OSError as e: self.log.error("Error parsing authz permission policy file: %s", exception_to_unicode(e)) raise ConfigurationError() self.authz = UnicodeConfigParser(ignorecase_option=False) try: self.authz.read(self.authz_file) except ParsingError as e: self.log.error("Error parsing authz permission policy file: %s", exception_to_unicode(e)) raise ConfigurationError() groups = {} if self.authz.has_section('groups'): for group, users in self.authz.items('groups'): groups[group] = to_list(users) self.groups_by_user = {} def add_items(group, items): for item in items: if item.startswith('@'): add_items(group, groups[item[1:]]) else: self.groups_by_user.setdefault(item, set()).add(group) for group, users in groups.iteritems(): add_items('@' + group, users) all_actions = set(PermissionSystem(self.env).get_actions()) authz_basename = os.path.basename(self.authz_file) for section in self.authz.sections(): if section == 'groups': continue for _, actions in self.authz.items(section): for action in to_list(actions): if action.startswith('!'): action = action[1:] if action not in all_actions: self.log.warning("The action %s in the [%s] section " "of %s is not a valid action.", action, section, authz_basename) def normalise_resource(self, resource): def to_descriptor(resource): id = resource.id return '%s:%s@%s' % (resource.realm or '*', id if id is not None else '*', resource.version or '*') def flatten(resource): if not resource: return ['*:*@*'] descriptor = to_descriptor(resource) if not resource.realm and resource.id is None: return [descriptor] # XXX Due to the mixed functionality in resource we can end up with # ticket, ticket:1, ticket:1@10. This code naively collapses all # subsets of the parent resource into one. eg. ticket:1@10 parent = resource.parent while parent and resource.realm == parent.realm: parent = parent.parent if parent: return flatten(parent) + [descriptor] else: return [descriptor] return '/'.join(flatten(resource)) def authz_permissions(self, resource_key, username): # TODO: Handle permission negation in sections. eg. "if in this # ticket, remove TICKET_MODIFY" if username and username != 'anonymous': valid_users = ['*', 'authenticated', 'anonymous', username] else: valid_users = ['*', 'anonymous'] for resource_section in [a for a in self.authz.sections() if a != 'groups']: resource_glob = resource_section if '@' not in resource_glob: resource_glob += '@*' if fnmatchcase(resource_key, resource_glob): for who, permissions in self.authz.items(resource_section): permissions = to_list(permissions) if who in valid_users or \ who in self.groups_by_user.get(username, []): self.log.debug("%s matched section %s for user %s", resource_key, resource_glob, username) if isinstance(permissions, basestring): return [permissions] else: return permissions return None
class UnicodeParserTestCase(unittest.TestCase): def setUp(self): self.tempdir = mkdtemp() self.filename = os.path.join(self.tempdir, 'config.ini') _write(self.filename, [ u'[ä]', u'öption = ÿ', u'[ä]', u'optīon = 1.1', u'[č]', u'ôption = ž', u'[č]', u'optïon = 1', u'[ė]', u'optioñ = true', ]) self.parser = UnicodeConfigParser() self._read() def tearDown(self): rmtree(self.tempdir) def _write(self): with open(self.filename, 'w') as f: self.parser.write(f) def _read(self): self.parser.read(self.filename) def test_sections(self): self.assertEqual([u'ä', u'č', u'ė'], self.parser.sections()) def test_add_section(self): self.parser.add_section(u'ē') self._write() self.assertEqual( u'[ä]\n' u'öption = ÿ\n' u'optīon = 1.1\n\n' u'[č]\n' u'ôption = ž\n' u'optïon = 1\n\n' u'[ė]\n' u'optioñ = true\n\n' u'[ē]\n\n', _read(self.filename)) def test_has_section(self): self.assertTrue(self.parser.has_section(u'ä')) self.assertTrue(self.parser.has_section(u'č')) self.assertTrue(self.parser.has_section(u'ė')) self.assertFalse(self.parser.has_section(u'î')) def test_options(self): self.assertEqual([u'öption', u'optīon'], self.parser.options(u'ä')) self.assertEqual([u'ôption', u'optïon'], self.parser.options(u'č')) def test_get(self): self.assertEqual(u'ÿ', self.parser.get(u'ä', u'öption')) self.assertEqual(u'ž', self.parser.get(u'č', u'ôption')) def test_items(self): self.assertEqual([(u'öption', u'ÿ'), (u'optīon', u'1.1')], self.parser.items(u'ä')) self.assertEqual([(u'ôption', u'ž'), (u'optïon', u'1')], self.parser.items(u'č')) def test_getint(self): self.assertEqual(1, self.parser.getint(u'č', u'optïon')) def test_getfloat(self): self.assertEqual(1.1, self.parser.getfloat(u'ä', u'optīon')) def test_getboolean(self): self.assertTrue(self.parser.getboolean(u'ė', u'optioñ')) def test_has_option(self): self.assertTrue(self.parser.has_option(u'ä', u'öption')) self.assertTrue(self.parser.has_option(u'ä', u'optīon')) self.assertTrue(self.parser.has_option(u'č', u'ôption')) self.assertTrue(self.parser.has_option(u'č', u'optïon')) self.assertTrue(self.parser.has_option(u'ė', u'optioñ')) self.assertFalse(self.parser.has_option(u'î', u'optioñ')) def test_set(self): self.parser.set(u'ä', u'öption', u'ù') self.parser.set(u'ė', u'optiœn', None) self._write() self.assertEqual( u'[ä]\n' u'öption = ù\n' u'optīon = 1.1\n\n' u'[č]\n' u'ôption = ž\n' u'optïon = 1\n\n' u'[ė]\n' u'optioñ = true\n' u'optiœn = \n\n', _read(self.filename)) def test_remove_option(self): self.parser.remove_option(u'ä', u'öption') self.parser.remove_option(u'ė', u'optioñ') self._write() self.assertEqual( u'[ä]\n' u'optīon = 1.1\n\n' u'[č]\n' u'ôption = ž\n' u'optïon = 1\n\n' u'[ė]\n\n', _read(self.filename)) def test_remove_section(self): self.parser.remove_section(u'ä') self.parser.remove_section(u'ė') self._write() self.assertEqual( u'[č]\n' u'ôption = ž\n' u'optïon = 1\n\n', _read(self.filename))
class AuthzPolicy(Component): """Permission policy using an authz-like configuration file. Refer to SVN documentation for syntax of the authz file. Groups are supported. As the fine-grained permissions brought by this permission policy are often used in complement of the other permission policies (like the `DefaultPermissionPolicy`), there's no need to redefine all the permissions here. Only additional rights or restrictions should be added. === Installation === Enabling this policy requires listing it in `trac.ini: {{{ [trac] permission_policies = AuthzPolicy, DefaultPermissionPolicy [authz_policy] authz_file = conf/authzpolicy.conf }}} This means that the `AuthzPolicy` permissions will be checked first, and only if no rule is found will the `DefaultPermissionPolicy` be used. === Configuration === The `authzpolicy.conf` file is a `.ini` style configuration file. - Each section of the config is a glob pattern used to match against a Trac resource descriptor. These descriptors are in the form: {{{ <realm>:<id>@<version>[/<realm>:<id>@<version> ...] }}} Resources are ordered left to right, from parent to child. If any component is inapplicable, `*` is substituted. If the version pattern is not specified explicitely, all versions (`@*`) is added implicitly Example: Match the WikiStart page {{{ [wiki:*] [wiki:WikiStart*] [wiki:WikiStart@*] [wiki:WikiStart] }}} Example: Match the attachment `wiki:WikiStart@117/attachment/FOO.JPG@*` on WikiStart {{{ [wiki:*] [wiki:WikiStart*] [wiki:WikiStart@*] [wiki:WikiStart@*/attachment/*] [wiki:WikiStart@117/attachment/FOO.JPG] }}} - Sections are checked against the current Trac resource '''IN ORDER''' of appearance in the configuration file. '''ORDER IS CRITICAL'''. - Once a section matches, the current username is matched, '''IN ORDER''', against the keys of the section. If a key is prefixed with a `@`, it is treated as a group. If a key is prefixed with a `!`, the permission is denied rather than granted. The username will match any of 'anonymous', 'authenticated', <username> or '*', using normal Trac permission rules. Example configuration: {{{ [groups] administrators = athomas [*/attachment:*] * = WIKI_VIEW, TICKET_VIEW [wiki:WikiStart@*] @administrators = WIKI_ADMIN anonymous = WIKI_VIEW * = WIKI_VIEW # Deny access to page templates [wiki:PageTemplates/*] * = # Match everything else [*] @administrators = TRAC_ADMIN anonymous = BROWSER_VIEW, CHANGESET_VIEW, FILE_VIEW, LOG_VIEW, MILESTONE_VIEW, POLL_VIEW, REPORT_SQL_VIEW, REPORT_VIEW, ROADMAP_VIEW, SEARCH_VIEW, TICKET_CREATE, TICKET_MODIFY, TICKET_VIEW, TIMELINE_VIEW, WIKI_CREATE, WIKI_MODIFY, WIKI_VIEW # Give authenticated users some extra permissions authenticated = REPO_SEARCH, XML_RPC }}} """ implements(IPermissionPolicy) authz_file = PathOption( "authz_policy", "authz_file", "", "Location of authz policy configuration file. " "Non-absolute paths are relative to the " "Environment `conf` directory.", ) authz = None authz_mtime = None def __init__(self): if not self.authz_file: self.log.error( "The `[authz_policy] authz_file` configuration " "option in trac.ini is empty or not defined." ) raise ConfigurationError() try: os.stat(self.authz_file) except OSError as e: self.log.error("Error parsing authz permission policy file: %s", to_unicode(e)) raise ConfigurationError() self.groups_by_user = {} # IPermissionPolicy methods def check_permission(self, action, username, resource, perm): if not self.authz_mtime or os.path.getmtime(self.authz_file) != self.authz_mtime: self.parse_authz() resource_key = self.normalise_resource(resource) self.log.debug("Checking %s on %s", action, resource_key) permissions = self.authz_permissions(resource_key, username) if permissions is None: return None # no match, can't decide elif permissions == []: return False # all actions are denied # FIXME: expand all permissions once for all ps = PermissionSystem(self.env) for deny, perms in groupby(permissions, key=lambda p: p.startswith("!")): if deny and action in ps.expand_actions(p[1:] for p in perms): return False # action is explicitly denied elif action in ps.expand_actions(perms): return True # action is explicitly granted return None # no match for action, can't decide # Internal methods def parse_authz(self): self.log.debug("Parsing authz security policy %s", self.authz_file) self.authz = UnicodeConfigParser() try: self.authz.read(self.authz_file) except ParsingError as e: self.log.error("Error parsing authz permission policy file: %s", to_unicode(e)) raise ConfigurationError() groups = {} if self.authz.has_section("groups"): for group, users in self.authz.items("groups"): groups[group] = to_list(users) self.groups_by_user = {} def add_items(group, items): for item in items: if item.startswith("@"): add_items(group, groups[item[1:]]) else: self.groups_by_user.setdefault(item, set()).add(group) for group, users in groups.iteritems(): add_items("@" + group, users) self.authz_mtime = os.path.getmtime(self.authz_file) def normalise_resource(self, resource): def to_descriptor(resource): id = resource.id return "%s:%s@%s" % (resource.realm or "*", id if id is not None else "*", resource.version or "*") def flatten(resource): if not resource: return ["*:*@*"] descriptor = to_descriptor(resource) if not resource.realm and resource.id is None: return [descriptor] # XXX Due to the mixed functionality in resource we can end up with # ticket, ticket:1, ticket:1@10. This code naively collapses all # subsets of the parent resource into one. eg. ticket:1@10 parent = resource.parent while parent and resource.realm == parent.realm: parent = parent.parent if parent: return flatten(parent) + [descriptor] else: return [descriptor] return "/".join(flatten(resource)) def authz_permissions(self, resource_key, username): # TODO: Handle permission negation in sections. eg. "if in this # ticket, remove TICKET_MODIFY" if username and username != "anonymous": valid_users = ["*", "authenticated", username] else: valid_users = ["*", "anonymous"] for resource_section in [a for a in self.authz.sections() if a != "groups"]: resource_glob = resource_section if "@" not in resource_glob: resource_glob += "@*" if fnmatchcase(resource_key, resource_glob): for who, permissions in self.authz.items(resource_section): permissions = to_list(permissions) if who in valid_users or who in self.groups_by_user.get(username, []): self.log.debug("%s matched section %s for user %s", resource_key, resource_glob, username) if isinstance(permissions, basestring): return [permissions] else: return permissions return None