~bzr-pqm/bzr/bzr.dev

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
#!/usr/bin/env python
###############################################################################
#
#  bzr_access:
#    Simple access control for shared bazaar repository accessed over ssh.
#
# Copyright (C) 2007 Balint Aradi
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
#
###############################################################################
"""
Invocation: bzr_access <bzr_executable> <repo_collection> <user>

The script extracts from the SSH_ORIGINAL_COMMAND environment variable the
repository, which bazaar tries to access through the bzr+ssh protocol. The
repository is assumed to be relative to <repo_collection>. Based
on the configuration file <repo_collection>/bzr_access.conf it determines
the access rights (denied, read-only, read-write) for the specified user.
If the user has read-only or read-write access a bazaar smart server is
started for it in read-only or in read-write mode, rsp., using the specified
bzr executable.

Config file: INI format, pretty much similar to the authfile of subversion.

Groups can be defined in the [groups] section. The options in this block are
the names of the groups to be defined, the corresponding values the lists of
the users belonging to the given groups. (User names must be separated by
commas.)

All other sections names should be path names (starting with '/'), defining
the permissions for the given path. The options in those sections are user
names or group references (group name with a leading '@'), the corresponding
values are the permissions: 'rw', 'r' and '' (without the quotes) for
read-write, read-only and no access, respectively.

Only the options in the section with the longest matching name are evaluated.
The last relevant option for the user is used.

Sample bzr_access.conf::

   [groups]
   admins = alpha
   devels = beta, gamma, delta
   
   [/test/trunk]
   @admins = rw
   @devels = r
   
   [/test/branches]
   @admins = rw
   @devels = rw


This allows you to set up a single SSH user, and customize the access based on
ssh key. Your ``.ssh/authorized_key`` file should look something like this::

   command="/path/to/bzr_access /path/to/bzr /path/to/repository <username>",no-port-forwarding,no-X11-forwarding,no-agent-forwarding ssh-<type> <key>
"""

import ConfigParser
import os
import re
import subprocess
import sys

CONFIG_FILE = "bzr_access.conf"
SCRIPT_NAME = os.path.basename(sys.argv[0])

# Permission constants
PERM_DENIED = 0
PERM_READ = 1
PERM_READWRITE = 2
PERM_DICT = { "r": PERM_READ, "rw": PERM_READWRITE }

# Exit codes
EXIT_BAD_NR_ARG = 1
EXIT_BZR_NOEXEC = 2
EXIT_REPO_NOREAD = 3
EXIT_BADENV = 4
EXIT_BADDIR = 5
EXIT_NOCONF = 6
EXIT_NOACCESS = 7
EXIT_BADUSERNAME = 8

# pattern for the bzr command passed to ssh
PAT_SSH_COMMAND = re.compile(r"""^bzr\s+
                             serve\s+
                             --inet\s+
                             --directory=(?P<dir>\S+)\s+
                             --allow-writes\s*$""", re.VERBOSE)

# Command line for starting bzr
BZR_OPTIONS = ['serve', '--inet', '--directory']
BZR_READWRITE_FLAGS = ['--allow-writes']



def error(msg, exit_code):
    """Prints error message to stdout and exits with given error code."""
    
    print >>sys.stderr, "%s::error: %s" % (SCRIPT_NAME, msg)
    sys.exit(exit_code)
  


class AccessManager(object):
    """Manages the permissions, can be queried for a specific user and path."""
    
    def __init__(self, fp):
        """:param fp: File like object, containing the configuration options.
        """
        # TODO: jam 20071211 Consider switching to bzrlib.util.configobj
        self.config = ConfigParser.ConfigParser()
        self.config.readfp(fp)
        self.groups = {}
        if self.config.has_section("groups"):
            for group, users in self.config.items("groups"):
                self.groups[group] = set([ s.strip() for s in users.split(",")])
        

    def permission(self, user, path):
        """Determines the permission for a given user and a given path
        :param user: user to look for.
        :param path: path to look for.
        :return: permission.
        """
        if not path.startswith("/"):
            return PERM_DENIED
        perm = PERM_DENIED
        pathFound = False
        while not pathFound and path != "/":
            print >>sys.stderr, "DEBUG:", path
            pathFound = self.config.has_section(path)
            if (pathFound):
                options = reversed(self.config.options(path))
                for option in options:
                    value = PERM_DICT.get(self.config.get(path, option),
                                          PERM_DENIED)
                    if self._is_relevant(option, user):
                        perm = value
            else:
                path = os.path.dirname(path)
        return perm
      
      
    def _is_relevant(self, option, user):
        """Decides if a certain option is relevant for a given user.
      
        An option is relevant if it is identical with the user or with a
        reference to a group including the user.
      
        :param option: Option to check.
        :param user: User
        :return: True if option is relevant for the user, False otherwise.
        """
        if option.startswith("@"):
            result = (user in self.groups.get(option[1:], set()))
        else:
            result = (option == user)
        return result



def get_directory(command):
    """Extracts the directory name from the command pass to ssh.
    :param command: command to parse.
    :return: Directory name or empty string, if directory was not found or if it
    does not start with '/'.
    """
    match = PAT_SSH_COMMAND.match(command)
    if not match:
        return ""
    directory = match.group("dir")
    return os.path.normpath(directory)



############################################################################
# Main program
############################################################################
def main():
    # Read arguments
    if len(sys.argv) != 4:
        error("Invalid number or arguments.", EXIT_BAD_NR_ARG)
    (bzrExec, repoRoot, user) = sys.argv[1:4]
    
    # Sanity checks
    if not os.access(bzrExec, os.X_OK):
        error("bzr is not executable.", EXIT_BZR_NOEXEC)
    if not os.access(repoRoot, os.R_OK):
        error("Path to repository not readable.", EXIT_REPO_NOREAD)
    
    # Extract the repository path from the command passed to ssh.
    if not os.environ.has_key("SSH_ORIGINAL_COMMAND"):
        error("Environment variable SSH_ORIGINAL_COMMAND missing.", EXIT_BADENV)
    directory = get_directory(os.environ["SSH_ORIGINAL_COMMAND"])
    if len(directory) == 0:
        error("Bad directory name.", EXIT_BADDIR)

    # Control user name
    if not user.isalnum():
        error("Invalid user name", EXIT_BADUSERNAME)
    
    # Read in config file.
    try:
        fp = open(os.path.join(repoRoot, CONFIG_FILE), "r")
        try:
            accessMan = AccessManager(fp)
        finally:
            fp.close()
    except IOError:
        error("Can't read config file.", EXIT_NOCONF)
    
    # Determine permission and execute bzr with appropriate options
    perm = accessMan.permission(user, directory)
    absDir = os.path.join(repoRoot, directory)
    command = [bzrExec] + BZR_OPTIONS + [absDir]
    if perm == PERM_READ:
        # Nothing extra needed for readonly operations
        pass
    elif perm == PERM_READWRITE:
        # Add the write flags
        command.extend(BZR_READWRITE_FLAGS)
    else:
        error("Access denied.", EXIT_NOACCESS)
    return subprocess.call(command)


if __name__ == "__main__":
  main()


### Local Variables:
### mode:python
### End: