kojid: extend SCM.assert_allowed with hub policy

This is a simple extention of `SCM.assert_allowed`

- `assert_allowed_by_policy` will set the default "use_common" to False which is different to the old behavior
- `channel`, `user_id`, `scratch` are passed in the `policy_data` with scminfo right now.

This is a prototype for this change, and there are some other solutions could be implemented too

- Use a scmpolicy plugin as `postSCMCheckout` callback, the pro is that we can do more checks after the source is initialized on builder, meanwhile, the con is that the source will be downloaded even it is denied by policy. It might be a potential risk?
- Do the scm check in hub's `make_task`, this looks straightforward, but may lack some builder's information

fixes: #2757
This commit is contained in:
Yu Ming Zhu 2021-07-16 15:33:39 +00:00
parent ec70d21c41
commit 47c4b5d70b
8 changed files with 431 additions and 25 deletions

View file

@ -1633,7 +1633,17 @@ class BuildMavenTask(BaseBuildTask):
self.opts = opts
scm = SCM(url)
scm.assert_allowed(self.options.allowed_scms)
scm_policy_opts = {
'user_id': self.taskinfo['owner'],
'channel': self.session.getChannel(self.taskinfo['channel_id'],
strict=True)['name'],
'scratch': self.opts.get('scratch')
}
scm.assert_allowed(allowed=self.options.allowed_scms,
session=self.session,
by_config=self.options.allowed_scms_use_config,
by_policy=self.options.allowed_scms_use_policy,
opts=scm_policy_opts)
repo_id = opts.get('repo_id')
if not repo_id:
raise koji.BuildError('A repo_id must be provided')
@ -1707,7 +1717,11 @@ class BuildMavenTask(BaseBuildTask):
if self.opts.get('patches'):
patchlog = self.workdir + '/patches.log'
patch_scm = SCM(self.opts.get('patches'))
patch_scm.assert_allowed(self.options.allowed_scms)
patch_scm.assert_allowed(allowed=self.options.allowed_scms,
session=self.session,
by_config=self.options.allowed_scms_use_config,
by_policy=self.options.allowed_scms_use_policy,
opts=scm_policy_opts)
self.run_callbacks('preSCMCheckout', scminfo=patch_scm.get_info(),
build_tag=build_tag, scratch=opts.get('scratch'),
buildroot=buildroot)
@ -1990,7 +2004,16 @@ class WrapperRPMTask(BaseBuildTask):
assert False # pragma: no cover
scm = SCM(spec_url)
scm.assert_allowed(self.options.allowed_scms)
scm.assert_allowed(allowed=self.options.allowed_scms,
session=self.session,
by_config=self.options.allowed_scms_use_config,
by_policy=self.options.allowed_scms_use_policy,
opts={
'user_id': self.taskinfo['owner'],
'channel': self.session.getChannel(self.taskinfo['channel_id'],
strict=True)['name'],
'scratch': opts.get('scratch')
})
repo_id = opts.get('repo_id')
if not repo_id:
@ -2979,7 +3002,16 @@ class ImageTask(BaseTaskHandler):
self.logger.debug("ksfile = %s" % ksfile)
if self.opts.get('ksurl'):
scm = SCM(self.opts['ksurl'])
scm.assert_allowed(self.options.allowed_scms)
scm.assert_allowed(allowed=self.options.allowed_scms,
session=self.session,
by_config=self.options.allowed_scms_use_config,
by_policy=self.options.allowed_scms_use_policy,
opts={
'user_id': self.taskinfo['owner'],
'channel': self.session.getChannel(self.taskinfo['channel_id'],
strict=True)['name'],
'scratch': self.opts.get('scratch')
})
logfile = os.path.join(self.workdir, 'checkout.log')
self.run_callbacks('preSCMCheckout', scminfo=scm.get_info(),
build_tag=build_tag, scratch=self.opts.get('scratch'),
@ -3453,7 +3485,16 @@ class LiveMediaTask(ImageTask):
can find the checked out templates.
"""
scm = SCM(self.opts['lorax_url'])
scm.assert_allowed(self.options.allowed_scms)
scm.assert_allowed(allowed=self.options.allowed_scms,
session=self.session,
by_config=self.options.allowed_scms_use_config,
by_policy=self.options.allowed_scms_use_policy,
opts={
'user_id': self.taskinfo['owner'],
'channel': self.session.getChannel(self.taskinfo['channel_id'],
strict=True)['name'],
'scratch': self.opts.get('scratch')
})
logfile = os.path.join(self.workdir, 'lorax-templates-checkout.log')
checkout_dir = scm.checkout(build_root.tmpdir(),
self.session, self.getUploadDir(), logfile)
@ -3700,7 +3741,16 @@ class OzImageTask(BaseTaskHandler):
self.logger.debug("ksfile = %s" % ksfile)
if self.opts.get('ksurl'):
scm = SCM(self.opts['ksurl'])
scm.assert_allowed(self.options.allowed_scms)
scm.assert_allowed(allowed=self.options.allowed_scms,
session=self.session,
by_config=self.options.allowed_scms_use_config,
by_policy=self.options.allowed_scms_use_policy,
opts={
'user_id': self.taskinfo['owner'],
'channel': self.session.getChannel(self.taskinfo['channel_id'],
strict=True)['name'],
'scratch': self.opts.get('scratch')
})
logfile = os.path.join(self.workdir, 'checkout-%s.log' % self.arch)
self.run_callbacks('preSCMCheckout', scminfo=scm.get_info(),
build_tag=build_tag, scratch=self.opts.get('scratch'))
@ -4527,7 +4577,16 @@ class BuildIndirectionImageTask(OzImageTask):
self.logger.debug("filepath = %s" % filepath)
if fileurl:
scm = SCM(fileurl)
scm.assert_allowed(self.options.allowed_scms)
scm.assert_allowed(allowed=self.options.allowed_scms,
session=self.session,
by_config=self.options.allowed_scms_use_config,
by_policy=self.options.allowed_scms_use_policy,
opts={
'user_id': self.taskinfo['owner'],
'channel': self.session.getChannel(self.taskinfo['channel_id'],
strict=True)['name'],
'scratch': self.opts.get('scratch')
})
self.run_callbacks('preSCMCheckout', scminfo=scm.get_info(),
build_tag=build_tag, scratch=self.opts.get('scratch'))
logfile = os.path.join(self.workdir, 'checkout.log')
@ -4935,12 +4994,20 @@ class BuildSRPMFromSCMTask(BaseBuildTask):
return self.checkHostArch(tag, hostdata)
def handler(self, url, build_tag, opts=None):
# will throw a BuildError if the url is invalid
scm = SCM(url)
scm.assert_allowed(self.options.allowed_scms)
if opts is None:
opts = {}
# will throw a BuildError if the url is invalid
scm = SCM(url)
scm.assert_allowed(allowed=self.options.allowed_scms,
session=self.session,
by_config=self.options.allowed_scms_use_config,
by_policy=self.options.allowed_scms_use_policy,
opts={
'user_id': self.taskinfo['owner'],
'channel': self.session.getChannel(self.taskinfo['channel_id'],
strict=True)['name'],
'scratch': opts.get('scratch')
})
repo_id = opts.get('repo_id')
if not repo_id:
raise koji.BuildError("A repo id must be provided")
@ -6362,6 +6429,8 @@ def get_options():
'mock_bootstrap_image': False,
'pkgurl': None,
'allowed_scms': '',
'allowed_scms_by_config': True,
'allowed_scms_by_policy': False,
'scm_credentials_dir': None,
'support_rpm_source_layout': True,
'yum_proxy': None,
@ -6390,7 +6459,7 @@ def get_options():
elif name in ['offline_retry', 'use_createrepo_c', 'createrepo_skip_stat',
'createrepo_update', 'use_fast_upload', 'support_rpm_source_layout',
'build_arch_can_fail', 'no_ssl_verify', 'log_timestamps',
'allow_noverifyssl']:
'allow_noverifyssl', 'allowed_scms_by_config', 'allowed_scms_by_policy']:
defaults[name] = config.getboolean('kojid', name)
elif name in ['plugin', 'plugins']:
defaults['plugin'] = value.split()

View file

@ -77,6 +77,14 @@ topurl=http://hub.example.com/kojifiles
; is run by default.
allowed_scms=scm.example.com:/cvs/example git.example.org:/example svn.example.org:/users/*:no
; If use the option allowed_scms above for allowing / denying SCM, default: true
; allowed_scms_use_config = true
; If use hub policy build_from_scm for allowing / denying SCM, default: false
; notice that if both options are enabled, both assertions will be applied, and user_common and
; source_cmd will be overridden by the policy's result.
; allowed_scms_use_policy = false
; A directory to bind mount into Source RPM creation so that some
; credentials can be supplied when required to fetch sources, e.g.
; when the place the sources are fetched from requires all accesses to

View file

@ -524,6 +524,13 @@ _default_policies = {
has_perm admin :: allow
all :: deny
''',
'build_from_scm': '''
has_perm admin :: allow
# match scmtype CVS CVS+SSH && match scmhost scm.example.com && match scmrepository /cvs/example :: allow
# match scmtype GIT GIT+SSH && match scmhost git.example.org && match scmrepository /example :: allow
# match scmtype SVN SVN+SSH && match scmhost svn.example.org && match scmrepository /users/* :: allow
all :: deny
''', # noqa: E501
'package_list': '''
has_perm admin :: allow
has_perm tag :: allow

View file

@ -327,7 +327,16 @@ class SCM(object):
# return parsed values
return (scheme, user, netloc, path, query, fragment)
def assert_allowed(self, allowed):
def assert_allowed(self, allowed='', session=None, by_config=True, by_policy=False, opts=None):
if by_config:
self.assert_allowed_by_config(allowed or '')
if by_policy:
if session is None:
raise koji.ConfigurationError(
'When allowed SCM assertion is by policy, session must be passed in.')
self.assert_allowed_by_policy(session, **(opts or {}))
def assert_allowed_by_config(self, allowed):
"""
Check this scm against allowed list and apply options
@ -396,6 +405,74 @@ class SCM(object):
raise koji.BuildError(
'%s:%s is not in the list of allowed SCMs' % (self.host, self.repository))
def assert_allowed_by_policy(self, session, **opts):
"""
Check this scm against hub policy: build_from_scm and apply options
The policy data is the combination of scminfo with scm prefix and kwargs.
It should at least contain following keys:
- scmurl
- scmscheme
- scmuser
- scmhost
- scmrepository
- scmmodule
- scmrevision
- scmtype
More keys could be added in opts. You can pass any reasonable data which could be handled
by policy tests, like:
- scratch (if the task is scratch)
- channel (which channel the task is assigned)
- user_id (the task owner)
The format of the action returned from build_from_scm could be one of following forms::
allow [use_common] [source_cmd]
deny [reason]
If use_common is not set, use_common property is False.
If source_cmd is none, it will be parsed as None. If it not set, the default value:
['make', 'sources'], or the value set by :func:`~koji.daemon.SCM.assert_allowed_by_config`
will be set.
Policy example:
build_from_scm =
bool scratch :: allow none
match scmhost scm.example.com :: allow use_common make sources
match scmhost scm2.example.com :: allow
all :: deny
:param koji.ClientSession session: the session object to call hub xmlrpc APIs.
It should be a host session.
:raises koji.BuildError: if the scm is denied.
"""
policy_data = {}
for k, v in six.iteritems(self.get_info()):
if not k.startswith('scm'):
k = 'scm' + k
policy_data[k] = v
policy_data.update(opts)
result = (session.host.evalPolicy('build_from_scm', policy_data) or '').split()
is_allowed = result and result[0].lower() in ('yes', 'true', 'allow', 'allowed')
if not is_allowed:
raise koji.BuildError(
'SCM: %s:%s is not allowed, reason: %s' % (self.host, self.repository,
' '.join(result[1:]) or None))
# Apply options when it's allowed
applied = result[1:]
self.use_common = len(applied) != 0 and applied[0].lower() == 'use_common'
idx = 1 if self.use_common else 0
self.source_cmd = applied[idx:] or self.source_cmd
if self.source_cmd is not None and len(self.source_cmd) > 0 \
and self.source_cmd[0].lower() == 'none':
self.source_cmd = None
def checkout(self, scmdir, session=None, uploadpath=None, logfile=None):
"""
Checkout the module from SCM. Accepts the following parameters:

View file

@ -312,6 +312,7 @@ class BaseTaskHandler(object):
self.workdir = workdir
self.logger = logging.getLogger("koji.build.BaseTaskHandler")
self.manager = None
self.taskinfo = None
def setManager(self, manager):
"""Set the manager attribute
@ -597,15 +598,20 @@ class BaseTaskHandler(object):
def run_callbacks(self, plugin, *args, **kwargs):
if 'taskinfo' not in kwargs:
try:
taskinfo = self.taskinfo
except AttributeError:
self.taskinfo = self.session.getTaskInfo(self.id, request=True)
taskinfo = self.taskinfo
kwargs['taskinfo'] = taskinfo
kwargs['taskinfo'] = self.taskinfo
kwargs['session'] = self.session
koji.plugin.run_callbacks(plugin, *args, **kwargs)
@property
def taskinfo(self):
if not getattr(self, '_taskinfo', None):
self._taskinfo = self.session.getTaskInfo(self.id, request=True, strict=True)
return self._taskinfo
@taskinfo.setter
def taskinfo(self, taskinfo):
self._taskinfo = taskinfo
class FakeTask(BaseTaskHandler):
Methods = ['someMethod']

View file

@ -8,9 +8,40 @@ import unittest
import koji
import koji.daemon
import koji.policy
from koji.daemon import SCM
policy = {
'one': '''
match scmhost goodserver :: allow none
match scmhost badserver :: deny
match scmhost maybeserver && match scmrepository /badpath/* :: deny
all :: allow
''',
'two': '''
match scmhost default :: allow
match scmhost nocommon :: allow
match scmhost common :: allow use_common
match scmhost srccmd :: allow fedpkg sources
match scmhost nosrc :: allow none
match scmhost mixed && match scmrepository /foo/* :: allow
match scmhost mixed && match scmrepository /bar/* :: allow use_common
match scmhost mixed && match scmrepository /baz/* :: allow fedpkg sources
match scmhost mixed && match scmrepository /foobar/* :: allow use_common fedpkg sources
match scmhost mixed && match scmrepository /foobaz/* :: allow use_common none
'''
}
class FakePolicy(object):
def __init__(self, rule):
base_tests = koji.policy.findSimpleTests(vars(koji.policy))
self.ruleset = koji.policy.SimpleRuleSet(rule.splitlines(), base_tests)
def evalPolicy(self, name, data):
return self.ruleset.apply(data)
class TestSCM(unittest.TestCase):
@ -67,8 +98,22 @@ class TestSCM(unittest.TestCase):
self.assertEqual(scm.source_cmd, ['make', 'sources'])
self.assertEqual(scm.scmtype, 'GIT')
def test_assert_allowed_basic(self):
scm = SCM("git://scm.example.com/path1#1234")
# session must be passed
with self.assertRaises(koji.GenericError) as cm:
scm.assert_allowed(session=None, by_config=False, by_policy=True)
self.assertEqual(str(cm.exception),
'When allowed SCM assertion is by policy, session must be passed in.')
# allowed could not be None
scm.assert_allowed_by_config = mock.MagicMock()
scm.assert_allowed(allowed=None, by_config=True, by_policy=False)
scm.assert_allowed_by_config.assert_called_once_with('')
@mock.patch('logging.getLogger')
def test_allowed(self, getLogger):
def test_allowed_by_config(self, getLogger):
config = '''
goodserver:*:no
!badserver:*
@ -104,7 +149,7 @@ class TestSCM(unittest.TestCase):
raise AssertionError("allowed bad url: %s" % url)
@mock.patch('logging.getLogger')
def test_badrule(self, getLogger):
def test_badrule_by_config(self, getLogger):
config = '''
bogus-entry-should-be-ignored
goodserver:*:no
@ -115,7 +160,7 @@ class TestSCM(unittest.TestCase):
scm.assert_allowed(config)
@mock.patch('logging.getLogger')
def test_opts(self, getLogger):
def test_opts_by_config(self, getLogger):
config = '''
default:*
nocommon:*:no
@ -196,6 +241,181 @@ class TestSCM(unittest.TestCase):
with self.assertRaises(koji.BuildError):
scm.assert_allowed(config)
def test_allowed_by_policy(self):
good = [
"git://goodserver/path1#1234",
"git+ssh://maybeserver/path1#1234",
]
bad = [
"cvs://badserver/projects/42#ref",
"svn://badserver/projects/42#ref",
"git://maybeserver/badpath/project#1234",
"git://maybeserver//badpath/project#1234",
"git://maybeserver////badpath/project#1234",
"git://maybeserver/./badpath/project#1234",
"git://maybeserver//.//badpath/project#1234",
"git://maybeserver/goodpath/../badpath/project#1234",
"git://maybeserver/goodpath/..//badpath/project#1234",
"git://maybeserver/..//badpath/project#1234",
]
session = mock.MagicMock()
session.host.evalPolicy.side_effect = FakePolicy(policy['one']).evalPolicy
for url in good:
scm = SCM(url)
scm.assert_allowed(session=session, by_config=False, by_policy=True)
for url in bad:
scm = SCM(url)
with self.assertRaises(koji.BuildError) as cm:
scm.assert_allowed(session=session, by_config=False, by_policy=True)
self.assertRegexpMatches(str(cm.exception), '^SCM: .* is not allowed, reason: None$')
def test_opts_by_policy(self):
session = mock.MagicMock()
session.host.evalPolicy.side_effect = FakePolicy(policy['two']).evalPolicy
url = "git://default/koji.git#1234"
scm = SCM(url)
scm.assert_allowed_by_policy(session=session)
self.assertEqual(scm.use_common, False)
self.assertEqual(scm.source_cmd, ['make', 'sources'])
url = "git://nocommon/koji.git#1234"
scm = SCM(url)
scm.assert_allowed_by_policy(session=session)
self.assertEqual(scm.use_common, False)
self.assertEqual(scm.source_cmd, ['make', 'sources'])
url = "git://common/koji.git#1234"
scm = SCM(url)
scm.assert_allowed_by_policy(session=session)
self.assertEqual(scm.use_common, True)
self.assertEqual(scm.source_cmd, ['make', 'sources'])
url = "git://srccmd/koji.git#1234"
scm = SCM(url)
scm.assert_allowed_by_policy(session=session)
self.assertEqual(scm.use_common, False)
self.assertEqual(scm.source_cmd, ['fedpkg', 'sources'])
url = "git://nosrc/koji.git#1234"
scm = SCM(url)
scm.assert_allowed_by_policy(session=session)
self.assertEqual(scm.use_common, False)
self.assertEqual(scm.source_cmd, None)
url = "git://mixed/foo/koji.git#1234"
scm = SCM(url)
scm.assert_allowed_by_policy(session=session)
self.assertEqual(scm.use_common, False)
self.assertEqual(scm.source_cmd, ['make', 'sources'])
url = "git://mixed/bar/koji.git#1234"
scm = SCM(url)
scm.assert_allowed_by_policy(session=session)
self.assertEqual(scm.use_common, True)
self.assertEqual(scm.source_cmd, ['make', 'sources'])
url = "git://mixed/baz/koji.git#1234"
scm = SCM(url)
scm.assert_allowed_by_policy(session=session)
self.assertEqual(scm.use_common, False)
self.assertEqual(scm.source_cmd, ['fedpkg', 'sources'])
url = "git://mixed/koji.git#1234"
scm = SCM(url)
with self.assertRaises(koji.BuildError):
scm.assert_allowed_by_policy(session=session)
url = "git://mixed/foo/koji.git#1234"
scm = SCM(url)
scm.assert_allowed_by_policy(session=session)
self.assertEqual(scm.use_common, False)
self.assertEqual(scm.source_cmd, ['make', 'sources'])
url = "git://mixed/bar/koji.git#1234"
scm = SCM(url)
scm.assert_allowed_by_policy(session=session)
self.assertEqual(scm.use_common, True)
self.assertEqual(scm.source_cmd, ['make', 'sources'])
url = "git://mixed/baz/koji.git#1234"
scm = SCM(url)
scm.assert_allowed_by_policy(session=session)
self.assertEqual(scm.use_common, False)
self.assertEqual(scm.source_cmd, ['fedpkg', 'sources'])
url = "git://mixed/foobar/koji.git#1234"
scm = SCM(url)
scm.assert_allowed_by_policy(session=session)
self.assertEqual(scm.use_common, True)
self.assertEqual(scm.source_cmd, ['fedpkg', 'sources'])
url = "git://mixed/foobaz/koji.git#1234"
scm = SCM(url)
scm.assert_allowed_by_policy(session=session)
self.assertEqual(scm.use_common, True)
self.assertIsNone(scm.source_cmd)
url = "git://nomatch/koji.git#1234"
scm = SCM(url)
with self.assertRaises(koji.BuildError):
scm.assert_allowed_by_policy(session=session)
def test_assert_allowed_by_both(self):
config = '''
default:*:no:
mixed:/foo/*:yes
mixed:/bar/*:no
mixed:/baz/*:no:centpkg,sources
mixed:/foobar/*:no:
mixed:/foobaz/*:no:centpkg,sources
'''
session = mock.MagicMock()
session.host.evalPolicy.side_effect = FakePolicy(policy['two']).evalPolicy
url = "git://default/koji.git#1234"
scm = SCM(url)
# match scmhost default :: allow
scm.assert_allowed(allowed=config, session=session, by_config=True, by_policy=True)
self.assertEqual(scm.use_common, False)
self.assertIsNone(scm.source_cmd)
url = "git://mixed/foo/koji.git#1234"
scm = SCM(url)
# match scmhost mixed && match scmrepository /foo/* :: allow
scm.assert_allowed(allowed=config, session=session, by_config=True, by_policy=True)
self.assertEqual(scm.use_common, False)
self.assertEqual(scm.source_cmd, ['make', 'sources'])
url = "git://mixed/bar/koji.git#1234"
scm = SCM(url)
# match scmhost mixed && match scmrepository /bar/* :: allow use_common
scm.assert_allowed(allowed=config, session=session, by_config=True, by_policy=True)
self.assertEqual(scm.use_common, True)
self.assertEqual(scm.source_cmd, ['make', 'sources'])
url = "git://mixed/baz/koji.git#1234"
scm = SCM(url)
# match scmhost mixed && match scmrepository /baz/* :: allow fedpkg sources
scm.assert_allowed(allowed=config, session=session, by_config=True, by_policy=True)
self.assertEqual(scm.use_common, False)
self.assertEqual(scm.source_cmd, ['fedpkg', 'sources'])
url = "git://mixed/foobar/koji.git#1234"
scm = SCM(url)
# match scmhost mixed && match scmrepository /foobar/* :: allow use_common fedpkg sources
scm.assert_allowed(allowed=config, session=session, by_config=True, by_policy=True)
self.assertEqual(scm.use_common, True)
self.assertEqual(scm.source_cmd, ['fedpkg', 'sources'])
url = "git://mixed/foobaz/koji.git#1234"
scm = SCM(url)
# match scmhost mixed && match scmrepository /foobaz/* :: allow use_common none
scm.assert_allowed(allowed=config, session=session, by_config=True, by_policy=True)
self.assertEqual(scm.use_common, True)
self.assertIsNone(scm.source_cmd)
class TestSCMCheckouts(unittest.TestCase):

View file

@ -138,6 +138,8 @@ def get_options():
'offline_retry': True,
'offline_retry_interval': 120,
'allowed_scms': '',
'allowed_scms_by_config': True,
'allowed_scms_by_policy': False,
'cert': None,
'serverca': None}
if config.has_section('kojivmd'):
@ -149,7 +151,8 @@ def get_options():
defaults[name] = int(value)
except ValueError:
quit("value for %s option must be a valid integer" % name)
elif name in ['offline_retry', 'no_ssl_verify']:
elif name in ['offline_retry', 'no_ssl_verify', 'allowed_scms_by_config',
'allowed_scms_by_policy']:
defaults[name] = config.getboolean('kojivmd', name)
elif name in ['plugin', 'plugins']:
defaults['plugin'] = value.split()
@ -325,8 +328,16 @@ class WinBuildTask(MultiPlatformTask):
# verify the urls before passing them to the VM
for url in [source_url] + koji.util.to_list(subopts.values()):
scm = SCM(url)
scm.assert_allowed(self.options.allowed_scms)
scm.assert_allowed(allowed=self.options.allowed_scms,
session=self.session,
by_config=self.options.allowed_scms_use_config,
by_policy=self.options.allowed_scms_use_policy,
opts={
'user_id': self.taskinfo['owner'],
'channel': self.session.getChannel(self.taskinfo['channel_id'],
strict=True)['name'],
'scratch': opts.get('scratch')
})
task_info = self.session.getTaskInfo(self.id)
target_info = self.session.getBuildTarget(target)
if not target_info:

View file

@ -27,6 +27,14 @@ server=http://hub.example.com/kojihub
; dir, and will raise an exception if it cannot.
allowed_scms=scm.example.com:/cvs/example git.example.org:/example svn.example.org:/users/*:no
; If use the option allowed_scms above for allowing / denying SCM, default: true
; allowed_scms_use_config = true
; If use hub policy: build_from_scm for allowing / denying SCM, default: false
; notice that if both options are enabled, both assertions will be applied, and user_common
; will be overridden by the policy's result.
; allowed_scms_use_policy = false
; The mail host to use for sending email notifications
smtphost=example.com