Get username from HTTP request headers when buildbot is served by frontend webserver #45

Closed
wants to merge 2 commits into
from
@@ -63,16 +63,46 @@ def needAuthForm(self, action):
if action not in self.knownActions:
raise KeyError("unknown action")
cfg = self.config.get(action, False)
+ if cfg == 'http':
+ # For HTTP auth authentication form will be displayed by browser
+ return False
if cfg == 'auth' or callable(cfg):
return True
return False
+ def needUsernameField(self, action):
+ """Does this action require an authentication form?"""
+ if action not in self.knownActions:
+ raise KeyError("unknown action")
+ cfg = self.config.get(action, False)
+ if cfg == 'http':
+ # For HTTP auth authentication form will be displayed by browser
+ return False
+ return True
+
+ def getUsername(self, request):
+ user = request.getUser()
+ if user != None:
+ return user
+ return request.args.get("username", ["<unknown>"])[0]
+
def actionAllowed(self, action, request, *args):
"""Is this ACTION allowed, given this http REQUEST?"""
if action not in self.knownActions:
raise KeyError("unknown action")
cfg = self.config.get(action, False)
if cfg:
+ if cfg == 'http':
+ if not self.auth:
+ return False
+ user = request.getUser()
+ passwd = "<authenticated-by-http>"
+ if self.auth.authenticate(user, passwd):
+ if callable(cfg) and not cfg(user, *args):
+ return False
+ return True
+ return False
+
if cfg == 'auth' or callable(cfg):
if not self.auth:
return False
@@ -161,7 +161,7 @@ def stop(self, req, auth_ok=False):
b = self.build_status
log.msg("web stopBuild of build %s:%s" % \
(b.getBuilder().getName(), b.getNumber()))
- name = req.args.get("username", ["<unknown>"])[0]
+ name = self.getAuthz(req).getUsername(req)
comments = req.args.get("comments", ["<no reason specified>"])[0]
# html-quote both the username and comments, just to be safe
reason = ("The web-page 'stop build' button was pressed by "
@@ -193,7 +193,7 @@ def rebuild(self, req):
b = self.build_status
builder_name = b.getBuilder().getName()
log.msg("web rebuild of build %s:%s" % (builder_name, b.getNumber()))
- name = req.args.get("username", ["<unknown>"])[0]
+ name = self.getAuthz(req).getUsername(req)
comments = req.args.get("comments", ["<no reason specified>"])[0]
reason = ("The web-page 'rebuild' button was pressed by "
"'%s': %s\n" % (name, comments))
@@ -121,7 +121,7 @@ def content(self, req, cxt):
return template.render(**cxt)
def force(self, req, auth_ok=False):
- name = req.args.get("username", ["<unknown>"])[0]
+ name = self.getAuthz(req).getUsername(req)
reason = req.args.get("comments", ["<no reason specified>"])[0]
branch = req.args.get("branch", [""])[0]
revision = req.args.get("revision", [""])[0]
@@ -107,12 +107,14 @@
{% if authz.needAuthForm('forceBuild') %}
{{ auth() }}
{% else %}
- <div class="row">
- <span class="label">
- Your name:
- </span>
- <input type="text" name="username"/>
- </div>
+ {% if authz.needUsernameField('forceBuild') %}
+ <div class="row">
+ <span class="label">
+ Your name:
+ </span>
+ <input type="text" name="username"/>
+ </div>
+ {% endif %}
{% endif %}
<div class="row">
@@ -26,6 +26,14 @@ def __init__(self, username, passwd):
'username' : [ username ],
'passwd' : [ passwd ],
}
+ def getUser(self):
+ return None
+
+class StubHttpAuthRequest(object):
+ def __init__(self, username):
+ self.username = username
+ def getUser(self):
+ return self.username
class StubAuth(object):
implements(IAuth)
@@ -66,6 +74,18 @@ def test_actionAllowed_AuthNegative(self):
assert not z.actionAllowed('stopBuild',
StubRequest('apeterson', 'bar'))
+ def test_actionAllowedHttp_AuthPositive(self):
+ z = Authz(auth=StubAuth('jrobinson'),
+ stopBuild='http')
+ assert z.actionAllowed('stopBuild',
+ StubHttpAuthRequest('jrobinson'))
+
+ def test_actionAllowedHttp_AuthNegative(self):
+ z = Authz(auth=StubAuth('jrobinson'),
+ stopBuild='http')
+ assert not z.actionAllowed('stopBuild',
+ StubHttpAuthRequest('apeterson'))
+
def test_actionAllowed_AuthCallable(self):
myargs = []
def myAuthzFn(*args):
@@ -123,6 +143,18 @@ def test_needAuthForm_callable(self):
z = Authz(stopAllBuilds = lambda u : False)
assert z.needAuthForm('stopAllBuilds')
+ def test_httpNoAuthForm_False(self):
+ z = Authz(forceBuild = 'http')
+ assert not z.needAuthForm('forceBuild')
+
+ def test_httpNoAuthForm_True(self):
+ z = Authz(forceAllBuilds = 'http')
+ assert not z.needAuthForm('forceAllBuilds')
+
+ def test_httpNoAuthForm_auth(self):
+ z = Authz(stopBuild = 'http')
+ assert not z.needAuthForm('stopBuild')
+
def test_constructor_invalidAction(self):
self.assertRaises(ValueError, Authz, someRandomAction=3)
@@ -137,3 +169,23 @@ def test_needAuthForm_invalidAction(self):
def test_actionAllowed_invalidAction(self):
z = Authz()
self.assertRaises(KeyError, z.actionAllowed, 'someRandomAction', StubRequest('snow', 'foo'))
+
+ def test_authzGetUsername_normal(self):
+ z = Authz()
+ assert z.getUsername(StubRequest('foo', 'bar')) == 'foo'
+
+ def test_authzGetUsername_http(self):
+ z = Authz()
+ assert z.getUsername(StubHttpAuthRequest('foo')) == 'foo'
+
+ def test_needUsernameField_invalidAction(self):
+ z = Authz(forceBuild='auth')
+ self.assertRaises(KeyError, z.needUsernameField, 'someRandomAction')
+
+ def test_needUsernameField_nonHttp(self):
+ z = Authz(forceBuild='auth')
+ assert z.needUsernameField('forceBuild')
+
+ def test_needUsernameField_http(self):
+ z = Authz(forceBuild="http")
+ assert not z.needUsernameField('forceBuild')
@@ -546,6 +546,38 @@ BuilderStatus object. The @code{stopBuild} action supplies a BuildStatus
object. The @code{cancelPendingBuild} action supplies a BuildRequest. The
remainder do not supply any extra arguments.
+@heading HTTP-based authentication by frontend server
+
+In case if WebStatus is served through reverse proxy that supports HTTP-based
+authentication (like apache, lighttpd), it's possible to to tell WebStatus to
+trust web server and get username from request headers. This allows displaying
+correct usernames in build reason, interrupt messages, etc.
+
+Just specify @code{"http"} for such action in @code{"Authz"} constructor.
+
+@example
+from buildbot.status.html import WebStatus
+from buildbot.status.web.authz import Authz
+from buildbot.status.web.auth import BasicAuth
+users = [('bob', '<authenticated-by-http>'), ('jill', '<authenticated-by-http>')]
+authz = Authz(auth=BasicAuth(users),
+ forceBuild='http', # WebStatus secured by web frontend with HTTP auth
+)
+c['status'].append(WebStatus(http_port="tcp:8080:interface=127.0.0.1", authz=authz))
+@end example
+
+Please note that WebStatus will not decode password from HTTP request (for
+DIGEST authentication it's just impossible). Instead it'll pass magic string
+@code{"<authenticated-by-http>"} to @code{"auth"}. Custom
+@code{status.web.auth.IAuth} subclasses may just ignore password at all since
+it's already validated by web server.
+
+Administrator must make sure that it's impossible to get access to WebStatus
+using other way than through frontend. Usually this means that WebStatus should
+listen for incoming connections only on localhost (or on some firewall-protected
+port). Frontend must require HTTP authentication to access WebStatus pages
+(using any source for credentials, such as htpasswd, PAM, LDAP).
+
@heading Logging configuration
The WebStatus uses a separate log file (http.log) to avoid clutter