1# -*- coding: utf-8 -*- 2# 3# Copyright (C) 2004-2021 Edgewall Software 4# Copyright (C) 2004 Francois Harvey <fharvey@securiweb.net> 5# Copyright (C) 2005 Matthew Good <trac@matt-good.net> 6# All rights reserved. 7# 8# This software is licensed as described in the file COPYING, which 9# you should have received as part of this distribution. The terms 10# are also available at https://trac.edgewall.org/wiki/TracLicense. 11# 12# This software consists of voluntary contributions made by many 13# individuals. For the exact contribution history, see the revision 14# history and logs, available at https://trac.edgewall.org/log/. 15# 16# Author: Francois Harvey <fharvey@securiweb.net> 17# Matthew Good <trac@matt-good.net> 18 19import os.path 20 21from trac.config import ConfigurationError, Option, ParsingError, \ 22 PathOption, UnicodeConfigParser 23from trac.core import Component, TracError, implements 24from trac.perm import IPermissionPolicy 25from trac.util import pathjoin, to_list 26from trac.util.text import exception_to_unicode 27from trac.versioncontrol.api import RepositoryManager 28 29 30def parent_iter(path): 31 while 1: 32 yield path 33 if path == '/': 34 return 35 path = path[:-1] 36 yield path 37 idx = path.rfind('/') 38 path = path[:idx + 1] 39 40 41def parse(authz_file, modules): 42 """Parse a Subversion authorization file. 43 44 Return a dict of modules, each containing a dict of paths, each containing 45 a dict mapping users to permissions. Only modules contained in `modules` 46 are retained. 47 """ 48 parser = UnicodeConfigParser(ignorecase_option=False) 49 parser.read(authz_file) 50 51 groups = {} 52 aliases = {} 53 sections = {} 54 for section in parser.sections(): 55 if section == 'groups': 56 for name, value in parser.items(section): 57 groups.setdefault(name, set()).update(to_list(value)) 58 elif section == 'aliases': 59 for name, value in parser.items(section): 60 aliases[name] = value.strip() 61 else: 62 for name, value in parser.items(section): 63 parts = section.split(':', 1) 64 module, path = parts[0] if len(parts) > 1 else '', parts[-1] 65 if module in modules: 66 sections.setdefault((module, path), []) \ 67 .append((name, value)) 68 69 def resolve(subject, done): 70 if subject.startswith('@'): 71 done.add(subject) 72 for members in groups[subject[1:]] - done: 73 for each in resolve(members, done): 74 yield each 75 elif subject.startswith('&'): 76 yield aliases[subject[1:]] 77 else: 78 yield subject 79 80 authz = {} 81 for (module, path), items in sections.items(): 82 section = authz.setdefault(module, {}).setdefault(path, {}) 83 for subject, perms in items: 84 readable = 'r' in perms 85 # Ordering isn't significant; any entry could grant permission 86 section.update((user, readable) 87 for user in resolve(subject, set()) 88 if not section.get(user)) 89 return authz 90 91 92class AuthzSourcePolicy(Component): 93 """Permission policy for `source:` and `changeset:` resources using a 94 Subversion authz file. 95 96 `FILE_VIEW` and `BROWSER_VIEW` permissions are granted as specified in the 97 authz file. 98 99 `CHANGESET_VIEW` permission is granted for changesets where `FILE_VIEW` is 100 granted on at least one modified file, as well as for empty changesets. 101 """ 102 103 implements(IPermissionPolicy) 104 105 authz_file = PathOption('svn', 'authz_file', '', 106 """The path to the Subversion 107 [%(svnbook)s authorization (authz) file]. 108 To enable authz permission checking, the `AuthzSourcePolicy` 109 permission policy must be added to `[trac] permission_policies`. 110 Non-absolute paths are relative to the Environment `conf` 111 directory. 112 """, 113 doc_args={'svnbook': 'http://svnbook.red-bean.com/en/1.7/' 114 'svn.serverconfig.pathbasedauthz.html'}) 115 116 authz_module_name = Option('svn', 'authz_module_name', '', 117 """The module prefix used in the `authz_file` for the default 118 repository. If left empty, the global section is used. 119 """) 120 121 _handled_perms = frozenset([(None, 'BROWSER_VIEW'), 122 (None, 'CHANGESET_VIEW'), 123 (None, 'FILE_VIEW'), 124 (None, 'LOG_VIEW'), 125 ('source', 'BROWSER_VIEW'), 126 ('source', 'FILE_VIEW'), 127 ('source', 'LOG_VIEW'), 128 ('changeset', 'CHANGESET_VIEW')]) 129 130 def __init__(self): 131 self._mtime = 0 132 self._authz = {} 133 self._users = set() 134 135 # IPermissionPolicy methods 136 137 def check_permission(self, action, username, resource, perm): 138 realm = resource.realm if resource else None 139 if (realm, action) in self._handled_perms: 140 authz, users = self._get_authz_info() 141 if authz is None: 142 return False 143 144 if username == 'anonymous': 145 usernames = '$anonymous', '*' 146 else: 147 usernames = username, '$authenticated', '*' 148 if resource is None: 149 return True if users & set(usernames) else None 150 151 rm = RepositoryManager(self.env) 152 try: 153 repos = rm.get_repository(resource.parent.id) 154 except TracError: 155 return True # Allow error to be displayed in the repo index 156 if repos is None: 157 return True 158 modules = [resource.parent.id or self.authz_module_name] 159 if modules[0]: 160 modules.append('') 161 162 def check_path_0(spath): 163 sections = [authz.get(module, {}).get(spath) 164 for module in modules] 165 sections = [section for section in sections if section] 166 denied = False 167 for user in usernames: 168 for section in sections: 169 if user in section: 170 if section[user]: 171 return True 172 denied = True 173 # Don't check section without module name 174 # because the section with module name defines 175 # the user's permissions. 176 break 177 if denied: # All users has no readable permission. 178 return False 179 180 def check_path(path): 181 path = '/' + pathjoin(repos.scope, path) 182 if path != '/': 183 path += '/' 184 185 # Allow access to parent directories of allowed resources 186 for spath in set(sum((list(authz.get(module, {})) 187 for module in modules), [])): 188 if spath.startswith(path): 189 result = check_path_0(spath) 190 if result is True: 191 return True 192 193 # Walk from resource up parent directories 194 for spath in parent_iter(path): 195 result = check_path_0(spath) 196 if result is not None: 197 return result 198 199 if realm == 'source': 200 return check_path(resource.id) 201 202 elif realm == 'changeset': 203 changes = list(repos.get_changeset(resource.id).get_changes()) 204 if not changes or any(check_path(change[0]) 205 for change in changes): 206 return True 207 208 def _get_authz_info(self): 209 if not self.authz_file: 210 self.log.error("The [svn] authz_file configuration option in " 211 "trac.ini is empty or not defined") 212 raise ConfigurationError() 213 try: 214 mtime = os.path.getmtime(self.authz_file) 215 except OSError as e: 216 self.log.error("Error accessing svn authz permission policy " 217 "file: %s", exception_to_unicode(e)) 218 raise ConfigurationError() 219 if mtime != self._mtime: 220 self._mtime = mtime 221 rm = RepositoryManager(self.env) 222 modules = set(repos.reponame 223 for repos in rm.get_real_repositories()) 224 if '' in modules and self.authz_module_name: 225 modules.add(self.authz_module_name) 226 modules.add('') 227 self.log.info("Parsing authz file: %s", self.authz_file) 228 try: 229 self._authz = parse(self.authz_file, modules) 230 except ParsingError as e: 231 self.log.error("Error parsing svn authz permission policy " 232 "file: %s", exception_to_unicode(e)) 233 raise ConfigurationError() 234 else: 235 self._users = {user 236 for paths in self._authz.values() 237 for path in paths.values() 238 for user, result in path.items() 239 if result} 240 return self._authz, self._users 241