diff --git a/contrib/adc/get-rights-and-owner.in-perl b/contrib/adc/get-rights-and-owner.in-perl index eec88d0..7c52899 100755 --- a/contrib/adc/get-rights-and-owner.in-perl +++ b/contrib/adc/get-rights-and-owner.in-perl @@ -41,8 +41,9 @@ my $ret = check_access($repo, 'refs/heads/foo', 'W', 1); # the return value may look like this: # refs/.* -# or perhaps this, if you were denied -# DENIED by fallthru +# On failure, it will have the reason, and it will contain the word DENIED +# somewhere, for example: +# W refs/heads/master foo u1 DENIED by fallthru # NOTE: do NOT pass "R" as the 3rd argument. It will seem to work because # you're merely testing the permissions in this code, but an *actual* "git diff --git a/hooks/common/update b/hooks/common/update index 1048208..bab836c 100755 --- a/hooks/common/update +++ b/hooks/common/update @@ -28,6 +28,7 @@ use gitolite qw(:DEFAULT %repos); # ---------------------------------------------------------------------------- my ($perm, $creator, $wild) = repo_rights($ENV{GL_REPO}); +trace("update: repo_rights perm is $perm"); my $reported_repo = $ENV{GL_REPO} . ( $wild ? " ($wild)" : "" ); # arguments are as supplied to an update hook by git; man githooks @@ -69,40 +70,80 @@ if ( $repos{ $ENV{GL_REPO} }{MERGE_CHECK} or $repos{'@all'}{MERGE_CHECK} ) { } my @allowed_refs; -# @all repos: see comments in similar code in check_access +# @all repos: see comments in similar code in check_score push @allowed_refs, @ { $repos{$ENV{GL_REPO}}{$ENV{GL_USER}} || [] }; push @allowed_refs, @ { $repos{'@all'} {$ENV{GL_USER}} || [] }; push @allowed_refs, @ { $repos{$ENV{GL_REPO}}{'@all'} || [] }; push @allowed_refs, @ { $repos{'@all'} {'@all'} || [] }; -# prepare the list of refs to be checked +my $minscore = 10; -# previously, we just checked $ref -- the ref being updated, which is passed -# to us by git (see man githooks). Now we also have to treat each NAME being -# updated as a potential "ref" and check that, if NAME-based restrictions have -# been specified +# check the first ref -- this is a real (git) ref, like refs/heads/master +my ($score, $txt) = check_ref(\@allowed_refs, $ENV{GL_REPO}, $ref, $att_acc); +trace("update: score $score trying $att_acc on ref $ref"); +my $log_refex = $txt; # save this for printing in the log record +my $denymsg = $txt if $score < 10; -my @refs = ($ref); # the first ref to check is the real one -# because making it work screws up efficiency like no tomorrow... -if (exists $repos{$ENV{GL_REPO}}{NAME_LIMITS}) { - # this is special to git -- the hash of an empty tree - my $empty='4b825dc642cb6eb9a060e54bf8d69288fbee4904'; - # well they're not really "trees" but $empty is indeed the empty tree so - # we can just pretend $oldsha/$newsha are also trees, and anyway 'git - # diff' only wants trees - my $oldtree = $oldsha eq '0' x 40 ? $empty : $oldsha; - my $newtree = $newsha eq '0' x 40 ? $empty : $newsha; - push @refs, map { chomp; s/^/NAME\//; $_; } `git diff --name-only $oldtree $newtree`; +# collect virtual ref types, if any +my %virtreftypes; +for (map { $_->[1] } @allowed_refs) { + $virtreftypes{$1}++ if m(^VR_([\w-]+)/); +} +trace("update: virt refs applicable: " . join(",", sort keys %virtreftypes)) if %virtreftypes; + +# here's how scoring works: +# (1) computing minscore: minimum score is 10 * number of ref types +# [ref types: there's exactly one "real" ref (refs/heads/master, etc), and +# any number of "virt"ual refs. For each ref type applicable to this +# repo+user, add 10 to the minimum score] +# (2) computing total score: each rule specified a score (default 10 for +# accept rules and 0 for deny rules. The score for a reftype is the +# minimum of the rules in that reftype. The total score is the sum of the +# scores of each reftype. [example: 1 real ref, 20 NAME/ refs (20 files +# were changed), is 2 reftypes so minscore is 20. Actual score is whatever +# the real ref returns + minimum(whatever the NAME/ refs return). + +# find virtual refs +for my $vrt (sort keys %virtreftypes) { + my $vscore; + my $match_sub; + + my @refs = get_virt_refs($vrt, $ref, $oldsha, $newsha); + # the first *may* be one of the following words, to indicate what sort of + # matching the virtref is expecting + if ( ($refs[0] || '') =~ m(^VR_$vrt/(regex|lesser|greater|IP)$) ) { + # "regex" is the default; there's no sub for this + $match_sub = \&match_lesser if ($1 eq 'lesser'); + $match_sub = \&match_greater if ($1 eq 'greater'); + $match_sub = \&match_IP if ($1 eq 'IP'); + shift @refs; + } + + # if there were no virtrefs to start with (for example, an empty commit), + # then bumping minscore up causes problems, so bail out + next unless @refs; + # each reftype specified increases the min score by 10 + $minscore += 10; + + # allow only refs that match the $vrt, for efficiency + my @allowed_vrefs = grep { $_->[1] =~ m(^VR_$vrt/) } @allowed_refs; + for my $r (@refs) { + my ($rscore, $txt) = check_ref(\@allowed_vrefs, $ENV{GL_REPO}, $r, $att_acc, $match_sub); + # the score for each vref type is the minimum of the scores for the + # refs of that type + unless (defined($vscore) and $vscore < $rscore) { + $vscore = $rscore; + $denymsg ||= $txt if $rscore < 10; + } + } + $score += $vscore || 0; + trace("update: score $score after adding vscore $vscore using " . scalar(@refs) . " refs from $vrt"); } -# we potentially have many "refs" to check. The one we print in the log is -# the *first* one (which is a *real* ref, like refs/heads/master), while all -# the rest (if they exist) are like NAME/something. So we do the first one -# separately to capture it, then run the rest (if any) -my $log_refex = check_ref(\@allowed_refs, $ENV{GL_REPO}, (shift @refs), $att_acc); -check_ref (\@allowed_refs, $ENV{GL_REPO}, $_ , $att_acc) for @refs; +die (($denymsg || "access DENIED. Score $score < $minscore") . "\n") if $score < $minscore; -# if we returned at all, all the checks succeeded. Check secondary hooks now +# if we came here, all the checks succeeded. Check secondary hooks now. +# Secondary hooks do not participate in scoring, by the way! $UPDATE_CHAINS_TO ||= 'hooks/update.secondary'; -x $UPDATE_CHAINS_TO and system ( $UPDATE_CHAINS_TO, @ARGV ) and die "$UPDATE_CHAINS_TO died\n"; diff --git a/src/gitolite.pm b/src/gitolite.pm index 802e86f..5b03dfc 100644 --- a/src/gitolite.pm +++ b/src/gitolite.pm @@ -5,12 +5,15 @@ use Exporter 'import'; @EXPORT = qw( can_read check_access + check_score check_ref check_repo_write_enabled cli_repo_rights cli_grouplist dbg dos2unix + trace + get_virt_refs list_phy_repos ln_sf log_it @@ -34,6 +37,10 @@ use Exporter 'import'; mirror_mode mirror_listslaves mirror_redirectOK + + match_lesser + match_greater + match_IP ); @EXPORT_OK = qw( %repos @@ -162,6 +169,17 @@ sub dos2unix { return @_; } +sub trace { + return unless $ENV{GL_TRACE}; + our $starttime; + unless (defined $starttime) { + require Time::HiRes; Time::HiRes->import ( qw(gettimeofday tv_interval) ); + $starttime = [gettimeofday()]; + } + print STDERR "## ", sprintf("%08.6f", tv_interval($starttime)), " gl-trace: ", @_, "\n"; + $starttime = [gettimeofday()]; +} + sub log_it { my ($ip, $logmsg); open my $log_fh, ">>", $ENV{GL_LOG} or die "open log failed: $!\n"; @@ -218,9 +236,9 @@ sub check_ref { # permission must also match the action (W/+, or C/D if used) being # attempted. If none of them match, the access is denied. - # NOTE: the function DIES when access is denied, unless arg 5 is true + # the return value is either (10, some-string) or (0, some-string) - my ($allowed_refs, $repo, $ref, $perm, $dry_run) = @_; + my ($allowed_refs, $repo, $ref, $perm, $match_sub) = @_; # sanity check the ref die "invalid characters in ref or filename: $ref\n" unless $ref =~ $GL_REF_OR_FILENAME_PATT; @@ -229,20 +247,23 @@ sub check_ref { for my $ar (@allowed_refs) { my $refex = $ar->[1]; # refex? sure -- a regex to match a ref against :) - next unless $ref =~ /^$refex/ or $ref eq 'joker'; - # joker matches any refex; it will only ever be sent internally - return "$perm $ref $repo $ENV{GL_USER} DENIED by $refex" if $ar->[2] eq '-' and $dry_run; - die "$perm $ref $repo $ENV{GL_USER} DENIED by $refex\n" if $ar->[2] eq '-'; + if ($match_sub) { + next unless &$match_sub($ref, $refex); + } else { + # match sub not passed; use the normal default + next unless $ref =~ /^$refex/ or $ref eq 'joker'; + # joker matches any refex; it will only ever be sent internally + } + return ($ar->[3] || 0, "$perm $ref $repo $ENV{GL_USER} DENIED by $refex") if $ar->[2] eq '-'; # $ar->[2] can be RW\+?(C|D|CD|DC)?M?. $perm can be W, +, C or # D, or any of these followed by "M". ( my $permq = $perm ) =~ s/\+/\\+/; $permq =~ s/M/.*M/; # as far as *this* ref is concerned we're ok - return $refex if ($ar->[2] =~ /$permq/); + return ($ar->[3] || 10, $refex) if ($ar->[2] =~ /$permq/); } - return "$perm $ref $repo $ENV{GL_USER} DENIED by fallthru" if $dry_run; - die "$perm $ref $repo $ENV{GL_USER} DENIED by fallthru\n"; + return (0, "$perm $ref $repo $ENV{GL_USER} DENIED by fallthru"); } # ---------------------------------------------------------------------------- @@ -724,7 +745,6 @@ sub parse_acl my $dr = $repo; $dr = '@all' if $r eq '@all'; $repos{$dr}{DELETE_IS_D} = 1 if $repos{$r}{DELETE_IS_D}; $repos{$dr}{CREATE_IS_C} = 1 if $repos{$r}{CREATE_IS_C}; - $repos{$dr}{NAME_LIMITS} = 1 if $repos{$r}{NAME_LIMITS}; $repos{$dr}{MERGE_CHECK} = 1 if $repos{$r}{MERGE_CHECK}; # this needs to copy the key-value pairs from RHS to LHS, not just # assign RHS to LHS! However, we want to roll in '@all' configs also @@ -983,12 +1003,26 @@ sub get_memberships { } # ---------------------------------------------------------------------------- -# generic check access routine +# build allowed refs and call check_ref for each of them # ---------------------------------------------------------------------------- +# compat layer for documented (and likely used in ADCs and such) check_access +# when used with 4 arguments; see contrib/adc/get-rights-and-owner.in-perl sub check_access { - my ($repo, $ref, $aa, $dry_run) = @_; + if ($_[3]) { + my ($score, $txt) = check_score(@_[0..2]); + return $txt; # this is all we return, per that doc + } + # otherwise the new check_score is the same as the old check_access + return check_score(@_); +} + +# what used to be 'check_access' is now 'check_score' all round; see comments +# on previous sub for more +sub check_score +{ + my ($repo, $ref, $aa) = @_; # aa = attempted access my ($perm, $creator, $wild); @@ -999,15 +1033,16 @@ sub check_access return ($perm, $creator); } + # level 1 check -- just one reponame as arg, gets you (perm, creator) ($perm, $creator, $wild) = repo_rights($repo) unless $ref eq 'joker'; # calling it when ref eq joker is infinitely recursive! check_access # will only be called with ref eq joker only when repo_rights has # already been called and %repos populated already. (See comments # elsewhere for what 'joker' is and why it is called that). - # until I do some major refactoring (which will bloat the update hook a - # bit, sadly), this code duplicates stuff in the current update hook. + # level 2 check -- repo, ref, aa as args, gets you (score, text) + # this code duplicates stuff in the current update hook. my @allowed_refs; # user+repo specific perms override everything else, so they come first. # Then perms given to specific user for @all repos, and finally perms @@ -1017,11 +1052,7 @@ sub check_access push @allowed_refs, @ { $repos{$repo}{'@all'} || [] }; push @allowed_refs, @ { $repos{'@all'}{'@all'} || [] }; - if ($dry_run) { - return check_ref(\@allowed_refs, $repo, $ref, $aa, $dry_run); - } else { - check_ref(\@allowed_refs, $repo, $ref, $aa); - } + return check_ref(\@allowed_refs, $repo, $ref, $aa); } # ---------------------------------------------------------------------------- @@ -1141,6 +1172,35 @@ sub setup_authkeys system("rm $ENV{HOME}/.ssh/new_authkeys"); } +# ---------------------------------------------------------------------------- +# V I R T U A L R E F S +# ---------------------------------------------------------------------------- + +sub get_virt_refs { + my ($vrt, $ref, $oldsha, $newsha) = @_; + my @refs; + + # this is special to git -- the hash of an empty tree + my $empty='4b825dc642cb6eb9a060e54bf8d69288fbee4904'; + # well they're not really "trees" but $empty is indeed the empty tree so + # we can just pretend $oldsha/$newsha are also trees, and anyway 'git + # diff' only wants trees + my $oldtree = $oldsha eq '0' x 40 ? $empty : $oldsha; + my $newtree = $newsha eq '0' x 40 ? $empty : $newsha; + + my ($vrtpgm, @args) = split(/-/, $vrt); + if ( -x "$ENV{GL_BINDIR}/gl-VR_$vrtpgm" ) { + $vrtpgm = "$ENV{GL_BINDIR}/gl-VR_$vrtpgm $ref $oldsha $newsha $oldtree $newtree"; + $vrtpgm .= " " . join(" ", @args) if @args; + chomp(@refs = `$vrtpgm`); + s/^/VR_$vrt\// for @refs; + } else { + die "virtual ref $vrt failed; you need a program called gl-VR_$vrtpgm among the gitolite scripts\n"; + } + + return @refs; +} + # ---------------------------------------------------------------------------- # S P E C I A L C O M M A N D S # ---------------------------------------------------------------------------- @@ -1279,6 +1339,35 @@ sub mirror_redirectOK { # deciding the name of the program (yet another rc var?) } +# ---------------------------------------------------------------------------- +# VRS HELPERS +# ---------------------------------------------------------------------------- + +sub match_lesser +{ + my ($ref, $refex) = @_; + for ($ref, $refex) { + s(^VR_[-\w]+/(\d+)$)($1) or die "malformed $_ in match_lesser\n"; + } + return $ref < $refex; +} + +sub match_greater +{ + my ($ref, $refex) = @_; + for ($ref, $refex) { + s(^VR_[-\w]+/(\d+)$)($1) or die "malformed $_ in match_greater\n"; + } + return $ref > $refex; +} + +sub match_IP +{ + my ($ref, $refex) = @_; + die "match_IP is not yet implemented\n"; + return 1; +} + # ------------------------------------------------------------------------------ # per perl rules, this should be the last line in such a file: 1; diff --git a/src/gl-auth-command b/src/gl-auth-command index 851f614..79543d9 100755 --- a/src/gl-auth-command +++ b/src/gl-auth-command @@ -121,12 +121,15 @@ if ($ENV{SSH_ORIGINAL_COMMAND} =~ $CUSTOM_COMMANDS) { # single quotes): # git-receive-pack 'reponame.git' -my ($verb, $repo) = ($ENV{SSH_ORIGINAL_COMMAND} =~ /^\s*(git\s+\S+|\S+)\s+'\/?(.*?)(?:\.git)?'/); +my ($verb, $repo) = ($ENV{SSH_ORIGINAL_COMMAND} =~ /^\s*(git\s+\S+|\S+)\s+'\/?(.*?)(?:\.git|\.git-trace)?'/); unless ( $verb and ( $verb eq 'git-init' or $verb =~ $R_COMMANDS or $verb =~ $W_COMMANDS ) and $repo and $repo =~ $REPONAME_PATT ) { special_cmd ($shell_allowed); exit 0; } +# set trace mode, if called for +$ENV{GL_TRACE} = $ENV{SSH_ORIGINAL_COMMAND} =~ /\.git-trace'$/; + # some final sanity checks die "$repo ends with a slash; I don't like that\n" if $repo =~ /\/$/; die "$repo has two consecutive periods; I don't like that\n" if $repo =~ /\.\./; @@ -156,8 +159,10 @@ if ( $aa eq 'W' and mirror_mode($repo) =~ /^slave of (\S+)/ ) { my ($perm, $creator, $wild); if ( $GL_ALL_READ_ALL and $verb =~ $R_COMMANDS and -d "$REPO_BASE/$repo.git") { $perm = 'R'; + trace("auth: GL_ALL_READ_ALL set, verb is $verb, granting 'R'"); } else { ($perm, $creator, $wild) = repo_rights($repo); + trace("auth: repo_rights perm is $perm"); } # it was missing, and you have create perms, so create it new_wild_repo($repo, $user) if ($perm =~ /C/); diff --git a/src/gl-compile-conf b/src/gl-compile-conf index 2f652ef..2cbc8c6 100755 --- a/src/gl-compile-conf +++ b/src/gl-compile-conf @@ -167,17 +167,21 @@ sub parse_conf_line { my $perms = $1; my @refs; @refs = split( ' ', $2 ) if $2; - @refs = expand_list ( @refs ); my @users = split ' ', $3; + my $score = 0; + $score = shift @refs if @refs and $refs[0] =~ /^-?\d+$/; + @refs = expand_list ( @refs ); die "$ABRT \$GL_WILDREPOS is not set, you cant use 'C' in config\n" if $perms eq 'C' and not $GL_WILDREPOS; # if no ref is given, this PERM applies to all refs @refs = qw(refs/.*) unless @refs; # deprecation warning - map { print STDERR "WARNING: old syntax 'PATH/' found; please use new syntax 'NAME/'\n" if s(^PATH/)(NAME/) } @refs; - # fully qualify refs that dont start with "refs/" or "NAME/"; - # prefix them with "refs/heads/" - @refs = map { m(^(refs|NAME)/) or s(^)(refs/heads/); $_ } @refs; + map { print STDERR "WARNING: really old syntax 'PATH/' found; please use new syntax 'VR_NAME/'\n" if s(^PATH/)(VR_NAME/) } @refs; + # (NAME is grandfathered in as VR_NAME) + s(^NAME/)(VR_NAME/) for @refs; + # now fully qualify refs that dont start with "refs/" or the VR + # pattern by prefixing them with "refs/heads/" + @refs = map { m(^(refs|VR_(?:[\w-]+))/) or s(^)(refs/heads/); $_ } @refs; @refs = map { s(/USER/)(/\$gl_user/); $_ } @refs; # expand the user list, unless it is just "@all" @@ -234,10 +238,10 @@ sub parse_conf_line # to avoid doing it for the large majority of repos # that do *not* use NAME limits. Setting a flag that # can be checked right away will help us do that - $repos{$repo}{NAME_LIMITS} = 1 if $ref =~ /^NAME\//; my $p_user = $user; $p_user =~ s/creator$/creator - wild/; - push @{ $repos{$repo}{$p_user} }, [ $rule_seq++, $ref, $perms ] - unless $rurp_seen{$repo}{$p_user}{$ref}{$perms}++; + push @{ $repos{$repo}{$p_user} }, + ( $score ? [ $rule_seq++, $ref, $perms, $score ] : [ $rule_seq++, $ref, $perms ] ) + unless $rurp_seen{$repo}{$p_user}{$ref}{$perms}++; } } }