Big change to support file conditions; format of hg-ssh-access.conf
authorPaul Crowley <paul@lshift.net>
Tue, 22 Apr 2008 09:46:29 +0100
changeset 18 538d6b198f4a
parent 17 4c98440de851
child 19 62ee928ac9b3
child 20 f4daa224dc7e
Big change to support file conditions; format of hg-ssh-access.conf changed.
README
access.py
hg-ssh
hg-ssh-wrapper
remote-hgrc
rules.py
--- a/README	Mon Apr 21 12:37:56 2008 +0100
+++ b/README	Tue Apr 22 09:46:29 2008 +0100
@@ -53,6 +53,10 @@
 hook in this repository re-runs "refresh-auth" on the most recent
 version after every push.
 
+Finally, a hook in an extension is run for each changeset that is
+remotely committed, which uses the rules file to determine whether to
+allow the changeset.
+
 GETTING STARTED
 
 This is only one setup - it can be tweaked in many ways, and is as
@@ -79,10 +83,10 @@
    mkdir -p admin repos/hgadmin/keys/admin .ssh
    cd admin
    hg clone http://hg.opensource.lshift.net/hg-admin-tools
-   cp hg-admin-tools/hg-ssh-wrapper ~
+   cp hg-admin-tools/hg-ssh-wrapper hg-admin-tools/remote-hgrc ~
    cd ../repos/hgadmin
    hg init .
-   echo "init admin/* *" > hg-ssh-access.conf
+   echo "init user=admin/*" > hg-ssh-access.conf
    cp /tmp/my-ssh-public-key keys/admin/myname
    hg add
    hg commit -m "initial commit"
@@ -105,7 +109,8 @@
 editing hg-ssh-access.conf.  Changes will take effect as soon as you
 push them to "ssh://hg@repository-host/hgadmin".
 
-Users authorized to do so can now also create new repositories on this host with "clone":
+Users authorized to do so can now also create new repositories on this
+host with "clone":
 
   hg clone . ssh://hg@repository-host/my-project-name
 
@@ -113,15 +118,63 @@
 
 Each line of hg-ssh-access.conf has the following syntax:
 
-<rule> <keypattern> <repositorypattern>
+<rule> <condition> <condition> ...
+
+Rule is one of
+
+init - allow any operation, including the creation of new repositories
+write - allow reads and writes to this file in this repository
+read - allow the repo to be read but reject matching writes
+deny - deny all requests
+
+A condition is a globpattern matched against a relative path, one of:
+
+user=<globpattern> - user's key
+repo=<globpattern> - repo (as the user supplies it)
+file=<globpattern> - file in the repo
+
+The first rule in the file which has all its conditions satisfied is
+used to determine whether an action is allowed.
+
+Paths cannot contain any special characters except "/"; glob patterns
+cannot contain any special characters except "/" and "*".  "*" matches
+zero or more characters not including "/" while "**" matches zero or
+more characters including "/".
+
+Blank lines and lines that start with "#" are ignored.
+
+FILE CONDITIONS
+
+The rules file is used to make three decisions:
 
-The "rule" is either "init", "allow", or "deny".  "keypattern" is a
-glob pattern matched against the name of the key used - for example,
-in our initial setup "admin/myname" matches "admin/*".
-"repositorypattern" is a pattern matched againt the repository name -
-so "hgadmin" matches "*".  Only boring characters are allowed in
-patterns and key and repository names - see the source for details.
-Blank lines and lines that start with "#" are ignored.  The first rule
-to match both the key and the repository applies: "deny" will deny all
-matching requests, "allow" allows read/write access to existing
-repositories, and "init" allows that and creation of new repositories.
+- Whether to allow a repository to be created
+- Whether to allow access to a repository
+- Whether to allow a changeset to change a particular file
+
+When the first two of these decisions are being made, nothing is known
+about what files might be changed, and so all file conditions
+automatically succeed for the purpose of such decisions.  This means
+that doing tricky things with file conditions can have
+counterintuitive consequences:
+
+- You cannot limit read access to a subset of a repository with a
+"read" rule and a file condition: any user who has access to a
+repository can read all of it and its full history.  Such a rule can
+only have the effect of masking a later "write" rule, as in this
+example:
+
+   read repo=specialrepo file=dontwritethis
+   write repo=specialrepo
+
+allows all users to read specialrepo, and to write to all files
+*except* that any changeset which writes to "dontwritethis" will be
+rejected.
+
+- For similar reasons, don't give "init" rules file conditions.
+
+THANKS
+
+Thanks for reading this far.  If you use hg-admin-tools, please tell
+me about it.
+
+Paul Crowley, 2008
--- a/access.py	Mon Apr 21 12:37:56 2008 +0100
+++ b/access.py	Tue Apr 22 09:46:29 2008 +0100
@@ -1,6 +1,10 @@
-# Copyright 2008 Paul Crowley <paul@lshift.net>
+# Copyright 2008 LShift Ltd
 # Copyright 2006 Vadim Gelfer <vadim.gelfer@gmail.com>
 #
+# Authors:
+# Paul Crowley <paul@lshift.net>
+# Vadim Gelfer <vadim.gelfer@gmail.com>
+#
 # This software may be used and distributed according to the terms
 # of the GNU General Public License, incorporated herein by reference.
 
@@ -9,84 +13,23 @@
 from mercurial import util
 
 import os
-import re
-
-allowedchars = "A-Za-z0-9_-"
-goodglobre = re.compile("[*/%s]+$" % allowedchars)
-
-# Don't put anything except *A-Za-z0-9_- in rule globs or   
-# it will match nothing.  No regexp metachars, not even .
-# We may fix this later.
-def globmatcher(pattern):
-    if not goodglobre.match(pattern):
-        #fail("Bad glob pattern in auth config: %s" % pattern)
-        # FIXME: report it somehow
-        return lambda x: False
-    pattern = pattern.replace("*", "[]")
-    pattern = pattern.replace("[][]", "[/%s]*" % allowedchars)
-    pattern = pattern.replace("[]", "[%s]*" % allowedchars)
-    rex = re.compile(pattern + "$")
-    return lambda x: rex.match(x) is not None
-
-def rule(pairs):
-    matchers = [(k, globmatcher(v)) for k, v in pairs]
-    def c(**kw):
-        for k, m in matchers:
-            if k not in kw or not m(kw[k]):
-                return False
-        return True
-    return c
-
-class Rulefile(object):
-    '''Class representing the rules in a rule file'''
-    
-    self.levels = ["init", "write", "read", "deny"]
-
-    def __init__(self):
-        self.rules = []
-
-    def add(self, action, conditions):
-        self.rules.append((action, conditions))
-
-    def matchrule(self, **kw):
-        for a, c in self.rules:
-            if c(**kw):
-                return a
-        return None
-
-    def allow(self, level, **kw):
-        a = matchrule(self, **kw)
-        return a in self.levels and self.levels.index(a) <= self.levels.index(level)
-
-def read_rules(fn):
-    res = Rulefile()
-    f = open(fn)
-    try:
-        for l in f:
-            l = l.strip()
-            if len(l) == 0 or l.startswith["#"]:
-                continue
-            res.add(l[0], rule([c.split("=", 1) for c in l[1:]]))
-    finally:
-        f.close()
+import rules
 
 class Checker(object):
     '''acl checker.'''
 
-    def getuser(self):
-        '''return name of hg-ssh user'''
-        return os.environ['REMOTE_USER']
-
     def __init__(self, ui, repo):
         self.ui = ui
         self.repo = repo
-        self.rules = read_rules(os.environ['HG_ACCESS_RULES_FILE'])
+        self.repo_path = os.environ['HG_REPO_PATH']
+        self.user = os.environ['REMOTE_USER']
+        self.rules = rules.Ruleset.readfile(os.environ['HG_ACCESS_RULES_FILE'])
 
     def check(self, node):
         '''return if access allowed, raise exception if not.'''
         files = self.repo.changectx(node).files()
         for f in files:
-            if not self.rules.allow("write", user=self.user, ):
+            if not self.rules.allow("write", user=self.user, repo=self.repo_path, file=f):
                 self.ui.debug(_('%s: user %s not allowed on %s\n') %
                               (__name__, self.getuser(), f))
                 raise util.Abort(_('%s: access denied for changeset %s') %
@@ -97,9 +40,7 @@
     if hooktype != 'pretxnchangegroup':
         raise util.Abort(_('config error - hook type "%s" cannot stop '
                            'incoming changesets') % hooktype)
-
-    c = checker(ui, repo)
-
+    c = Checker(ui, repo)
     start = repo.changelog.rev(bin(node))
     end = repo.changelog.count()
     for rev in xrange(start, end):
--- a/hg-ssh	Mon Apr 21 12:37:56 2008 +0100
+++ b/hg-ssh	Tue Apr 22 09:46:29 2008 +0100
@@ -2,7 +2,7 @@
 #
 # Copyright 2008 LShift Ltd
 # Copyright 2005-2007 by Intevation GmbH <intevation@intevation.de>
-# Author(s):
+# Authors:
 # Paul Crowley <paul@lshift.net>
 # Thomas Arendsen Hein <thomas@intevation.de>
 # with ideas from  Mathieu PASQUET <kiorky@cryptelium.net>
@@ -14,15 +14,18 @@
 hg-ssh - limit access to hg repositories reached via ssh.  Part of
 hg-admin-tools.
 
-This script is called by hg-ssh-wrapper with two arguments:
-
-hg-ssh <rulefile> <keyname>
+This script is called by hg-ssh-wrapper with no arguments - everything
+should be in enviroment variables:
 
-It expects to find the command the SSH user was trying to run in the
-environment variable SSH_ORIGINAL_COMMAND, and uses it to determine
-what the user was trying to do and to what repository, and then checks
-each rule in the rule file in turn for a matching rule which decides
-what to do, defaulting to disallowing the action.
+HG_ACCESS_RULES_FILE identifies the path to the rules file
+REMOTE_USER the remote user (which is the key used by ssh)
+SSH_ORIGINAL_COMMAND the command the user was trying to run
+
+It uses SSH_ORIGINAL_COMMAND to determine what the user was trying to
+do and to what repository, and then checks each rule in the rule file
+in turn for a matching rule which decides what to do, defaulting to
+disallowing the action.
+
 """
 
 # enable importing on demand to reduce startup time
@@ -30,70 +33,44 @@
 
 from mercurial import dispatch
 
-import sys, os, re
+import sys, os
+import rules
 
 def fail(message):
     #logfile.write("Fail: %s\n" % message)
     sys.stderr.write(message + "\n")
     sys.exit(-1)
 
-# Note that this currently disallows dots in path components - if you change it
-# to allow them ensure that "." and ".." are disallowed in path components.
-allowedchars = "A-Za-z0-9_-"
-goodpathre = re.compile("([%s]+/)*[%s]+$" % (allowedchars, allowedchars))
-def goodpath(path):
-    if goodpathre.match(path) is None:
+def getpath(path):
+    if path.endswith("/"):
+        path = path[:-1]
+    if not rules.goodpath(path):
         fail("Disallowing path: %s" % path)
-
-# Don't put anything except *A-Za-z0-9_- in rule globs or
-# you'll probably break security.  No regexp metachars, not even .
-# We may fix this later.
-goodglobre = re.compile("[*/%s]+$" % allowedchars)
-def globmatch(pattern, match):
-    if goodglobre.match(pattern) is None:
-        fail("Bad glob pattern in auth config: %s" % pattern)
-    pattern = pattern.replace(".", r'\.')
-    pattern = pattern.replace("*", "[%s]*" % allowedchars)
-    return re.compile(pattern + "$").match(match) is not None
+    return path
 
-def testrule(rulefile, keyname, path, applicable):
-    goodpath(keyname)
-    goodpath(path)
-    f = open(rulefile)
-    try:
-        for l in f:
-            l = l.strip()
-            if l == "" or l.startswith("#"):
-                continue
-            rule, rk, rp = l.split()
-            if globmatch(rk, keyname) and globmatch(rp, path):
-                #logfile.write("Used rule: %s\n" % l)
-                return rule in applicable
-    finally:
-        f.close()
-    return False
-
-def get_cmd(rulefile, keyname, cmd):
+def get_cmd(rules, remoteuser, cmd):
     if cmd.startswith('hg -R ') and cmd.endswith(' serve --stdio'):
-        path = cmd[6:-14]
-        if testrule(rulefile, keyname, path, set(["allow", "init"])):
-            return ['-R', path, 'serve', '--stdio']
+        repo = getpath(cmd[6:-14])
+        if rules.allow("read", user=remoteuser, repo=repo, file=None):
+            os.environ["HG_REPO_PATH"] = repo
+            return ['-R', repo, 'serve', '--stdio']
     elif cmd.startswith('hg init '):
-        path = cmd[8:]
-        if testrule(rulefile, keyname, path, set(["init"])):
-            return ['init', path]
+        repo = getpath(cmd[8:])
+        if rules.allow("init", user=remoteuser, repo=repo, file=None):
+            os.environ["HG_REPO_PATH"] = repo
+            return ['init', repo]
     fail("Illegal command %r" % cmd)
 
 #logfile = open("/tmp/hg-ssh.%d.txt" % os.getpid(), "w")
 #logfile.write("Started: %s\n" % sys.argv)
 
-if len(sys.argv) != 3:
-    fail("hg-ssh must have exactly two arguments (%s)" 
+if len(sys.argv) != 1:
+    fail("hg-ssh must have no arguments (%s)" 
         % sys.argv)
 
-rulefile = sys.argv[1]
-keyname = sys.argv[2]
-todispatch = get_cmd(rulefile, keyname, 
+rules = rules.Ruleset.readfile(os.environ['HG_ACCESS_RULES_FILE'])
+remoteuser = getpath(os.environ['REMOTE_USER'])
+todispatch = get_cmd(rules, remoteuser, 
     os.environ.get('SSH_ORIGINAL_COMMAND', '?'))
 dispatch.dispatch(todispatch)
 
--- a/hg-ssh-wrapper	Mon Apr 21 12:37:56 2008 +0100
+++ b/hg-ssh-wrapper	Tue Apr 22 09:46:29 2008 +0100
@@ -15,14 +15,16 @@
 set -e
 
 # Use a different hgrc for remote pulls - this way you can set
-# up notify for everything at once without affecting local operations
-# 
-# HGRCPATH=$(pwd)/remote-hgrc
-# export HGRCPATH
+# up access.py for everything at once without affecting local operations
+
+HGRCPATH=$(pwd)/remote-hgrc
+export HGRCPATH
 
 # Set up this environment variable - useful for hg hooks to check.
-HG_ACL_USER=$1
-export HG_ACL_USER
+REMOTE_USER=$1
+export REMOTE_USER
 
 cd repos
-exec ../admin/hg-admin-tools/hg-ssh hgadmin/hg-ssh-access.conf "$@"
+HG_ACCESS_RULES_FILE=$(pwd)/hgadmin/hg-ssh-access.conf
+export HG_ACCESS_RULES_FILE
+exec ../admin/hg-admin-tools/hg-ssh 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/remote-hgrc	Tue Apr 22 09:46:29 2008 +0100
@@ -0,0 +1,7 @@
+# hgrc to use for all remote users
+
+[extensions]
+access = /home/hg/admin/hg-admin-tools/access.py
+
+[hooks]
+pretxnchangegroup.access = python:access.hook
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/rules.py	Tue Apr 22 09:46:29 2008 +0100
@@ -0,0 +1,81 @@
+# Copyright 2008 LShift Ltd
+# Author(s):
+# Paul Crowley <paul@lshift.net>
+#
+# This software may be used and distributed according to the terms
+# of the GNU General Public License, incorporated herein by reference.
+
+import re
+
+allowedchars = "A-Za-z0-9_-"
+
+goodpathre = re.compile("([%s]+/)*[%s]+$" % (allowedchars, allowedchars))
+def goodpath(path):
+    return goodpathre.match(path) is not None:
+
+goodglobre = re.compile("[*/%s]+$" % allowedchars)
+
+def goodglob(pattern):
+    return goodglobre.match(pattern) is not None
+
+# Don't put anything except *A-Za-z0-9_- in rule globs or   
+# it will match nothing.  No regexp metachars, not even .
+# We may fix this later.
+def globmatcher(pattern):
+    if not goodglob(pattern):
+        #fail("Bad glob pattern in auth config: %s" % pattern)
+        # FIXME: report it somehow
+        return lambda x: False
+    # Substitution cunning so ** can be different from *
+    pattern = pattern.replace("*", "[]")
+    pattern = pattern.replace("[][]", "[/%s]*" % allowedchars)
+    pattern = pattern.replace("[]", "[%s]*" % allowedchars)
+    rex = re.compile(pattern + "$")
+    # None matches everything
+    return lambda x: x is None or rex.match(x) is not None
+
+def rule(pairs):
+    matchers = [(k, globmatcher(v)) for k, v in pairs]
+    def c(**kw):
+        for k, m in matchers:
+            if k not in kw or not m(kw[k]):
+                return False
+        return True
+    return c
+
+class Ruleset(object):
+    '''Class representing the rules in a rule file'''
+    
+    levels = ["init", "write", "read", "deny"]
+
+    def __init__(self):
+        self.rules = []
+
+    def add(self, action, conditions):
+        self.rules.append((action, conditions))
+
+    def matchrule(self, **kw):
+        for a, c in self.rules:
+            if c(**kw):
+                return a
+        return None
+
+    def allow(self, level, **kw):
+        a = matchrule(self, **kw)
+        return a in self.levels and self.levels.index(a) <= self.levels.index(level)
+    
+    @classmethod
+    def read_rules(cls, fn):
+        res = cls()
+        f = open(fn)
+        try:
+            for l in f:
+                l = l.strip()
+                if len(l) == 0 or l.startswith["#"]:
+                    continue
+                res.add(l[0], rule([c.split("=", 1) for c in l[1:]]))
+        finally:
+            f.close()
+        return res
+
+