(vrs) virtual refs and scoring

Note: filenames of actual VR scripts on disk start with "gl-"

compile
  - insert scores into %repos, if provided
  - recognise VR_[A-Z0-9_]+/ like NAME/

update and gitolite.pm
  - move die()s up the call chain
  - (set up for using other forms of virtref later)
  - collect virtual refs and call special routines for each one found
  - (virtrefs can have arguments)
  - (get rid of NAME_LIMITS; you don't need it anymore)
  - add score computation code

Other stuff:
  - basic support for tracing; undocumented for now
  - remove "in-core" NAME; use new VR_NAME by default

Implementation notes:

  - the interface to check_access is no written in stone (since
    v1.5.8+1; 5b9bf70: "allow access checks from ADC or hook"), and we
    don't want to change that (would cause problems if people missed the
    change warning!)

    So the new version of check_access is called 'check_score', and
    check_access is not just a compatibility layer.

    The new check_score shall stay undocumented for now; we will decide
    later if we should "out" it.
This commit is contained in:
Sitaram Chamarty
2012-01-16 11:07:36 +05:30
parent fdfad75210
commit 5e7c213918
5 changed files with 194 additions and 54 deletions
+3 -2
View File
@@ -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
+65 -24
View File
@@ -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";
+108 -19
View File
@@ -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;
+6 -1
View File
@@ -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/);
+12 -8
View File
@@ -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}++;
}
}
}