Patchwork hgweb: add group authorization

login
register
mail settings
Submitter Markus Zapke-Gründemann
Date Feb. 7, 2013, 10:14 a.m.
Message ID <a605a2bcbd12346c778a.1360232071@hyperion.local>
Download mbox | patch
Permalink /patch/825/
State Rejected
Headers show

Comments

Markus Zapke-Gründemann - Feb. 7, 2013, 10:14 a.m.
# HG changeset patch
# User Markus Zapke-Gründemann <markus@keimlink.de>
# Date 1360231888 -3600
# Node ID a605a2bcbd12346c778ae1adf35905823fb353bf
# Parent  1516d5624a2911fcb90ee051c6dc0679b49aef55
hgweb: add group authorization.
Mads Kiilerich - Feb. 7, 2013, 11:18 a.m.
On 02/07/2013 11:14 AM, Markus Zapke-Gründemann wrote:
> # HG changeset patch
> # User Markus Zapke-Gründemann <markus@keimlink.de>
> # Date 1360231888 -3600
> # Node ID a605a2bcbd12346c778ae1adf35905823fb353bf
> # Parent  1516d5624a2911fcb90ee051c6dc0679b49aef55
> hgweb: add group authorization.

Hi Markus

Thanks, that might be a useful feature ... but it is very hard to tell 
when the patch also includes a lot of other unrelated changes 
(white-space and other coding style and minor issues). Please leave that 
part out and resend.

Please also see http://mercurial.selenic.com/wiki/ContributingChanges 
regarding the patch description.

/Mads

Patch

diff --git a/mercurial/help/config.txt b/mercurial/help/config.txt
--- a/mercurial/help/config.txt
+++ b/mercurial/help/config.txt
@@ -1286,8 +1286,12 @@  The full set of options is:
     push is not allowed. If the special value ``*``, any remote user can
     push, including unauthenticated users. Otherwise, the remote user
     must have been authenticated, and the authenticated user name must
-    be present in this list. The contents of the allow_push list are
-    examined after the deny_push list.
+    be present in this list. It is also possible to use groups in this
+    list. A group name is prefixed by an ``@``. Groups can either be
+    groups defined in the ``groups_section`` or Unix groups. If a group
+    from the ``groups_section`` has the same name as an Unix group it
+    is used instead. The contents of the allow_push list are examined
+    after the deny_push list.
 
 ``allow_read``
     If the user has not already been denied repository access due to
@@ -1297,8 +1301,12 @@  The full set of options is:
     denied for the user. If the list is empty or not set, then access
     is permitted to all users by default. Setting allow_read to the
     special value ``*`` is equivalent to it not being set (i.e. access
-    is permitted to all users). The contents of the allow_read list are
-    examined after the deny_read list.
+    is permitted to all users). It is also possible to use groups in
+    this list. A group name is prefixed by an ``@``. Groups can either
+    be groups defined in the ``groups_section`` or Unix groups. If a
+    group from the ``groups_section`` has the same name as an Unix group
+    it is used instead. The contents of the allow_read list are examined
+    after the deny_read list.
 
 ``allowzip``
     (DEPRECATED) Whether to allow .zip downloading of repository
@@ -1366,8 +1374,13 @@  The full set of options is:
     Whether to deny pushing to the repository. If empty or not set,
     push is not denied. If the special value ``*``, all remote users are
     denied push. Otherwise, unauthenticated users are all denied, and
-    any authenticated user name present in this list is also denied. The
-    contents of the deny_push list are examined before the allow_push list.
+    any authenticated user name present in this list is also denied. It
+    is also possible to use groups in this list. A group name is
+    prefixed by an ``@``. Groups can either be groups defined in the
+    ``groups_section`` or Unix groups. If a group from the
+    ``groups_section`` has the same name as an Unix group it is used
+    instead. The contents of the deny_push list are examined before the
+    allow_push list.
 
 ``deny_read``
     Whether to deny reading/viewing of the repository. If this list is
@@ -1380,9 +1393,12 @@  The full set of options is:
     deny_read and allow_read are empty or not set, then access is
     permitted to all users by default. If the repository is being
     served via hgwebdir, denied users will not be able to see it in
-    the list of repositories. The contents of the deny_read list have
-    priority over (are examined before) the contents of the allow_read
-    list.
+    the list of repositories. It is also possible to use groups in this
+    list. A group name is prefixed by an ``@``. Groups can either be
+    groups defined in the ``groups_section`` or Unix groups. If a group
+    from the ``groups_section`` has the same name as an Unix group it is
+    used instead. The contents of the deny_read list have priority over
+    (are examined before) the contents of the allow_read list.
 
 ``descend``
     hgwebdir indexes will not descend into subdirectories. Only repositories
@@ -1400,6 +1416,30 @@  The full set of options is:
 ``errorlog``
     Where to output the error log. Default is stderr.
 
+``groups_section``
+    Name of hgrc section used to define groups for authorization.
+    Default is ``web.groups``. Use the section to define the groups used
+    by authorization.
+
+    Example::
+
+        [web]
+        allow_read = @devs
+
+        [web.groups]
+        devs = alice, bob, clara, david
+
+    Groups can contain other groups::
+
+        [web]
+        allow_read = @devs, @testers
+        allow_push = @devs
+
+        [web.groups]
+        devs = alice, bob, clara, david
+        ci = hudson
+        testers = @ci, lisa, mario
+
 ``guessmime``
     Control MIME types for raw download of file content.
     Set to True to let hgweb guess the content type from the file
diff --git a/mercurial/hgweb/common.py b/mercurial/hgweb/common.py
--- a/mercurial/hgweb/common.py
+++ b/mercurial/hgweb/common.py
@@ -6,7 +6,11 @@ 
 # This software may be used and distributed according to the terms of the
 # GNU General Public License version 2 or any later version.
 
-import errno, mimetypes, os
+import errno
+import mimetypes
+import os
+
+from mercurial import util
 
 HTTP_OK = 200
 HTTP_NOT_MODIFIED = 304
@@ -18,25 +22,73 @@  HTTP_METHOD_NOT_ALLOWED = 405
 HTTP_SERVER_ERROR = 500
 
 
+def _get_users(ui, group, seen=None):
+    """Return the users of the group as list."""
+    # update list of groups seen so far for detecting recursions
+    if not seen:
+        seen = []
+    seen.append(group)
+    # check which section to use to lookup groups
+    section = ui.config('web', 'groups_section', 'web.groups')
+    # first, try to use group definition from groups_section
+    users = []
+    hgrcusers = ui.configlist(section, group)
+    if hgrcusers:
+        for item in hgrcusers:
+            if not item.startswith('@'):
+                users.append(item)
+                continue
+            if item[1:] in seen:
+                raise ErrorResponse(HTTP_UNAUTHORIZED,
+                    'recursion detected for group "%s" in group "%s"' %
+                    (item[1:], group))
+            users += _get_users(ui, item[1:], seen)
+    if not users:
+        # if no users found in group definition, get users from OS-level group
+        try:
+            users = util.groupmembers(group)
+        except KeyError:
+            raise ErrorResponse(HTTP_UNAUTHORIZED,
+                    'group "%s" is undefined' % group)
+    return users
+
+
+def _is_member(ui, user, group):
+    """Check recursively if a user is member of a group.
+
+    If the group equals * all users are members.
+    """
+    if group == ['*'] or user in group:
+        return True
+    for item in group:
+        if not item.startswith('@'):
+            continue
+        users = _get_users(ui, item[1:])
+        if user in users:
+            return True
+    return False
+
+
 def checkauthz(hgweb, req, op):
-    '''Check permission for operation based on request data (including
+    """Check permission for operation based on request data (including
     authentication info). Return if op allowed, else raise an ErrorResponse
-    exception.'''
-
+    exception.
+    """
     user = req.env.get('REMOTE_USER')
 
+    # check read permission
     deny_read = hgweb.configlist('web', 'deny_read')
-    if deny_read and (not user or deny_read == ['*'] or user in deny_read):
+    if deny_read and (not user or _is_member(hgweb.repo.ui, user, deny_read)):
         raise ErrorResponse(HTTP_UNAUTHORIZED, 'read not authorized')
 
     allow_read = hgweb.configlist('web', 'allow_read')
-    result = (not allow_read) or (allow_read == ['*'])
-    if not (result or user in allow_read):
+    if not (not allow_read or _is_member(hgweb.repo.ui, user, allow_read)):
         raise ErrorResponse(HTTP_UNAUTHORIZED, 'read not authorized')
 
+    # check pull permission
     if op == 'pull' and not hgweb.allowpull:
         raise ErrorResponse(HTTP_UNAUTHORIZED, 'pull not authorized')
-    elif op == 'pull' or op is None: # op is None for interface requests
+    elif op == 'pull' or op is None:  # op is None for interface requests
         return
 
     # enforce that you can only push using POST requests
@@ -50,12 +102,13 @@  def checkauthz(hgweb, req, op):
     if hgweb.configbool('web', 'push_ssl', True) and scheme != 'https':
         raise ErrorResponse(HTTP_FORBIDDEN, 'ssl required')
 
+    # check push permission
     deny = hgweb.configlist('web', 'deny_push')
-    if deny and (not user or deny == ['*'] or user in deny):
+    if deny and (not user or _is_member(hgweb.repo.ui, user, deny)):
         raise ErrorResponse(HTTP_UNAUTHORIZED, 'push not authorized')
 
     allow = hgweb.configlist('web', 'allow_push')
-    result = allow and (allow == ['*'] or user in allow)
+    result = allow and _is_member(hgweb.repo.ui, user, allow)
     if not result:
         raise ErrorResponse(HTTP_UNAUTHORIZED, 'push not authorized')
 
@@ -70,16 +123,20 @@  permhooks = [checkauthz]
 
 
 class ErrorResponse(Exception):
-    def __init__(self, code, message=None, headers=[]):
+    def __init__(self, code, message=None, headers=None):
         if message is None:
             message = _statusmessage(code)
+        if headers is None:
+            headers = []
         Exception.__init__(self)
         self.code = code
         self.message = message
         self.headers = headers
+
     def __str__(self):
         return self.message
 
+
 class continuereader(object):
     def __init__(self, f, write):
         self.f = f
@@ -97,14 +154,17 @@  class continuereader(object):
             return getattr(self.f, attr)
         raise AttributeError
 
+
 def _statusmessage(code):
     from BaseHTTPServer import BaseHTTPRequestHandler
     responses = BaseHTTPRequestHandler.responses
     return responses.get(code, ('Error', 'Unknown error'))[0]
 
+
 def statusmessage(code, message=None):
     return '%d %s' % (code, message or _statusmessage(code))
 
+
 def get_stat(spath):
     """stat changelog if it exists, spath otherwise"""
     cl_path = os.path.join(spath, "00changelog.i")
@@ -113,9 +173,11 @@  def get_stat(spath):
     else:
         return os.stat(spath)
 
+
 def get_mtime(spath):
     return get_stat(spath).st_mtime
 
+
 def staticfile(directory, fname, req):
     """return a file inside directory with guessed Content-Type header
 
@@ -123,12 +185,11 @@  def staticfile(directory, fname, req):
     contain unusual path components.
     Content-Type is guessed using the mimetypes module.
     Return an empty string if fname is illegal or file not found.
-
     """
     parts = fname.split('/')
     for part in parts:
         if (part in ('', os.curdir, os.pardir) or
-            os.sep in part or os.altsep is not None and os.altsep in part):
+                os.sep in part or os.altsep is not None and os.altsep in part):
             return ""
     fpath = os.path.join(*parts)
     if isinstance(directory, str):
@@ -153,6 +214,7 @@  def staticfile(directory, fname, req):
         else:
             raise ErrorResponse(HTTP_SERVER_ERROR, err.strerror)
 
+
 def paritygen(stripecount, offset=0):
     """count parity of horizontal stripes for easier reading"""
     if stripecount and offset:
@@ -169,6 +231,7 @@  def paritygen(stripecount, offset=0):
             parity = 1 - parity
             count = 0
 
+
 def get_contact(config):
     """Return repo contact information or empty string.
 
@@ -179,6 +242,7 @@  def get_contact(config):
             config("ui", "username") or
             os.environ.get("EMAIL") or "")
 
+
 def caching(web, req):
     tag = str(web.mtime)
     if req.env.get('HTTP_IF_NONE_MATCH') == tag:
diff --git a/mercurial/hgweb/hgwebdir_mod.py b/mercurial/hgweb/hgwebdir_mod.py
--- a/mercurial/hgweb/hgwebdir_mod.py
+++ b/mercurial/hgweb/hgwebdir_mod.py
@@ -6,19 +6,24 @@ 
 # This software may be used and distributed according to the terms of the
 # GNU General Public License version 2 or any later version.
 
-import os, re, time
+import os
+import re
+import time
+
 from mercurial.i18n import _
-from mercurial import ui, hg, scmutil, util, templater
-from mercurial import error, encoding
-from common import ErrorResponse, get_mtime, staticfile, paritygen, \
-                   get_contact, HTTP_OK, HTTP_NOT_FOUND, HTTP_SERVER_ERROR
+from mercurial import hg, scmutil, templater, ui, util
+from mercurial import encoding, error
+from common import _is_member, get_contact, get_mtime, ErrorResponse, \
+    HTTP_NOT_FOUND, HTTP_OK, HTTP_SERVER_ERROR, paritygen, staticfile
 from hgweb_mod import hgweb, makebreadcrumb
 from request import wsgirequest
 import webutil
 
+
 def cleannames(items):
     return [(util.pconvert(name).strip('/'), path) for name, path in items]
 
+
 def findrepos(paths):
     repos = []
     for prefix, root in cleannames(paths):
@@ -37,6 +42,7 @@  def findrepos(paths):
         repos.extend(urlrepos(prefix, roothead, paths))
     return repos
 
+
 def urlrepos(prefix, roothead, paths):
     """yield url paths and filesystem paths from a list of repo paths
 
@@ -51,6 +57,7 @@  def urlrepos(prefix, roothead, paths):
         yield (prefix + '/' +
                util.pconvert(path[len(roothead):]).lstrip('/')).strip('/'), path
 
+
 def geturlcgivars(baseurl, port):
     """
     Extract CGI variables from baseurl
@@ -78,6 +85,7 @@  def geturlcgivars(baseurl, port):
 
     return name, str(port), path
 
+
 class hgwebdir(object):
     refreshinterval = 20
 
@@ -159,19 +167,16 @@  class hgwebdir(object):
         user can be denied read access:  (1) deny_read is not empty, and the
         user is unauthenticated or deny_read contains user (or *), and (2)
         allow_read is not empty and the user is not in allow_read.  Return True
-        if user is allowed to read the repo, else return False."""
-
+        if user is allowed to read the repo, else return False.
+        """
         user = req.env.get('REMOTE_USER')
-
         deny_read = ui.configlist('web', 'deny_read', untrusted=True)
-        if deny_read and (not user or deny_read == ['*'] or user in deny_read):
+        if deny_read and (not user or _is_member(ui, user, deny_read)):
             return False
-
         allow_read = ui.configlist('web', 'allow_read', untrusted=True)
         # by default, allow reading if no allow_read option has been set
-        if (not allow_read) or (allow_read == ['*']) or (user in allow_read):
+        if (not allow_read) or _is_member(ui, user, allow_read):
             return True
-
         return False
 
     def run_wsgi(self, req):
@@ -329,6 +334,7 @@  class hgwebdir(object):
                 except Exception, e:
                     u.warn(_('error reading %s/.hg/hgrc: %s\n') % (path, e))
                     continue
+
                 def get(section, name, default=None):
                     return u.config(section, name, default, untrusted=True)
 
@@ -363,13 +369,14 @@  class hgwebdir(object):
                            description=description or "unknown",
                            description_sort=description.upper() or "unknown",
                            lastchange=d,
-                           lastchange_sort=d[1]-d[0],
+                           lastchange_sort=d[1] - d[0],
                            archives=archivelist(u, "tip", url))
 
                 seenrepos.add(name)
                 yield row
 
         sortdefault = None, False
+
         def entries(sortcolumn="", descending=False, subdir="", **map):
             rows = rawentries(subdir=subdir, **map)
 
diff --git a/tests/test-hgweb-authz.t b/tests/test-hgweb-authz.t
new file mode 100644
--- /dev/null
+++ b/tests/test-hgweb-authz.t
@@ -0,0 +1,111 @@ 
+This test exercises the authorization functionality with a dummy script
+
+  $ cat <<EOF > dummywsgi
+  > import os
+  > import sys
+  > 
+  > from mercurial.hgweb import hgweb
+  > 
+  > app = hgweb(os.path.join(os.environ['TESTTMP'], 'hgweb.config'))
+  > environ = {
+  >     'SCRIPT_NAME': '',
+  >     'REQUEST_METHOD': 'GET',
+  >     'PATH_INFO': sys.argv[1],
+  >     'SERVER_PROTOCOL': 'HTTP/1.0',
+  >     'QUERY_STRING': '',
+  >     'CONTENT_LENGTH': '0',
+  >     'SERVER_NAME': 'localhost',
+  >     'SERVER_PORT': '80',
+  >     'REPO_NAME': sys.argv[1],
+  >     'HTTP_HOST': 'localhost:80',
+  >     'REMOTE_USER': sys.argv[2],
+  >     'wsgi.input': sys.stdin,
+  >     'wsgi.url_scheme': 'http',
+  >     'wsgi.multithread': False,
+  >     'wsgi.version': (1, 0),
+  >     'wsgi.run_once': False,
+  >     'wsgi.errors': sys.stderr,
+  >     'wsgi.multiprocess': False,
+  > }
+  > 
+  > def start_response(status, headers, exc_info=None):
+  >     def dummy_response(data):
+  >         pass
+  >     sys.stdout.write(status + '\n')
+  >     return dummy_response
+  > 
+  > app(environ, start_response)
+  > EOF
+
+creating test repository
+
+  $ hg init r1
+  $ cd r1
+  $ echo c1 > f1
+  $ echo c2 > f2
+  $ hg ci -A -m "init" f1 f2
+
+writing hgweb.config
+
+  $ cd ..
+  $ cat <<EOF > hgweb.config
+  > [paths]
+  > r1 = `pwd`/r1
+  > EOF
+
+group authorization test
+
+  $ cat <<EOF > r1/.hg/hgrc
+  > [web]
+  > allow_read = @developers, cathrin
+  > 
+  > [web.groups]
+  > developers = alice, bob
+  > EOF
+
+  $ python ./dummywsgi r1 alice
+  200 Script output follows
+  $ python ./dummywsgi r1 bob
+  200 Script output follows
+  $ python ./dummywsgi r1 cathrin
+  200 Script output follows
+  $ python ./dummywsgi r1 nosuchuser
+  401 read not authorized
+
+groups can contain other groups
+
+  $ cat <<EOF > r1/.hg/hgrc
+  > [web]
+  > allow_read = @developers, @testers
+  > 
+  > [web.groups]
+  > developers = alice, bob
+  > ci = hudson
+  > testers = @ci, lisa, mario
+  > EOF
+
+  $ python ./dummywsgi r1 hudson
+  200 Script output follows
+
+using an unknown groups fails
+
+  $ cat <<EOF > r1/.hg/hgrc
+  > [web]
+  > allow_read = @quux
+  > EOF
+
+  $ python ./dummywsgi r1 alice
+  401 group "quux" is undefined
+
+using a recursive groups setup is not allowed
+
+  $ cat <<EOF > r1/.hg/hgrc
+  > [web]
+  > allow_read = @developers
+  > 
+  > [web.groups]
+  > developers = alice, bob, @developers
+  > EOF
+
+  $ python ./dummywsgi r1 alice
+  401 recursion detected for group "developers" in group "developers"