Permalink
Browse files

allow individual control of all web-based actions

This replaces allowForce=.. and auth=.. with an Authz object, which
can specify how to handle:
 * gracefulShutdown
 * forceBuild
 * forceAllBuilds
 * stopBuild
 * stopAllBuilds
 * pingBuilder
 * cancelPendingBuild

fixes #701
  • Loading branch information...
Dustin J. Mitchell
Dustin J. Mitchell committed Feb 14, 2010
1 parent 0832998 commit 7572c5bdad4a09393b665fff2939e605df58deb1
View
6 NEWS
@@ -12,6 +12,12 @@ to JavaScript status displays. See /json/help for details.
TODO - write this :)
+** Authorization Framework
+
+The web-based status displays now provide fine-grained control over who can
+do what - force builds, stop builds, cancel builds, etc. See the manual for
+configuration details.
+
** Cleanup, Bug Fixes, and Test Fixes
Thanks to help from a number of devoted contributors, this version of Buildbot
@@ -848,7 +848,7 @@ def requestBuildSoon(self, req):
raise interfaces.NoSlaveError
self.requestBuild(req)
- def resubmitBuild(self, bs, reason="<rebuild, no reason given>", extraProperties=None):
+ def rebuildBuild(self, bs, reason="<rebuild, no reason given>", extraProperties=None):
if not bs.isFinished():
return
@@ -131,13 +131,20 @@ c['builders'] = [b1]
c['status'] = []
-# Use allowForce=True (boolean, not a string. ie: not 'True') to allow
-# Forcing Builds in the Web User Interface. The default is False.
-# from buildbot.status import html
-# c['status'].append(html.WebStatus(http_port=8010,allowForce=True))
-
from buildbot.status import html
-c['status'].append(html.WebStatus(http_port=8010))
+from buildbot.status.web import auth, authz
+authz=Authz(
+ # change any of these to True to enable; see the manual for more
+ # options
+ gracefulShutdown = False,
+ forceBuild = False,
+ forceAllBuilds = False,
+ pingBuilder = False,
+ stopBuild = False,
+ stopAllBuilds = False,
+ cancelPendingBuild = False,
+)
+c['status'].append(html.WebStatus(http_port=8010, authz=authz))
# from buildbot.status import mail
# c['status'].append(mail.MailNotifier(fromaddr="buildbot@localhost",
@@ -0,0 +1,109 @@
+from buildbot.status.web.auth import IAuth
+
+# Programming against Authz
+#
+# There are two times to check authorization in a web app:
+# 1. do I advertise the activity (show the form, link, etc.)
+# 2. is this request authorized?
+#
+# The first is accomplished via advertiseAction. In general, this
+# is used in a Jinja template:
+#
+# {{ if authz.advertiseAction('myNewTrick') }}
+# <form action="{{ myNewTrick_url }}"> ...
+#
+# this requires that the template's context include 'authz'. This
+# object is available from any HtmlResource subclass as
+#
+# cxt['authz'] = self.getAuthz(req)
+#
+# Actions can optionally require authentication, so use needAuthForm
+# to determine whether to require a 'username' and 'passwd' field in
+# the generated form. These fields are usually generated by the auth()
+# form:
+#
+# {% if authz.needAuthForm('myNewTrick') %}
+# {{ auth() }}
+# {% endif %}
+#
+# Once the POST request comes in, it's time to check authorization again.
+# This usually looks something like
+#
+# if not self.getAuthz(req).actionAllowed('myNewTrick', req, someExtraArg):
+# return Redirect("../../authfail") # double-check this path!
+#
+# the someExtraArg is optional (it's handled with *args, so you can have
+# several if you want), and is given to the user's authorization function.
+# For example, a build-related action should pass the build status, so that
+# the user's authorization function could ensure that devs can only operate
+# on their own builds.
+
+class Authz(object):
+ """Decide who can do what."""
+
+ knownActions = [
+ # If you add a new action here, be sure to also update the documentation
+ 'gracefulShutdown',
+ 'forceBuild',
+ 'forceAllBuilds',
+ 'pingBuilder',
+ 'stopBuild',
+ 'stopAllBuilds',
+ 'cancelPendingBuild',
+ ]
+
+ def __init__(self,
+ default_action=False,
+ auth=None,
+ **kwargs):
+ self.auth = auth
+ if auth:
+ assert IAuth.providedBy(auth)
+
+ self.config = dict( (a, default_action) for a in self.knownActions )
+ for act in self.knownActions:
+ if act in kwargs:
+ self.config[act] = kwargs[act]
+ del kwargs[act]
+
+ if kwargs:
+ raise ValueError("unknown authorization action(s) " + ", ".join(kwargs.keys()))
+
+ def advertiseAction(self, action):
+ """Should the web interface even show the form for ACTION?"""
+ if action not in self.knownActions:
+ raise KeyError("unknown action")
+ cfg = self.config.get(action, False)
+ if cfg:
+ return True
+ return False
+
+ def needAuthForm(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 == 'auth' or callable(cfg):
+ return True
+ return False
+
+ 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 == 'auth' or callable(cfg):
+ if not self.auth:
+ return False
+ user = request.args.get("username", ["<unknown>"])[0]
+ passwd = request.args.get("passwd", ["<no-password>"])[0]
+ if user == "<unknown>" or passwd == "<no-password>":
+ return False
+ if self.auth.authenticate(user, passwd):
+ if callable(cfg) and not cfg(user, *args):
+ return False
+ return True
+ return False
+ else:
+ return True # anyone can do this..
@@ -225,18 +225,11 @@ def render(self, request):
return ''
return data
- def getControl(self, request):
- return request.site.buildbot_service.getControl()
-
- def isUsingUserPasswd(self, request):
- return request.site.buildbot_service.isUsingUserPasswd()
-
- def authUser(self, request):
- user = request.args.get("username", ["<unknown>"])[0]
- passwd = request.args.get("passwd", ["<no-password>"])[0]
- if user == "<unknown>" or passwd == "<no-password>":
- return False
- return request.site.buildbot_service.authUser(user, passwd)
+ def getAuthz(self, request):
+ return request.site.buildbot_service.authz
+
+ def getBuildmaster(self, request):
+ return request.site.buildbot_service.master
def getChangemaster(self, request):
return request.site.buildbot_service.getChangeSvc()
@@ -25,7 +25,8 @@
from buildbot.status.web.status_json import JsonStatusResource
from buildbot.status.web.xmlrpc import XMLRPCServer
from buildbot.status.web.about import AboutBuildbot
-from buildbot.status.web.auth import IAuth, AuthFailResource
+from buildbot.status.web.authz import Authz
+from buildbot.status.web.auth import AuthFailResource
from buildbot.status.web.root import RootPage
# this class contains the status services (WebStatus and the older Waterfall)
@@ -110,7 +111,6 @@ def get_reload_time(self, request):
def content(self, req, cxt):
status = self.getStatus(req)
- control = self.getControl(req)
numbuilds = int(req.args.get("numbuilds", [self.numbuilds])[0])
builders = req.args.get("builder", [])
branches = [b for b in req.args.get("branch", []) if b]
@@ -124,26 +124,23 @@ def content(self, req, cxt):
cxt['builders'] = builders
got = 0
- building = False
- online = cxt['online_count'] = 0
+ building = 0
+ online = 0
builders = cxt['builds'] = []
for build in g:
got += 1
builders.append(self.get_line_values(req, build))
builder_status = build.getBuilder().getState()[0]
if builder_status == "building":
- building = True
+ building += 1
online += 1
elif builder_status != "offline":
online += 1
- if control is not None:
- cxt['use_user_passwd'] = self.isUsingUserPasswd(req)
- if building:
- cxt['stop_url'] = "builders/_all/stop"
- if online:
- cxt['force_url'] = "builders/_all/force"
+ cxt['authz'] = self.getAuthz(req)
+ cxt['num_online'] = online
+ cxt['num_building'] = building
template = req.site.buildbot_service.templates.get_template('onelineperbuild.html')
return template.render(**cxt)
@@ -193,15 +190,14 @@ class OneBoxPerBuilder(HtmlResource):
def content(self, req, cxt):
status = self.getStatus(req)
- control = self.getControl(req)
builders = req.args.get("builder", status.getBuilderNames())
branches = [b for b in req.args.get("branch", []) if b]
cxt['branches'] = branches
bs = cxt['builders'] = []
- building = False
+ building = 0
online = 0
base_builders_url = path_to_root(req) + "builders/"
for bn in builders:
@@ -231,17 +227,14 @@ def content(self, req, cxt):
builder_status = builder.getState()[0]
if builder_status == "building":
- building = True
+ building += 1
online += 1
elif builder_status != "offline":
online += 1
- if control is not None:
- cxt['use_user_passwd'] = self.isUsingUserPasswd(req)
- if building:
- cxt['stop_url'] = "builders/_all/stop"
- if online:
- cxt['force_url'] = "builders/_all/force"
+ cxt['authz'] = self.getAuthz(req)
+ cxt['num_building'] = online
+ cxt['num_online'] = online
template = req.site.buildbot_service.templates.get_template("oneboxperbuilder.html")
return template.render(**cxt)
@@ -345,11 +338,11 @@ class WebStatus(service.MultiService):
# not (we'd have to do a recursive traversal of all children to discover
# all the changes).
- def __init__(self, http_port=None, distrib_port=None, allowForce=False,
+ def __init__(self, http_port=None, distrib_port=None, allowForce=None,
public_html="public_html", site=None, numbuilds=20,
num_events=200, num_events_max=None, auth=None,
order_console_by_time=False, changecommentlink=None,
- revlink=None):
+ revlink=None, authz=None):
"""Run a web server that provides Buildbot status.
@type http_port: int or L{twisted.application.strports} string
@@ -383,8 +376,11 @@ def __init__(self, http_port=None, distrib_port=None, allowForce=False,
a non-absolute pathname will probably confuse
the strports parser.
- @param allowForce: boolean, if True then the webserver will allow
- visitors to trigger and cancel builds
+ @param allowForce: deprecated; use authz instead
+ @param auth: deprecated; use with authz
+
+ @param authz: a buildbot.status.web.authz.Authz instance giving the authorization
+ parameters for this view
@param public_html: the path to the public_html directory for this display,
either absolute or relative to the basedir. The default
@@ -444,21 +440,32 @@ def __init__(self, http_port=None, distrib_port=None, allowForce=False,
if distrib_port[0] in "/~.": # pathnames
distrib_port = "unix:%s" % distrib_port
self.distrib_port = distrib_port
- self.allowForce = allowForce
self.num_events = num_events
if num_events_max:
assert num_events_max >= num_events
self.num_events_max = num_events_max
self.public_html = public_html
- if self.allowForce and auth:
- assert IAuth.providedBy(auth)
- self.auth = auth
- else:
+ # make up an authz if allowForce was given
+ if authz:
+ if allowForce is not None:
+ raise ValueError("cannot use both allowForce and authz parameters")
if auth:
- log.msg("Warning: Ignoring authentication. allowForce must be"
- " set to True use this")
- self.auth = None
+ raise ValueError("cannot use both auth and authz parameters (pass "
+ "auth as an Authz parameter)")
+ else:
+ # invent an authz
+ if allowForce and auth:
+ authz = Authz(auth=auth, default_action="auth")
+ elif allowForce:
+ authz = Authz(default_action=True)
+ else:
+ if auth:
+ log.msg("Warning: Ignoring authentication. Search for 'authorization'"
+ " in the manual")
+ authz = Authz() # no authorization for anything
+
+ self.authz = authz
self.orderConsoleByTime = order_console_by_time
@@ -582,11 +589,6 @@ def stopService(self):
def getStatus(self):
return self.master.getStatus()
- def getControl(self):
- if self.allowForce:
- return IControl(self.master)
- return None
-
def getChangeSvc(self):
return self.master.change_svc
@@ -595,23 +597,13 @@ def getPortnum(self):
s = list(self)[0]
return s._port.getHost().port
- def isUsingUserPasswd(self):
- """Returns boolean to indicate if this WebStatus uses authentication"""
- if self.auth:
- return True
- return False
-
- def authUser(self, user, passwd):
- """Check that user/passwd is a valid user/pass tuple and can should be
- allowed to perform the action. If this WebStatus is not password
- protected, this function returns False."""
- if not self.isUsingUserPasswd():
- return False
- if self.auth.authenticate(user, passwd):
- return True
- log.msg("Authentication failed for '%s': %s" % (user,
- self.auth.errmsg()))
- return False
+ # What happened to getControl?!
+ #
+ # instead of passing control objects all over the place in the web
+ # code, at the few places where a control instance is required we
+ # find the requisite object manually, starting at the buildmaster.
+ # This is in preparation for removal of the IControl hierarchy
+ # entirely.
# resources can get access to the IStatus by calling
# request.site.buildbot_service.getStatus()
Oops, something went wrong.

0 comments on commit 7572c5b

Please sign in to comment.