# HG changeset patch # User Paul Crowley # Date 1208853989 -3600 # Node ID 538d6b198f4a17be01feaa79ae1e97e501f96d3c # Parent 4c98440de85177987daf257132356725cdce44fc Big change to support file conditions; format of hg-ssh-access.conf changed. diff -r 4c98440de851 -r 538d6b198f4a README --- 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 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= - user's key +repo= - repo (as the user supplies it) +file= - 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 diff -r 4c98440de851 -r 538d6b198f4a access.py --- 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 +# Copyright 2008 LShift Ltd # Copyright 2006 Vadim Gelfer # +# Authors: +# Paul Crowley +# Vadim Gelfer +# # 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): diff -r 4c98440de851 -r 538d6b198f4a hg-ssh --- 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 -# Author(s): +# Authors: # Paul Crowley # Thomas Arendsen Hein # with ideas from Mathieu PASQUET @@ -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 +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) diff -r 4c98440de851 -r 538d6b198f4a hg-ssh-wrapper --- 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 diff -r 4c98440de851 -r 538d6b198f4a remote-hgrc --- /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 diff -r 4c98440de851 -r 538d6b198f4a rules.py --- /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 +# +# 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 + +