From 9b26de39356f7d7f771d1bdbbfd017e517849b60 Mon Sep 17 00:00:00 2001
From: Benoit Sigoure <tsuna@lrde.epita.fr>
Date: Tue, 13 Nov 2007 18:55:52 +0100
Subject: [PATCH v4 09/12] Add a possibility to password-protect the forms on the WebStatus.
It is now possible to instantiate the WebStatus with a list of
login/password. Only users with a valid login/password can
force/stop builds.
* NEWS: Mention the new feature.
* buildbot/status/web/authentication.py: New file.
(IAuth): New interface.
(AuthBase): New base class.
(BasicAuth): Implement IAuth.
* buildbot/status/web/base.py (make_name_login_password_form): New
helper to factor some common code.
(make_stop_form, make_force_build_form): Use it. Accept a 2nd
mandatory boolean argument.
(HtmlResource.isUsingLoginPassword, HtmlResource.authUser): New
dispatch methods.
* buildbot/status/web/baseweb.py (OneLinePerBuild.body),
(OneBoxPerBuilder.body): Adjust.
(WebStatus.__init__): Accept a new `auth' keyword argument.
(WebStatus.isUsingLoginPassword, WebStatus.authUser): New.
* buildbot/status/web/build.py (StatusResourceBuild.body): Adjust.
(StatusResourceBuild.stop): Check the credentials of the user, if
needed.
* buildbot/status/web/builder.py (StatusResourceBuilder.body),
(StatusResourceBuilder.force): Likewise.
* docs/buildbot.texinfo (WebStatus Configuration Parameters):
Document the new `userpass' argument of WebStatus.
Signed-off-by: Benoit Sigoure <tsuna@lrde.epita.fr>
---
NEWS | 6 +++
buildbot/status/web/authentication.py | 47 +++++++++++++++++++++
buildbot/status/web/base.py | 74 +++++++++++++++++++++++----------
buildbot/status/web/baseweb.py | 57 ++++++++++++++++++++++---
buildbot/status/web/build.py | 7 +++-
buildbot/status/web/builder.py | 20 +++++++--
docs/buildbot.texinfo | 16 ++++++-
7 files changed, 190 insertions(+), 37 deletions(-)
create mode 100644 buildbot/status/web/authentication.py
diff --git a/NEWS b/NEWS
index 26a01eb..7fdcd11 100644
|
a
|
b
|
These are now reserved for internal buildbot purposes, such as the magic |
| 10 | 10 | "_all" pseudo-builder that the web pages use to allow force-build buttons |
| 11 | 11 | that start builds on all Builders at once. |
| 12 | 12 | |
| | 13 | *** Password protected WebStatus |
| | 14 | The WebStatus constructor can take an instance of IAuth (from |
| | 15 | status.web.authentication). The class BasicAuth accepts a `userpass' keyword |
| | 16 | argument in pretty much the same way as Try_Userpass does. |
| | 17 | Only users with a valid login/password can then force/stop builds from the |
| | 18 | WebStatus. |
| 13 | 19 | |
| 14 | 20 | * Release 0.7.6 (30 Sep 2007) |
| 15 | 21 | |
diff --git a/buildbot/status/web/authentication.py b/buildbot/status/web/authentication.py
new file mode 100644
index 0000000..c8c6875
|
-
|
+
|
|
| | 1 | |
| | 2 | from zope.interface import Interface, implements |
| | 3 | |
| | 4 | class IAuth(Interface): |
| | 5 | """Represent an authentication method.""" |
| | 6 | |
| | 7 | def authenticate(self, login, password): |
| | 8 | """Check whether C{login} / C{password} are valid""" |
| | 9 | |
| | 10 | def errmsg(self): |
| | 11 | """Get the last error message (reason why authentication |
| | 12 | failed).""" |
| | 13 | |
| | 14 | class AuthBase: |
| | 15 | err = "" |
| | 16 | |
| | 17 | def errmsg(self): |
| | 18 | return self.err |
| | 19 | |
| | 20 | class BasicAuth(AuthBase): |
| | 21 | implements(IAuth) |
| | 22 | """Implement a basic authentication mechanism against of list of |
| | 23 | user/password.""" |
| | 24 | |
| | 25 | userpass = [] |
| | 26 | """List of couples (user, password)""" |
| | 27 | |
| | 28 | def __init__(self, userpass): |
| | 29 | """C{userpass} is a list of (user, password).""" |
| | 30 | for user_pass_pair in userpass: |
| | 31 | assert isinstance(user_pass_pair, tuple) |
| | 32 | login, password = user_pass_pair |
| | 33 | assert isinstance(login, str) |
| | 34 | assert isinstance(password, str) |
| | 35 | self.userpass = userpass |
| | 36 | |
| | 37 | def authenticate(self, login, password): |
| | 38 | """Check that C{login}/C{password} is a valid user/password pair.""" |
| | 39 | if not self.userpass: |
| | 40 | self.err = "Invalid value for self.userpass" |
| | 41 | return False |
| | 42 | for l, p in self.userpass: |
| | 43 | if login == l and password == p: |
| | 44 | self.err = "" |
| | 45 | return True |
| | 46 | self.err = "Invalid login or password" |
| | 47 | return False |
diff --git a/buildbot/status/web/base.py b/buildbot/status/web/base.py
index b5e8065..7b6e5e8 100644
|
a
|
b
|
def make_row(label, field): |
| 53 | 53 | label = html.escape(label) |
| 54 | 54 | return ROW_TEMPLATE % {"label": label, "field": field} |
| 55 | 55 | |
| 56 | | def make_stop_form(stopURL, on_all=False): |
| | 56 | def make_name_login_password_form(useLoginPassword): |
| | 57 | """helper function that produces either one row of a form with a `name' |
| | 58 | text input (if C{useLoginPassword} is C{False}) or two rows with a |
| | 59 | `login' / `password' text input.""" |
| | 60 | |
| | 61 | if useLoginPassword: |
| | 62 | user_label = "Your login:" |
| | 63 | else: |
| | 64 | user_label = "Your name:" |
| | 65 | data = make_row(user_label, |
| | 66 | '<input type="text" name="username" />') |
| | 67 | if useLoginPassword: |
| | 68 | data += make_row("Your password:", |
| | 69 | '<input type="password" name="passwd" />') |
| | 70 | return data |
| | 71 | |
| | 72 | def make_stop_form(stopURL, useLoginPassword, on_all=False): |
| | 73 | """Create a form whose submit button sends the request to C{stopURL}. If |
| | 74 | C{useLoginPassword} is true, this form will have a password field.""" |
| | 75 | |
| 57 | 76 | if on_all: |
| 58 | | data = """<form action="%s" class='command stopbuild'> |
| 59 | | <p>To stop all builds, fill out the following fields and |
| 60 | | push the 'Stop' button</p>\n""" % stopURL |
| | 77 | what = "all builds" |
| 61 | 78 | else: |
| 62 | | data = """<form action="%s" class='command stopbuild'> |
| 63 | | <p>To stop this build, fill out the following fields and |
| 64 | | push the 'Stop' button</p>\n""" % stopURL |
| 65 | | data += make_row("Your name:", |
| 66 | | "<input type='text' name='username' />") |
| | 79 | what = "this build" |
| | 80 | data = """<form action="%s" class="command stopbuild"> |
| | 81 | <p>To stop %s, fill out the following fields and |
| | 82 | click the `Stop' button</p>\n""" % (stopURL, what) |
| | 83 | data += make_name_login_password_form(useLoginPassword) |
| | 84 | |
| 67 | 85 | data += make_row("Reason for stopping build:", |
| 68 | | "<input type='text' name='comments' />") |
| | 86 | '<input type="text" name="comments" />') |
| 69 | 87 | data += '<input type="submit" value="Stop Builder" /></form>\n' |
| 70 | 88 | return data |
| 71 | 89 | |
| 72 | | def make_force_build_form(forceURL, on_all=False): |
| | 90 | def make_force_build_form(forceURL, useLoginPassword, on_all=False): |
| | 91 | """Create a form whose submit button sends the request to C{forceURL}. If |
| | 92 | C{useLoginPassword} is true, this form will have a password field.""" |
| | 93 | |
| 73 | 94 | if on_all: |
| 74 | | data = """<form action="%s" class="command forcebuild"> |
| 75 | | <p>To force a build on all Builders, fill out the following fields |
| 76 | | and push the 'Force Build' button</p>""" % forceURL |
| | 95 | where = " on all builders" |
| 77 | 96 | else: |
| 78 | | data = """<form action="%s" class="command forcebuild"> |
| 79 | | <p>To force a build, fill out the following fields and |
| 80 | | push the 'Force Build' button</p>""" % forceURL |
| | 97 | where = "" |
| | 98 | data = """<form action="%s" class="command forcebuild"> |
| | 99 | <p>To force a build%s, fill out the following fields and |
| | 100 | click the `Force Build' button</p>""" % (forceURL, where) |
| 81 | 101 | return (data |
| 82 | | + make_row("Your name:", |
| 83 | | "<input type='text' name='username' />") |
| | 102 | + make_name_login_password_form(useLoginPassword) |
| 84 | 103 | + make_row("Reason for build:", |
| 85 | | "<input type='text' name='comments' />") |
| | 104 | '<input type="text" name="comments" />') |
| 86 | 105 | + make_row("Branch to build:", |
| 87 | | "<input type='text' name='branch' />") |
| | 106 | '<input type="text" name="branch" />') |
| 88 | 107 | + make_row("Revision to build:", |
| 89 | | "<input type='text' name='revision' />") |
| | 108 | '<input type="text" name="revision" />') |
| 90 | 109 | + '<input type="submit" value="Force Build" /></form>\n') |
| 91 | 110 | |
| 92 | 111 | colormap = { |
| … |
… |
class HtmlResource(resource.Resource): |
| 254 | 273 | |
| 255 | 274 | def getStatus(self, request): |
| 256 | 275 | return request.site.buildbot_service.getStatus() |
| | 276 | |
| 257 | 277 | def getControl(self, request): |
| 258 | 278 | return request.site.buildbot_service.getControl() |
| 259 | 279 | |
| | 280 | def isUsingLoginPassword(self, request): |
| | 281 | return request.site.buildbot_service.isUsingLoginPassword() |
| | 282 | |
| | 283 | def authUser(self, request): |
| | 284 | login = request.args.get("username", ["<unknown>"])[0] |
| | 285 | password = request.args.get("passwd", ["<no-password>"])[0] |
| | 286 | if login == "<unknown>" or password == "<no-password>": |
| | 287 | return False |
| | 288 | return request.site.buildbot_service.authUser(login, password) |
| | 289 | |
| 260 | 290 | def getChangemaster(self, request): |
| 261 | 291 | return request.site.buildbot_service.parent.change_svc |
| 262 | 292 | |
diff --git a/buildbot/status/web/baseweb.py b/buildbot/status/web/baseweb.py
index 8682735..66b1022 100644
|
a
|
b
|
from buildbot.status.web.builder import BuildersResource |
| 19 | 19 | from buildbot.status.web.slaves import BuildSlavesResource |
| 20 | 20 | from buildbot.status.web.xmlrpc import XMLRPCServer |
| 21 | 21 | from buildbot.status.web.about import AboutBuildbot |
| | 22 | from buildbot.status.web.authentication import IAuth |
| 22 | 23 | |
| 23 | 24 | # this class contains the status services (WebStatus and the older Waterfall) |
| 24 | 25 | # which can be put in c['status']. It also contains some of the resources |
| … |
… |
class OneLinePerBuild(HtmlResource, OneLineMixin): |
| 123 | 124 | |
| 124 | 125 | if building: |
| 125 | 126 | stopURL = "builders/_all/stop" |
| 126 | | data += make_stop_form(stopURL, True) |
| | 127 | data += make_stop_form(stopURL, self.isUsingLoginPassword(req), |
| | 128 | True) |
| 127 | 129 | if online: |
| 128 | 130 | forceURL = "builders/_all/force" |
| 129 | | data += make_force_build_form(forceURL, True) |
| | 131 | data += make_force_build_form(forceURL, |
| | 132 | self.isUsingLoginPassword(req), |
| | 133 | True) |
| 130 | 134 | |
| 131 | 135 | return data |
| 132 | 136 | |
| … |
… |
class OneBoxPerBuilder(HtmlResource): |
| 232 | 236 | |
| 233 | 237 | if building: |
| 234 | 238 | stopURL = "builders/_all/stop" |
| 235 | | data += make_stop_form(stopURL, True) |
| | 239 | data += make_stop_form(stopURL, self.isUsingLoginPassword(req), |
| | 240 | True) |
| 236 | 241 | if online: |
| 237 | 242 | forceURL = "builders/_all/force" |
| 238 | | data += make_force_build_form(forceURL, True) |
| | 243 | data += make_force_build_form(forceURL, |
| | 244 | self.isUsingLoginPassword(req), |
| | 245 | True) |
| 239 | 246 | |
| 240 | 247 | return data |
| 241 | 248 | |
| … |
… |
class WebStatus(service.MultiService): |
| 350 | 357 | # not (we'd have to do a recursive traversal of all children to discover |
| 351 | 358 | # all the changes). |
| 352 | 359 | |
| 353 | | def __init__(self, http_port=None, distrib_port=None, allowForce=False): |
| | 360 | def __init__(self, http_port=None, distrib_port=None, allowForce=False, |
| | 361 | auth=None): |
| 354 | 362 | """Run a web server that provides Buildbot status. |
| 355 | 363 | |
| 356 | 364 | @type http_port: int or L{twisted.application.strports} string |
| … |
… |
class WebStatus(service.MultiService): |
| 385 | 393 | the strports parser. |
| 386 | 394 | @param allowForce: boolean, if True then the webserver will allow |
| 387 | 395 | visitors to trigger and cancel builds |
| | 396 | @type auth: a L{status.web.authentication.IAuth} or C{None} |
| | 397 | @param auth: an object that performs authentication to restrain |
| | 398 | access to the C{allowForce} features. Ignored if |
| | 399 | C{allowForce} is not True. If C{auth} is C{None}, the |
| | 400 | legacy behavior is used: people can force/stop builds |
| | 401 | without auth. |
| 388 | 402 | """ |
| 389 | 403 | |
| 390 | 404 | service.MultiService.__init__(self) |
| … |
… |
class WebStatus(service.MultiService): |
| 398 | 412 | distrib_port = "unix:%s" % distrib_port |
| 399 | 413 | self.distrib_port = distrib_port |
| 400 | 414 | self.allowForce = allowForce |
| | 415 | if allowForce and auth: |
| | 416 | assert IAuth.providedBy(auth) |
| | 417 | self.auth = auth |
| | 418 | else: |
| | 419 | if auth: |
| | 420 | log.msg("warning: discarding your authentication method: you" |
| | 421 | "must also set allowForce to True to use one.") |
| | 422 | self.auth = None |
| 401 | 423 | |
| 402 | 424 | # this will be replaced once we've been attached to a parent (and |
| 403 | 425 | # thus have a basedir and can reference BASEDIR/public_html/) |
| … |
… |
class WebStatus(service.MultiService): |
| 475 | 497 | self.site.resource = root |
| 476 | 498 | |
| 477 | 499 | def putChild(self, name, child_resource): |
| 478 | | """This behaves a lot like root.putChild() . """ |
| | 500 | """This behaves a lot like root.putChild() .""" |
| 479 | 501 | self.childrenToBeAdded[name] = child_resource |
| 480 | 502 | |
| 481 | 503 | def registerChannel(self, channel): |
| … |
… |
class WebStatus(service.MultiService): |
| 493 | 515 | |
| 494 | 516 | def getStatus(self): |
| 495 | 517 | return self.parent.getStatus() |
| | 518 | |
| 496 | 519 | def getControl(self): |
| 497 | 520 | if self.allowForce: |
| 498 | 521 | return IControl(self.parent) |
| 499 | 522 | return None |
| 500 | 523 | |
| | 524 | def isUsingLoginPassword(self): |
| | 525 | """Return a boolean to indicate whether or not this WebStatus uses a |
| | 526 | list of login/passwords for privileged actions.""" |
| | 527 | if self.auth: |
| | 528 | return True |
| | 529 | return False |
| | 530 | |
| | 531 | def authUser(self, login, password): |
| | 532 | """Check that login/password is a valid user/password pair and can be |
| | 533 | allowed to perform a privileged action. If this WebStatus is not |
| | 534 | password protected, this function returns False (conservative |
| | 535 | approach).""" |
| | 536 | # Do not mess up this function, it's critical for the security of the |
| | 537 | # WebStatus |
| | 538 | if not self.isUsingLoginPassword(): |
| | 539 | return False |
| | 540 | if self.auth.authenticate(login, password): |
| | 541 | return True |
| | 542 | log.msg("Authentication failed for `%s': %s" % (login, |
| | 543 | self.auth.errmsg())) |
| | 544 | return False |
| | 545 | |
| 501 | 546 | def getPortnum(self): |
| 502 | 547 | # this is for the benefit of unit tests |
| 503 | 548 | s = list(self)[0] |
diff --git a/buildbot/status/web/build.py b/buildbot/status/web/build.py
index 2522b92..432b282 100644
|
a
|
b
|
class StatusResourceBuild(HtmlResource): |
| 48 | 48 | |
| 49 | 49 | if self.build_control is not None: |
| 50 | 50 | stopURL = urllib.quote(req.childLink("stop")) |
| 51 | | data += make_stop_form(stopURL) |
| | 51 | data += make_stop_form(stopURL, self.isUsingLoginPassword(req)) |
| 52 | 52 | |
| 53 | 53 | if b.isFinished(): |
| 54 | 54 | results = b.getResults() |
| … |
… |
class StatusResourceBuild(HtmlResource): |
| 166 | 166 | return data |
| 167 | 167 | |
| 168 | 168 | def stop(self, req): |
| | 169 | if self.isUsingLoginPassword(req): |
| | 170 | if not self.authUser(req): |
| | 171 | # TODO: tell the web user that their request was denied |
| | 172 | return Redirect("..") |
| | 173 | |
| 169 | 174 | b = self.build_status |
| 170 | 175 | c = self.build_control |
| 171 | 176 | log.msg("web stopBuild of build %s:%s" % \ |
diff --git a/buildbot/status/web/builder.py b/buildbot/status/web/builder.py
index 1851dd1..fbc8fb5 100644
|
a
|
b
|
class StatusResourceBuilder(HtmlResource, OneLineMixin): |
| 107 | 107 | |
| 108 | 108 | if control is not None and connected_slaves: |
| 109 | 109 | forceURL = urllib.quote(req.childLink("force")) |
| 110 | | data += make_force_build_form(forceURL) |
| | 110 | data += make_force_build_form(forceURL, |
| | 111 | self.isUsingLoginPassword(req)) |
| 111 | 112 | elif control is not None: |
| 112 | 113 | data += """ |
| 113 | 114 | <p>All buildslaves appear to be offline, so it's not possible |
| … |
… |
class StatusResourceBuilder(HtmlResource, OneLineMixin): |
| 135 | 136 | r = "The web-page 'force build' button was pressed by '%s': %s\n" \ |
| 136 | 137 | % (name, reason) |
| 137 | 138 | log.msg("web forcebuild of builder '%s', branch='%s', revision='%s'" |
| 138 | | % (self.builder_status.getName(), branch, revision)) |
| | 139 | "by user '%s'" % (self.builder_status.getName(), branch, |
| | 140 | revision, name)) |
| 139 | 141 | |
| 140 | 142 | if not self.builder_control: |
| 141 | 143 | # TODO: tell the web user that their request was denied |
| 142 | 144 | log.msg("but builder control is disabled") |
| 143 | 145 | return Redirect("..") |
| 144 | 146 | |
| | 147 | if self.isUsingLoginPassword(req): |
| | 148 | if not self.authUser(req): |
| | 149 | # TODO: tell the web user that their request was denied |
| | 150 | return Redirect("..") |
| | 151 | |
| 145 | 152 | # keep weird stuff out of the branch and revision strings. TODO: |
| 146 | 153 | # centralize this somewhere. |
| 147 | 154 | if not re.match(r'^[\w\.\-\/]*$', branch): |
| … |
… |
class StatusResourceBuilder(HtmlResource, OneLineMixin): |
| 155 | 162 | if not revision: |
| 156 | 163 | revision = None |
| 157 | 164 | |
| 158 | | # TODO: if we can authenticate that a particular User pushed the |
| 159 | | # button, use their name instead of None, so they'll be informed of |
| 160 | | # the results. |
| | 165 | # TODO: we can authenticate that a particular User clicked the |
| | 166 | # button, so we could use their name instead of None, so they'll be |
| | 167 | # informed of the results. The problem is that we must create a |
| | 168 | # buildbot.changes.changes.Change instance which doesn't really fit |
| | 169 | # this use case (it requires a list of changed files which is tedious |
| | 170 | # to compute at this stage) |
| 161 | 171 | s = SourceStamp(branch=branch, revision=revision) |
| 162 | 172 | req = BuildRequest(r, s, self.builder_status.getName()) |
| 163 | 173 | try: |
diff --git a/docs/buildbot.texinfo b/docs/buildbot.texinfo
index 5c6ca17..278cf4a 100644
|
a
|
b
|
True, then the web page will provide a ``Force Build'' button that |
| 6120 | 6120 | allows visitors to manually trigger builds. This is useful for |
| 6121 | 6121 | developers to re-run builds that have failed because of intermittent |
| 6122 | 6122 | problems in the test suite, or because of libraries that were not |
| 6123 | | installed at the time of the previous build. You may not wish to allow |
| 6124 | | strangers to cause a build to run: in that case, set this to False to |
| 6125 | | remove these buttons. The default value is False. |
| | 6123 | installed at the time of the previous build. The default value is False. |
| 6126 | 6124 | |
| | 6125 | You may not wish to allow strangers to cause a build to run or to stop |
| | 6126 | current builds, in that case you can pass an instance of |
| | 6127 | @code{status.web.authentication.IAuth} as a @code{auth} keyword argument. |
| | 6128 | The class @code{BasicAuth} implements a basic authentication mechanism |
| | 6129 | using a list of login/password pairs provided from the configuration file. |
| | 6130 | |
| | 6131 | @example |
| | 6132 | from buildbot.status.html import WebStatus |
| | 6133 | from buildbot.status.web.authentication import BasicAuth |
| | 6134 | users = [('login', 'password'), ('bob', 'secret-pass')] |
| | 6135 | c['status'].append(WebStatus(http_port=8080, auth=BasicAuth(users))) |
| | 6136 | @end example |
| 6127 | 6137 | |
| 6128 | 6138 | |
| 6129 | 6139 | @node Buildbot Web Resources, XMLRPC server, WebStatus Configuration Parameters, WebStatus |