| #!/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 abbrevations 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"; |