mirror of
https://github.com/git/git
synced 2024-08-28 20:20:07 +00:00
![Shawn O. Pearce](/assets/img/avatar_default.png)
In some applications of the update hook a user may be allowed to modify a branch, but only if the file level difference is also an allowed change. This is the commonly requested feature of allowing users to modify only certain files. A new repository.*.allow syntax permits granting the three basic file level operations: A: file is added relative to the other tree M: file exists in both trees, but its SHA-1 or mode differs D: file is removed relative to the other tree on a per-branch and path-name basis. The user must also have a branch level allow line already granting them access to create, rewind or update (CRU) that branch before the hook will consult any file level rules. In order for a branch change to succeed _all_ files that differ relative to some base (by default the old value of this branch, but it can also be any valid tree-ish) must be allowed by file level allow rules. A push is rejected if any diff exists that is not covered by at least one allow rule. Signed-off-by: Shawn O. Pearce <spearce@spearce.org> Signed-off-by: Junio C Hamano <gitster@pobox.com>
411 lines
11 KiB
Perl
411 lines
11 KiB
Perl
#!/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;
|
|
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";
|
|
open(T,'-|','git','diff-tree',
|
|
'-r','--name-status','-z',
|
|
$base,$new) or return undef;
|
|
my %this_diff;
|
|
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;
|
|
|
|
$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";
|