From: Junio C Hamano <junkio@cox.net> and Carl Baldwin <cnb@fc.hp.com>
Subject: control access to branches.
Date: Thu, 17 Nov 2005 23:55:32 -0800
Message-ID: <7vfypumlu3.fsf@assigned-by-dhcp.cox.net>
Abstract: An example hooks/update script is presented to
 implement repository maintenance policies, such as who can push
 into which branch and who can make a tag.

When your developer runs git-push into the repository,
git-receive-pack is run (either locally or over ssh) as that
developer, so is hooks/update script.  Quoting from the relevant
section of the documentation:

    Before each ref is updated, if $GIT_DIR/hooks/update file exists
    and executable, it is called with three parameters:

           $GIT_DIR/hooks/update refname sha1-old sha1-new

    The refname parameter is relative to $GIT_DIR; e.g. for the
    master head this is "refs/heads/master".  Two sha1 are the
    object names for the refname before and after the update.  Note
    that the hook is called before the refname is updated, so either
    sha1-old is 0{40} (meaning there is no such ref yet), or it
    should match what is recorded in refname.

So if your policy is (1) always require fast-forward push
(i.e. never allow "git-push repo +branch:branch"), (2) you
have a list of users allowed to update each branch, and (3) you
do not let tags to be overwritten, then you can use something
like this as your hooks/update script.

[jc: editorial note.  This is a much improved version by Carl
since I posted the original outline]

-- >8 -- beginning of script -- >8 --

#!/bin/bash

umask 002

# If you are having trouble with this access control hook script
# you can try setting this to true.  It will tell you exactly
# why a user is being allowed/denied access.

verbose=false

# Default shell globbing messes things up downstream
GLOBIGNORE=*

function grant {
  $verbose && echo >&2 "-Grant-		$1"
  echo grant
  exit 0
}

function deny {
  $verbose && echo >&2 "-Deny-		$1"
  echo deny
  exit 1
}

function info {
  $verbose && echo >&2 "-Info-		$1"
}

# Implement generic branch and tag policies.
# - Tags should not be updated once created.
# - Branches should only be fast-forwarded.
case "$1" in
  refs/tags/*)
    git rev-parse --verify -q "$1" &&
    deny >/dev/null "You can't overwrite an existing tag"
    ;;
  refs/heads/*)
    # No rebasing or rewinding
    if expr "$2" : '0*$' >/dev/null; then
      info "The branch '$1' is new..."
    else
      # updating -- make sure it is a fast forward
      mb=$(git-merge-base "$2" "$3")
      case "$mb,$2" in
        "$2,$mb") info "Update is fast-forward" ;;
        *)        deny >/dev/null  "This is not a fast-forward update." ;;
      esac
    fi
    ;;
  *)
    deny >/dev/null \
    "Branch is not under refs/heads or refs/tags.  What are you trying to do?"
    ;;
esac

# Implement per-branch controls based on username
allowed_users_file=$GIT_DIR/info/allowed-users
username=$(id -u -n)
info "The user is: '$username'"

if [ -f "$allowed_users_file" ]; then
  rc=$(cat $allowed_users_file | grep -v '^#' | grep -v '^$' |
    while read head_pattern user_patterns; do
      matchlen=$(expr "$1" : "$head_pattern")
      if [ "$matchlen" == "${#1}" ]; then
        info "Found matching head pattern: '$head_pattern'"
        for user_pattern in $user_patterns; do
          info "Checking user: '$username' against pattern: '$user_pattern'"
          matchlen=$(expr "$username" : "$user_pattern")
          if [ "$matchlen" == "${#username}" ]; then
            grant "Allowing user: '$username' with pattern: '$user_pattern'"
          fi
        done
        deny "The user is not in the access list for this branch"
      fi
    done
  )
  case "$rc" in
    grant) grant >/dev/null "Granting access based on $allowed_users_file" ;;
    deny)  deny  >/dev/null "Denying  access based on $allowed_users_file" ;;
    *) ;;
  esac
fi

allowed_groups_file=$GIT_DIR/info/allowed-groups
groups=$(id -G -n)
info "The user belongs to the following groups:"
info "'$groups'"

if [ -f "$allowed_groups_file" ]; then
  rc=$(cat $allowed_groups_file | grep -v '^#' | grep -v '^$' |
    while read head_pattern group_patterns; do
      matchlen=$(expr "$1" : "$head_pattern")
      if [ "$matchlen" == "${#1}" ]; then
        info "Found matching head pattern: '$head_pattern'"
        for group_pattern in $group_patterns; do
          for groupname in $groups; do
            info "Checking group: '$groupname' against pattern: '$group_pattern'"
            matchlen=$(expr "$groupname" : "$group_pattern")
            if [ "$matchlen" == "${#groupname}" ]; then
              grant "Allowing group: '$groupname' with pattern: '$group_pattern'"
            fi
          done
        done
        deny "None of the user's groups are in the access list for this branch"
      fi
    done
  )
  case "$rc" in
    grant) grant >/dev/null "Granting access based on $allowed_groups_file" ;;
    deny)  deny  >/dev/null "Denying  access based on $allowed_groups_file" ;;
    *) ;;
  esac
fi

deny >/dev/null "There are no more rules to check.  Denying access"

-- >8 -- end of script -- >8 --

This uses two files, $GIT_DIR/info/allowed-users and
allowed-groups, to describe which heads can be pushed into by
whom.  The format of each file would look like this:

        refs/heads/master	junio
        refs/heads/cogito$	pasky
        refs/heads/bw/.*	linus
        refs/heads/tmp/.*	.*
        refs/tags/v[0-9].*	junio

With this, Linus can push or create "bw/penguin" or "bw/zebra"
or "bw/panda" branches, Pasky can do only "cogito", and JC can
do master branch and make versioned tags.  And anybody can do
tmp/blah branches.

------------
