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