Big change to support file conditions; format of hg-ssh-access.conf
changed.
--- 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
+
+