| #!/usr/bin/perl | |
| use strict; | |
| use File::Spec; | |
| $ENV{PATH} = '/opt/git/bin'; | |
| my $acl_git = '/vcs/acls.git'; | |
| my $acl_branch = 'refs/heads/master'; | |
| my $debug = 0; | |
| =doc | |
| Invoked as: update refname old-sha1 new-sha1 | |
| This script is run by git-receive-pack once for each ref that the | |
| client is trying to modify. If we exit with a non-zero exit value | |
| then the update for that particular ref is denied, but updates for | |
| other refs in the same run of receive-pack may still be allowed. | |
| We are run after the objects have been uploaded, but before the | |
| ref is actually modified. We take advantage of that fact when we | |
| look for "new" commits and tags (the new objects won't show up in | |
| `rev-list --all`). | |
| This script loads and parses the content of the config file | |
| "users/$this_user.acl" from the $acl_branch commit of $acl_git ODB. | |
| The acl file is a git-config style file, but uses a slightly more | |
| restricted syntax as the Perl parser contained within this script | |
| is not nearly as permissive as git-config. | |
| Example: | |
| [user] | |
| committer = John Doe <john.doe@example.com> | |
| committer = John R. Doe <john.doe@example.com> | |
| [repository "acls"] | |
| allow = heads/master | |
| allow = CDUR for heads/jd/ | |
| allow = C for ^tags/v\\d+$ | |
| For all new commit or tag objects the committer (or tagger) line | |
| within the object must exactly match one of the user.committer | |
| values listed in the acl file ("HEAD:users/$this_user.acl"). | |
| For a branch to be modified an allow line within the matching | |
| repository section must be matched for both the refname and the | |
| opcode. | |
| Repository sections are matched on the basename of the repository | |
| (after removing the .git suffix). | |
| The opcode abbreviations are: | |
| C: create new ref | |
| D: delete existing ref | |
| U: fast-forward existing ref (no commit loss) | |
| R: rewind/rebase existing ref (commit loss) | |
| if no opcodes are listed before the "for" keyword then "U" (for | |
| fast-forward update only) is assumed as this is the most common | |
| usage. | |
| Refnames are matched by always assuming a prefix of "refs/". | |
| This hook forbids pushing or deleting anything not under "refs/". | |
| Refnames that start with ^ are Perl regular expressions, and the ^ | |
| is kept as part of the regexp. \\ is needed to get just one \, so | |
| \\d expands to \d in Perl. The 3rd allow line above is an example. | |
| Refnames that don't start with ^ but that end with / are prefix | |
| matches (2nd allow line above); all other refnames are strict | |
| equality matches (1st allow line). | |
| Anything pushed to "heads/" (ok, really "refs/heads/") must be | |
| a commit. Tags are not permitted here. | |
| Anything pushed to "tags/" (err, really "refs/tags/") must be an | |
| annotated tag. Commits, blobs, trees, etc. are not permitted here. | |
| Annotated tag signatures aren't checked, nor are they required. | |
| The special subrepository of 'info/new-commit-check' can | |
| be created and used to allow users to push new commits and | |
| tags from another local repository to this one, even if they | |
| aren't the committer/tagger of those objects. In a nut shell | |
| the info/new-commit-check directory is a Git repository whose | |
| objects/info/alternates file lists this repository and all other | |
| possible sources, and whose refs subdirectory contains symlinks | |
| to this repository's refs subdirectory, and to all other possible | |
| sources refs subdirectories. Yes, this means that you cannot | |
| use packed-refs in those repositories as they won't be resolved | |
| correctly. | |
| =cut | |
| my $git_dir = $ENV{GIT_DIR}; | |
| my $new_commit_check = "$git_dir/info/new-commit-check"; | |
| my $ref = $ARGV[0]; | |
| my $old = $ARGV[1]; | |
| my $new = $ARGV[2]; | |
| my $new_type; | |
| my ($this_user) = getpwuid $<; # REAL_USER_ID | |
| my $repository_name; | |
| my %user_committer; | |
| my @allow_rules; | |
| my @path_rules; | |
| my %diff_cache; | |
| sub deny ($) { | |
| print STDERR "-Deny- $_[0]\n" if $debug; | |
| print STDERR "\ndenied: $_[0]\n\n"; | |
| exit 1; | |
| } | |
| sub grant ($) { | |
| print STDERR "-Grant- $_[0]\n" if $debug; | |
| exit 0; | |
| } | |
| sub info ($) { | |
| print STDERR "-Info- $_[0]\n" if $debug; | |
| } | |
| sub git_value (@) { | |
| open(T,'-|','git',@_); local $_ = <T>; chop; close T; $_; | |
| } | |
| sub match_string ($$) { | |
| my ($acl_n, $ref) = @_; | |
| ($acl_n eq $ref) | |
| || ($acl_n =~ m,/$, && substr($ref,0,length $acl_n) eq $acl_n) | |
| || ($acl_n =~ m,^\^, && $ref =~ m:$acl_n:); | |
| } | |
| sub parse_config ($$$$) { | |
| my $data = shift; | |
| local $ENV{GIT_DIR} = shift; | |
| my $br = shift; | |
| my $fn = shift; | |
| return unless git_value('rev-list','--max-count=1',$br,'--',$fn); | |
| info "Loading $br:$fn"; | |
| open(I,'-|','git','cat-file','blob',"$br:$fn"); | |
| my $section = ''; | |
| while (<I>) { | |
| chomp; | |
| if (/^\s*$/ || /^\s*#/) { | |
| } elsif (/^\[([a-z]+)\]$/i) { | |
| $section = lc $1; | |
| } elsif (/^\[([a-z]+)\s+"(.*)"\]$/i) { | |
| $section = join('.',lc $1,$2); | |
| } elsif (/^\s*([a-z][a-z0-9]+)\s*=\s*(.*?)\s*$/i) { | |
| push @{$data->{join('.',$section,lc $1)}}, $2; | |
| } else { | |
| deny "bad config file line $. in $br:$fn"; | |
| } | |
| } | |
| close I; | |
| } | |
| sub all_new_committers () { | |
| local $ENV{GIT_DIR} = $git_dir; | |
| $ENV{GIT_DIR} = $new_commit_check if -d $new_commit_check; | |
| info "Getting committers of new commits."; | |
| my %used; | |
| open(T,'-|','git','rev-list','--pretty=raw',$new,'--not','--all'); | |
| while (<T>) { | |
| next unless s/^committer //; | |
| chop; | |
| s/>.*$/>/; | |
| info "Found $_." unless $used{$_}++; | |
| } | |
| close T; | |
| info "No new commits." unless %used; | |
| keys %used; | |
| } | |
| sub all_new_taggers () { | |
| my %exists; | |
| open(T,'-|','git','for-each-ref','--format=%(objectname)','refs/tags'); | |
| while (<T>) { | |
| chop; | |
| $exists{$_} = 1; | |
| } | |
| close T; | |
| info "Getting taggers of new tags."; | |
| my %used; | |
| my $obj = $new; | |
| my $obj_type = $new_type; | |
| while ($obj_type eq 'tag') { | |
| last if $exists{$obj}; | |
| $obj_type = ''; | |
| open(T,'-|','git','cat-file','tag',$obj); | |
| while (<T>) { | |
| chop; | |
| if (/^object ([a-z0-9]{40})$/) { | |
| $obj = $1; | |
| } elsif (/^type (.+)$/) { | |
| $obj_type = $1; | |
| } elsif (s/^tagger //) { | |
| s/>.*$/>/; | |
| info "Found $_." unless $used{$_}++; | |
| last; | |
| } | |
| } | |
| close T; | |
| } | |
| info "No new tags." unless %used; | |
| keys %used; | |
| } | |
| sub check_committers (@) { | |
| my @bad; | |
| foreach (@_) { push @bad, $_ unless $user_committer{$_}; } | |
| if (@bad) { | |
| print STDERR "\n"; | |
| print STDERR "You are not $_.\n" foreach (sort @bad); | |
| deny "You cannot push changes not committed by you."; | |
| } | |
| } | |
| sub load_diff ($) { | |
| my $base = shift; | |
| my $d = $diff_cache{$base}; | |
| unless ($d) { | |
| local $/ = "\0"; | |
| my %this_diff; | |
| if ($base =~ /^0{40}$/) { | |
| # Don't load the diff at all; we are making the | |
| # branch and have no base to compare to in this | |
| # case. A file level ACL makes no sense in this | |
| # context. Having an empty diff will allow the | |
| # branch creation. | |
| # | |
| } else { | |
| open(T,'-|','git','diff-tree', | |
| '-r','--name-status','-z', | |
| $base,$new) or return undef; | |
| while (<T>) { | |
| my $op = $_; | |
| chop $op; | |
| my $path = <T>; | |
| chop $path; | |
| $this_diff{$path} = $op; | |
| } | |
| close T or return undef; | |
| } | |
| $d = \%this_diff; | |
| $diff_cache{$base} = $d; | |
| } | |
| return $d; | |
| } | |
| deny "No GIT_DIR inherited from caller" unless $git_dir; | |
| deny "Need a ref name" unless $ref; | |
| deny "Refusing funny ref $ref" unless $ref =~ s,^refs/,,; | |
| deny "Bad old value $old" unless $old =~ /^[a-z0-9]{40}$/; | |
| deny "Bad new value $new" unless $new =~ /^[a-z0-9]{40}$/; | |
| deny "Cannot determine who you are." unless $this_user; | |
| grant "No change requested." if $old eq $new; | |
| $repository_name = File::Spec->rel2abs($git_dir); | |
| $repository_name =~ m,/([^/]+)(?:\.git|/\.git)$,; | |
| $repository_name = $1; | |
| info "Updating in '$repository_name'."; | |
| my $op; | |
| if ($old =~ /^0{40}$/) { $op = 'C'; } | |
| elsif ($new =~ /^0{40}$/) { $op = 'D'; } | |
| else { $op = 'R'; } | |
| # This is really an update (fast-forward) if the | |
| # merge base of $old and $new is $old. | |
| # | |
| $op = 'U' if ($op eq 'R' | |
| && $ref =~ m,^heads/, | |
| && $old eq git_value('merge-base',$old,$new)); | |
| # Load the user's ACL file. Expand groups (user.memberof) one level. | |
| { | |
| my %data = ('user.committer' => []); | |
| parse_config(\%data,$acl_git,$acl_branch,"external/$repository_name.acl"); | |
| %data = ( | |
| 'user.committer' => $data{'user.committer'}, | |
| 'user.memberof' => [], | |
| ); | |
| parse_config(\%data,$acl_git,$acl_branch,"users/$this_user.acl"); | |
| %user_committer = map {$_ => $_} @{$data{'user.committer'}}; | |
| my $rule_key = "repository.$repository_name.allow"; | |
| my $rules = $data{$rule_key} || []; | |
| foreach my $group (@{$data{'user.memberof'}}) { | |
| my %g; | |
| parse_config(\%g,$acl_git,$acl_branch,"groups/$group.acl"); | |
| my $group_rules = $g{$rule_key}; | |
| push @$rules, @$group_rules if $group_rules; | |
| } | |
| RULE: | |
| foreach (@$rules) { | |
| while (/\${user\.([a-z][a-zA-Z0-9]+)}/) { | |
| my $k = lc $1; | |
| my $v = $data{"user.$k"}; | |
| next RULE unless defined $v; | |
| next RULE if @$v != 1; | |
| next RULE unless defined $v->[0]; | |
| s/\${user\.$k}/$v->[0]/g; | |
| } | |
| if (/^([AMD ]+)\s+of\s+([^\s]+)\s+for\s+([^\s]+)\s+diff\s+([^\s]+)$/) { | |
| my ($ops, $pth, $ref, $bst) = ($1, $2, $3, $4); | |
| $ops =~ s/ //g; | |
| $pth =~ s/\\\\/\\/g; | |
| $ref =~ s/\\\\/\\/g; | |
| push @path_rules, [$ops, $pth, $ref, $bst]; | |
| } elsif (/^([AMD ]+)\s+of\s+([^\s]+)\s+for\s+([^\s]+)$/) { | |
| my ($ops, $pth, $ref) = ($1, $2, $3); | |
| $ops =~ s/ //g; | |
| $pth =~ s/\\\\/\\/g; | |
| $ref =~ s/\\\\/\\/g; | |
| push @path_rules, [$ops, $pth, $ref, $old]; | |
| } elsif (/^([CDRU ]+)\s+for\s+([^\s]+)$/) { | |
| my $ops = $1; | |
| my $ref = $2; | |
| $ops =~ s/ //g; | |
| $ref =~ s/\\\\/\\/g; | |
| push @allow_rules, [$ops, $ref]; | |
| } elsif (/^for\s+([^\s]+)$/) { | |
| # Mentioned, but nothing granted? | |
| } elsif (/^[^\s]+$/) { | |
| s/\\\\/\\/g; | |
| push @allow_rules, ['U', $_]; | |
| } | |
| } | |
| } | |
| if ($op ne 'D') { | |
| $new_type = git_value('cat-file','-t',$new); | |
| if ($ref =~ m,^heads/,) { | |
| deny "$ref must be a commit." unless $new_type eq 'commit'; | |
| } elsif ($ref =~ m,^tags/,) { | |
| deny "$ref must be an annotated tag." unless $new_type eq 'tag'; | |
| } | |
| check_committers (all_new_committers); | |
| check_committers (all_new_taggers) if $new_type eq 'tag'; | |
| } | |
| info "$this_user wants $op for $ref"; | |
| foreach my $acl_entry (@allow_rules) { | |
| my ($acl_ops, $acl_n) = @$acl_entry; | |
| next unless $acl_ops =~ /^[CDRU]+$/; # Uhh.... shouldn't happen. | |
| next unless $acl_n; | |
| next unless $op =~ /^[$acl_ops]$/; | |
| next unless match_string $acl_n, $ref; | |
| # Don't test path rules on branch deletes. | |
| # | |
| grant "Allowed by: $acl_ops for $acl_n" if $op eq 'D'; | |
| # Aggregate matching path rules; allow if there aren't | |
| # any matching this ref. | |
| # | |
| my %pr; | |
| foreach my $p_entry (@path_rules) { | |
| my ($p_ops, $p_n, $p_ref, $p_bst) = @$p_entry; | |
| next unless $p_ref; | |
| push @{$pr{$p_bst}}, $p_entry if match_string $p_ref, $ref; | |
| } | |
| grant "Allowed by: $acl_ops for $acl_n" unless %pr; | |
| # Allow only if all changes against a single base are | |
| # allowed by file path rules. | |
| # | |
| my @bad; | |
| foreach my $p_bst (keys %pr) { | |
| my $diff_ref = load_diff $p_bst; | |
| deny "Cannot difference trees." unless ref $diff_ref; | |
| my %fd = %$diff_ref; | |
| foreach my $p_entry (@{$pr{$p_bst}}) { | |
| my ($p_ops, $p_n, $p_ref, $p_bst) = @$p_entry; | |
| next unless $p_ops =~ /^[AMD]+$/; | |
| next unless $p_n; | |
| foreach my $f_n (keys %fd) { | |
| my $f_op = $fd{$f_n}; | |
| next unless $f_op; | |
| next unless $f_op =~ /^[$p_ops]$/; | |
| delete $fd{$f_n} if match_string $p_n, $f_n; | |
| } | |
| last unless %fd; | |
| } | |
| if (%fd) { | |
| push @bad, [$p_bst, \%fd]; | |
| } else { | |
| # All changes relative to $p_bst were allowed. | |
| # | |
| grant "Allowed by: $acl_ops for $acl_n diff $p_bst"; | |
| } | |
| } | |
| foreach my $bad_ref (@bad) { | |
| my ($p_bst, $fd) = @$bad_ref; | |
| print STDERR "\n"; | |
| print STDERR "Not allowed to make the following changes:\n"; | |
| print STDERR "(base: $p_bst)\n"; | |
| foreach my $f_n (sort keys %$fd) { | |
| print STDERR " $fd->{$f_n} $f_n\n"; | |
| } | |
| } | |
| deny "You are not permitted to $op $ref"; | |
| } | |
| close A; | |
| deny "You are not permitted to $op $ref"; |