Easy git access control - Part 2 - more granularity

In my previous post i talked about how to easily manage write permissions on a git repository.
As i said, the script could easily be extented to provide more granularity.
Here comes an improved version!

Features:

  • Support groups
  • Support nested groups
  • Let's you name the admin group
  • Let's you give full rights to admin group
  • Let's you give user the right to push to his/her own branch - branch named after user name

Here's an example git.acl:

[global]
# Name of the admin group
admin_group     = admin
# Is admin group able to push everything? 0|1
full_admin      = 1
# Is user able to push in his named branch? 0|1
owner_branch    = 1

[groups]
admin       = olivier joe
commiters   = @admin jane jim
project1    = @admin @commiters @project2
project2    = @admin john mickael

[branches]
master      = @admin
project1    = @project1
test_branch = sam

And here is the updated update hook:

#!/bin/env python2
import os
import sys
import subprocess
import ConfigParser
#from pprint import pprint

# Exit on error
def error_exit(string):
    print string
    sys.exit(1)

# Exit on success
def ok_exit(string):
    print string
    sys.exit(0)

# Recursively get all users from a group
def expand_nested_groups(groups, group, user, parsed_groups=[]):
    parsed_groups.append(group)
    if user.startswith('@'):
        group_name = user[1:]
        if group_name in parsed_groups:
            return []
        res = set()
        for u in groups[group_name]:
            res.update(expand_nested_groups(
                groups,
                group_name,
                u,
                parsed_groups))
        return res
    return [user]

# Verify that acl_file exists before reading it
def _config():
    # Fix acl file path if needed
    exec_path = os.getcwd()
    if '/hooks/' in exec_path:
        acl_file = './git.acl'
    else:
        acl_file = './hooks/git.acl'

    # Open and read acl file if exists
    if os.path.isfile(acl_file):
        config = ConfigParser.RawConfigParser()
        if not config.read(acl_file):
            error_exit(acl_file+" doesn't seem to be a valid config file!")
        return config
    else:
        error_exit(acl_file+" is missing!")

# Returns global settings 
def _config_global(config, option, is_int):
    # Verify if global section exists
    if config.has_section('global'):
        if config.has_option('global', option):
            if is_int:
                return config.getint('global', option)
            else:
                return config.get('global', option)
        else:
            error_exit(str(option)+" option doesn't exists in 'global' section!")
    else:
        error_exit("'[global]' section not found in your acl file!")

# Retuns a dict of groups
def _config_groups(config):
    if config.has_section('groups'):
        group_list = config.items('groups')
        if group_list:
            group_dict = dict((e[0], e[1].split()) for e in group_list)
            #pprint(group_dict)

            # Let's expand groups
            expanded_groups = {}
            for group, users in group_dict.iteritems():
                expanded_groups[group] = set()
                for user in users:
                    expanded_users = expand_nested_groups(
                        group_dict,
                        group,
                        user,
                        [])
                    expanded_groups[group].update(expanded_users)

            return expanded_groups
        else:
            error_exit("Your '[groups]' section seems to have no options...")
    else:
        error_exit("'[groups]' section not found in your acl file!")

# Returns a list of users allowed for a branch
def _config_branch(config, branch):
    if config.has_section('branches'):
        if config.has_option('branches', branch):
            return config.get('branches', branch).split()
        else:
            error_exit("This branch doesn't have any right set!")
    else:
        error_exit("'[branches]' section not found in your acl file!")

def main():
    # Get arguments
    refname = sys.argv[1]
    oldrev = sys.argv[2]
    newrev = sys.argv[3]
    gituser = os.getenv('USER')

    # Declare object types
    objtype = ['commit', 'delete', 'tag']

    # Init acl file reading
    acl_config = _config()

    # Get global settings
    admin_group = _config_global(acl_config, 'admin_group', 0)
    full_admin = _config_global(acl_config, 'full_admin', 1)
    owner_branch = _config_global(acl_config, 'owner_branch', 1)

    # Get group list
    g = _config_groups(acl_config)
    #pprint(g)

    # Get branch name
    exp_refname = refname.split('/')
    branch_name = exp_refname[2]

    # Printing this only for information
    print "branch="+branch_name
    print "oldrev="+oldrev
    print "newrev="+newrev
    print "user="+gituser

    # Get object type
    cmd = 'git cat-file -t %s' % (newrev)
    process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=None, shell=True)
    output = process.communicate()
    thistype = output[0][:-1]
    print "object="+thistype

    # Reject unknown object types 
    if thistype not in objtype:
        error_exit("You can't push this type of objects")

    ### Access right checking ###
    # Admin processing
    if full_admin and gituser in g[admin_group]:
        ok_exit('ADMIN ACCESS GRANTED')

    # user writes to his own branch
    if owner_branch and gituser == branch_name:    
        ok_exit('BRANCH OWNER ACCESS GRANTED')

    # Not admin nor branch owner
    # Get branch access rights
    branch_access = _config_branch(acl_config, branch_name)

    # Branch exists in git.acl check if user has rights
    # Start with group
    for right in branch_access:
        if '@' in right and gituser in g[right.strip('@')]:
            ok_exit('BRANCH ACCESS GRANTED')

    # Not in a group so do a basic check
    if gituser in branch_access:
        ok_exit('BRANCH ACCESS GRANTED')
    else:
        error_exit('BRANCH ACCESS DENIED')

    # Catchall exit, means we haven't match any case...
    sys.exit(1)


if __name__ == '__main__':
    main()

Thanks to Steeve Chailloux for his group expansion function which was way better than mine :)

Next time let's support rights on tags ;)