每個 SVN 檔案庫都可以透過 conf/authz 去控制哪些人可以讀寫哪些路徑的檔案,
一般來說如果有在用 Trac 或 Redmine 這類 web front-end 的都會希望能在 web UI 上控制,
不會還想要進到 shell 下做修改,
Redmine 的 Repository Controls plugin 就是在做這件事,
不過它的控制方法有點微妙的不同,
不像之前設 Trac 的時候是直接讓 Trac 指向那個 authz 檔做修改,
而是直接掛 Perl module 去讀 Redmine db 裡的設定來控制存取權,
只是這有一個麻煩之處就是沒辦法直接用 SVN 內建的機制,
寫那個 Perl module 的人要重新實作那個功能一遍。
掛上了這個 plugin 之後我首先遇到的問題就是 apache-worker 跑不動它,
一啟動 httpd 就直接 crash 掉了,
查了一陣子發現是那個 Perl module 為了不要讓 LDAP 驗證的動作在每次存取時都重新做一次,
於是造了一份 APR::Table 去 cache 住認證的資訊來加速,
而產生一份 APR::Table 需要傳入一個 APR::Pool 物件,
原作者在 httpd 啟動的時候就做了這個動作並把它們分別存在變數內,
之後要做認證的時候就能去用那一份 APR::Table 去做 cache;
我是不曉得 apache-worker 的原理是怎樣,
反正試來試去就發現 APR::Pool->new 這行只要在載入 httpd 的時候就做一定會炸,
至於炸在哪裡是相當隨機的狀況 (視 httpd.conf 的那一串 LoadModule 寫了什麼而定),
不過這種問題在跑 prefork 版的 Apache 是不會出現就是了,
後來我把 APR::Pool 和 APR::Table 的初始化時間點往後挪到做認證的地方,
這個問題就解決了;
就算是沒有打算要用這個 plugin 去控制檔案存取權限,
只是想使用官方提供的 extra/svn/Redmine.pm 這個 module 做帳號登入控制等基本功能的,
也是會遇上一樣的問題,
這種時候也是把初始化的時間點往後挪就可以。
好不容易讓這個 plugin 可以在 apache-worker 上跑了之後,
我又發現似乎原作者對 Redmine 的 roles 設定以及 SVN 的 authz 設計有些誤解,
Redmine 的 roles 設定畫面裡確實是可以把各種 role 的位置做上下調整,
而這個 plugin 的作者似乎認為那個位置會影響到檔案存取權限的大小,
在程式碼裡對那個部分做了多餘的解讀;
再來又發現它的那個 Perl module 抓 project id 跟 request path 的方法也有問題,
像是我習慣開一個 virtual host 把檔案庫直接放在 / 下面的話他就會判斷錯誤,
這個部分我也做了一點小小的改寫,
不過我不是很熟 Perl 所以也不清楚有沒有什麼更有效率的方法去解就是了,
反正我就是用 split 把 / 當 delimiter 把路徑分解成 array 再慢慢玩,
實際 checkout 過一次之後感覺執行的速度也還不差就是了。
為了能確實控制 non-member 跟 anonymous 的存取權,
我把這 plugin 的 app/views/repository_controls/_form.html.erb 第一行做了點小修改,
變成:
1 |
<% roles = Role.find_all_givable %> |
這讓 Repository Controls 設定頁裡的選單可以選擇 Anonymous 和 Non Member 這兩個 roles,
最後就是去修改那個 Perl module 讓它能 work 的跟之前 Trac + authz 的模式一樣了;
不管是這個 plugin 還是 Redmine 官方 release 出來的那個 extra/svn/Redmine.pm,
似乎都會拿 project 的 is_public 屬性來判斷檔案庫是否能被 anonymous 讀取,
我是覺得 web front-end 上的存取限制最好還是跟檔案庫分開,
所以把它拔掉了。
改完以後檔案是長這樣:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 |
package Apache::Authn::RedmineRepoControl; =head1 Apache::Authn::RedmineRepoControl Module for repository access and control to interface with Redmine =head1 SYNOPSIS =head1 INSTALLATION =head1 CONFIGURATION =cut use strict; use warnings FATAL => 'all', NONFATAL => 'redefine'; use DBI; use Digest::SHA1; # optional module for LDAP authentication my $CanUseLDAPAuth = eval("use Authen::Simple::LDAP; 1"); use Apache2::Module; use Apache2::Access; use Apache2::ServerRec qw(); use Apache2::RequestRec qw(); use Apache2::RequestUtil qw(); use Apache2::Const qw(:common :override :cmd_how); use APR::Pool(); use APR::Table(); my @directives = ( { name => 'RedmineDSN', req_override => OR_AUTHCFG, args_how => TAKE1, errmsg => 'DSN in format used by Perl DBI. eg: "DBI:Pg:dbname=databasename;host=my.db.server"', }, { name => 'RedmineDbUser', req_override => OR_AUTHCFG, args_how => TAKE1, }, { name => 'RedmineDbPass', req_override => OR_AUTHCFG, args_how => TAKE1, }, { name => 'RedmineCacheCredsMax', req_override => OR_AUTHCFG, args_how => TAKE1, errmsg => 'RedmineCacheCredsMax must be a decimal number', }, ); sub RedmineDSN { my ($self, $parms, $arg) = @_; $self->{RedmineDSN} = $arg; my $query = "SELECT hashed_password, auth_source_id, permissions FROM members, projects, users, roles WHERE projects.id=members.project_id AND users.id=members.user_id AND roles.id=members.role_id AND users.status=1 AND login=? AND identifier=? "; $self->{RedmineQuery} = trim($query); } sub RedmineDbUser { set_val('RedmineDbUser', @_); } sub RedmineDbPass { set_val('RedmineDbPass', @_); } sub RedmineDbWhereClause { my ($self, $parms, $arg) = @_; $self->{RedmineQuery} = trim($self->{RedmineQuery}.($arg ? $arg : "")." "); } sub RedmineCacheCredsMax { my ($self, $parms, $arg) = @_; if ($arg) { # $self->{RedmineCachePool} = APR::Pool->new; # $self->{RedmineCacheCreds} = APR::Table::make($self->{RedmineCachePool}, $arg); $self->{RedmineCacheCredsCount} = 0; $self->{RedmineCacheCredsMax} = $arg; } } sub trim { my $string = shift; $string =~ s/\s{2,}/ /g; return $string; } sub set_val { my ($key, $self, $parms, $arg) = @_; $self->{$key} = $arg; } Apache2::Module::add(__PACKAGE__, \@directives); # This is the list of all apache request methods that we are treating as "read-only" methods my %read_only_methods = map { $_ => 1 } qw/GET PROPFIND REPORT OPTIONS/; # # Access is the first step in the AAA model. This funciton decides whether or not to even ask # the user to log in. Basically, this is going to always be yes, however, if an anonymous # person is trying to connect, and we've set that anonymous people can browse public projects # then we will OK it, and tell apache that the authenicatin handler doesn't need to be called # or, that the anonymous person doesn't need to log in. # sub access_handler { my $r = shift; my $cfg = Apache2::Module::get_config( __PACKAGE__, $r->server, $r->per_dir_config ); unless ( $r->some_auth_required ) { $r->log_reason("No authentication has been configured"); return FORBIDDEN; } # commit operations are alwayed required login return OK unless exists $read_only_methods{$r->method}; my $project_id = get_project_identifier($r); my $req_path = get_requested_path($r); # anonymous doesn't require to login if( is_anonymous_readable($project_id, $req_path, $r) ) { $r->set_handlers( PerlAuthenHandler => [ \&OK ] ); } return OK; } # # This is the second step in the AAA model, Authentication. If we have gotten here, we need # to make sure the user can authenticate against redmine. # sub authen_handler { my $r = shift; my $ret = AUTH_REQUIRED; my ($res, $redmine_pass) = $r->get_basic_auth_pw(); return $res unless $res == OK; my $redmine_user = $r->user; my $project_id = get_project_identifier($r); if(!defined($project_id)) { return FORBIDDEN; } my $cfg = Apache2::Module::get_config(__PACKAGE__, $r->server, $r->per_dir_config); #1. Check the chache for the user's credentials my $usrprojpass; my $pass_digest = Digest::SHA1::sha1_hex($redmine_pass); if ($cfg->{RedmineCacheCredsMax}) { if(!defined($cfg->{RedmineCacheCreds})) { $cfg->{RedmineCachePool} = APR::Pool->new; $cfg->{RedmineCacheCreds} = APR::Table::make($cfg->{RedmineCachePool}, $cfg->{RedmineCacheCredsMax}); } $usrprojpass = $cfg->{RedmineCacheCreds}->get($redmine_user.":".$project_id); return OK if ( defined $usrprojpass and ( $usrprojpass eq $pass_digest )); } #2. Otherwise, authenticate the user # Pull the hashed password for the user from the DB my $dbh = connect_database($r); my $sth = $dbh->prepare("SELECT hashed_password, auth_source_id FROM users WHERE users.status=1 AND login=? "); $sth->execute($redmine_user); # check the result from the DB query to try and authenticate the user while ( my($hashed_password, $auth_source_id) = $sth->fetchrow_array ) { # if there is an auth_source_id set, then skip this first part and authenticate using the auth_source unless($auth_source_id) { # otherwise, authenticate using the hashed password if ( $hashed_password eq $pass_digest ) { $ret = OK; } } elsif ($CanUseLDAPAuth) { # pull the auth_source configuration from the database my $sthldap = $dbh->prepare("SELECT host,port,tls,account,account_password,base_dn,attr_login from auth_sources WHERE id = ?;"); $sthldap->execute($auth_source_id); while (my @rowldap = $sthldap->fetchrow_array) { # add ldap authenticate as user my $bind_as = $rowldap[3]; my $bind_pw = $rowldap[4] ? $rowldap[4] : ""; if ($bind_as =~ m/\$login/) { # if we have $login in the bind user, replace it with the user # trying to log in, and use their password as well $bind_as =~ s/\$login/$redmine_user/g; $bind_pw = $redmine_pass; } my $ldap = Authen::Simple::LDAP->new( host => ($rowldap[2] eq "t") ? "ldaps://$rowldap[0]" : $rowldap[0], port => $rowldap[1], basedn => $rowldap[5], binddn => $bind_as, bindpw => $bind_pw, filter => "(".$rowldap[6]."=%s)" ); $ret = OK if ($ldap->authenticate($redmine_user, $redmine_pass)); } $sthldap->finish(); } else { #there is an auth_source, but we can't use it because we don't have the LDAP module installed, #so error and let the user know $r->log_error("Cannot load the Authen::Simple::LDAP module to authenticate the user, please check your installation"); $ret = SERVER_ERROR; } } $sth->finish(); $dbh->disconnect(); # # If the login was successful, add it to the cache # if ($cfg->{RedmineCacheCredsMax} and $ret == OK) { if (defined $usrprojpass) { $cfg->{RedmineCacheCreds}->set($redmine_user.":".$project_id, $pass_digest); } else { if ($cfg->{RedmineCacheCredsCount} < $cfg->{RedmineCacheCredsMax}) { $cfg->{RedmineCacheCreds}->set($redmine_user.":".$project_id, $pass_digest); $cfg->{RedmineCacheCredsCount}++; } else { $cfg->{RedmineCacheCreds}->clear(); $cfg->{RedmineCacheCredsCount} = 0; } } } $ret; } # # This is the final stage in the AAA model, Authorization. This function decides # whether or not the user is actually allowed to access this particular path. sub authz_handler { my $r = shift; my $user = $r->user; my $project_id = get_project_identifier($r); my $req_path = get_requested_path($r); my $perm = get_user_permission($user, $project_id, $req_path, $r); if(exists $read_only_methods{$r->method}) { return OK if($perm =~ /:browse_repository/); } else { return OK if($perm =~ /:commit_access/); } return FORBIDDEN; } # # Returns the path requested in the repository # sub get_requested_path { my $r = shift; my $location = $r->location; my $path; if($location ne "/") { ($path) = $r->uri =~ m{$location/*[^/]+(/.*)}; } else { my @path_items = split /\//, $r->uri; if(@path_items <= 2) { $path = "/"; } else { if($path_items[2] eq "!svn") { if(@path_items <= 5) { return "/"; } @path_items = @path_items[5..$#path_items]; } else { @path_items = @path_items[2..$#path_items]; } $path = "/" . join "/", @path_items; } } $path; } # # Returns the project identifier for a given request # sub get_project_identifier { my $r = shift; my $location = $r->location; my $identifier; if($location ne "/") { ($identifier) = $r->uri =~ m{$location/*([^/]+)}; } else { my @path_items = split /\//, $r->uri; $identifier = $path_items[1]; } $identifier; } # # Returns a connection to the database # sub connect_database { my $r = shift; my $cfg = Apache2::Module::get_config( __PACKAGE__, $r->server, $r->per_dir_config ); return DBI->connect( $cfg->{RedmineDSN}, $cfg->{RedmineDbUser},$cfg->{RedmineDbPass} ); } # Checks a given permission against the request method. If the request to apache # is in the $read_only_methods list, then we only need to see that the permission # given is :browse_repository. Otherwise, it is a write request, and the permission # needs to be :commit_access sub check_permission() { my $perm = shift; my $r = shift; if ( defined $read_only_methods{ $r->method } ) { #$r->log_error("Checking permission '$perm' for read access"); return OK if ( $perm =~ /:browse_repository/ ); } else { #$r->log_error("Checking permission '$perm' for write access"); return OK if ( $perm =~ /:commit_access/ ); } return FORBIDDEN; } sub get_role_permission { my ($role_id, $project_id, $req_path, $r) = @_; if(!defined($project_id)) { return ''; } my $dbh = connect_database($r); my $sth = $dbh->prepare("SELECT path, permissions FROM repository_controls, projects WHERE repository_controls.role_id='$role_id' AND projects.identifier='$project_id' AND repository_controls.project_id=projects.id"); $sth->execute; my $longest_path = 0; my $role_perm; while(my ($path, $perm) = $sth->fetchrow_array()) { if($req_path =~ m{^($path).*}) { my $path_length = length $path; if($path_length > $longest_path) { $longest_path = $path_length; $role_perm = $perm; } } } $sth->finish(); $dbh->disconnect(); return $role_perm; } sub get_user_permission { my ($user, $project_id, $req_path, $r) = @_; my $user_perm; if(!defined($project_id)) { return ''; } if($user) { my $dbh = connect_database($r); my $sth; my $is_member; $sth = $dbh->prepare("SELECT COUNT(*) FROM users, members, projects WHERE users.login='$user' AND projects.identifier='$project_id' AND members.user_id=users.id AND members.project_id=projects.id"); $sth->execute; ($is_member) = $sth->fetchrow_array(); if($is_member eq '1') { my $longest_path = 0; $sth = $dbh->prepare("SELECT path, permissions FROM repository_controls, projects, users, members WHERE users.login='$user' AND projects.identifier='$project_id' AND members.user_id=users.id AND members.project_id=projects.id AND repository_controls.role_id=members.role_id AND repository_controls.project_id=projects.id"); $sth->execute; while(my ($path, $perm) = $sth->fetchrow_array()) { if($req_path =~ m{^($path).*}) { my $path_length = length $path; if($path_length > $longest_path) { $longest_path = $path_length; $user_perm = $perm; } } } } else { # non-member access $user_perm = get_role_permission('1', $project_id, $req_path, $r); } $sth->finish(); $dbh->disconnect(); } else { # anonymous access $user_perm = get_role_permission('2', $project_id, $req_path, $r); } if(!defined($user_perm)) { $user_perm = ''; } return $user_perm; } sub is_anonymous_readable { my ($project_id, $req_path, $r) = @_; my $perm = get_role_permission('2', $project_id, $req_path, $r); if($perm and $perm =~ /:browse_repository/) { return 1; } else { return 0; } } 1; |
至於有沒有問題用一陣子就知道了,
想拿去用的我可要先說這不提供保固,
畢竟 perl 並不是我的專長,
不過跟原版比的話出現 500 Internal Server Error 的機率是大大降低了許多,
至於會不會有恐怖的 memory leakage 我就不清楚了;
特別要注意的地方就是這個版本是給 sqlite 用的,
要給 MySQL 用的話 boolean value 判斷式那邊要改一下。
不過說起保固,
這讓我想起了 Firefox 最經典的 about:config 開頭畫面:
這絕對不是翻譯的錯,
它的英文也是差不多的意思,
只是中文翻譯翻得比較生動了些。
2009-12-19 Update
這篇跟 code 有關的東西已經大幅更新過了,
也全面修改成 MySQL 可以用的版本,
詳情請參考:
Redmine 的 Repository Controls plugin (續)