Add Authorization Rules configuration

Allow an operator to define authorization rules.
Allow an operator to add authorization rules to a tenant.
Add a rule parser and a rule registry.

The authZ engine is not plugged in yet.

Change-Id: I3a86c6c7d62ad2bce68a98dbd2fff18549b94fb9
This commit is contained in:
mhuin 2019-02-27 23:03:50 +01:00 committed by Matthieu Huin
parent 9d86c00111
commit 7a622a5823
25 changed files with 597 additions and 19 deletions

View File

@ -39,6 +39,7 @@ listen_address=127.0.0.1
port=9000
static_cache_expiry=0
status_url=https://zuul.example.com/status
authorizations_config=/etc/zuul/authorizations.yaml
[webclient]
url=https://zuul.example.com

View File

@ -28,3 +28,4 @@ paho-mqtt
cherrypy
ws4py
routes
jsonpath-rw

View File

@ -2529,8 +2529,9 @@ class ZuulWebFixture(fixtures.Fixture):
include_drivers=[zuul.driver.sql.SQLDriver,
zuul.driver.github.GithubDriver,
zuul.driver.pagure.PagureDriver])
self.auths = zuul.lib.auth.AuthenticatorRegistry()
self.auths.configure(config)
self.authenticators = zuul.lib.auth.AuthenticatorRegistry()
self.authenticators.configure(config)
self.authorizations = zuul.lib.auth.AuthorizationRegistry()
if info is None:
self.info = zuul.model.WebInfo()
else:
@ -2547,7 +2548,8 @@ class ZuulWebFixture(fixtures.Fixture):
connections=self.connections,
zk_hosts=self.zk_hosts,
command_socket=os.path.join(self.test_root, 'web.socket'),
auths=self.auths)
authenticators=self.authenticators,
authorizations=self.authorizations)
self.web.start()
self.addCleanup(self.stop)
@ -3194,6 +3196,8 @@ class ZuulTestCase(BaseTestCase):
with open(os.path.join(FIXTURE_DIR, path)) as f:
tenant_config = yaml.safe_load(f.read())
for tenant in tenant_config:
if 'tenant' not in tenant.keys():
continue
sources = tenant['tenant']['source']
for source, conf in sources.items():
for project in conf.get('config-projects', []):

View File

@ -0,0 +1,17 @@
- rule:
name: venkman_rule
conditions:
- zuul_uid: venkman
- rule:
name: columbia_rule
conditions:
- sub: stantz
iss: columbia.edu
- sub: zeddemore
iss: columbia.edu
- rule:
name: gb_rule
conditions:
- groups: ghostbusters
claim_types:
- groups: list

View File

@ -0,0 +1,2 @@
- hosts: all
tasks: []

View File

@ -0,0 +1,2 @@
- hosts: all
tasks: []

View File

@ -0,0 +1,2 @@
- hosts: all
tasks: []

View File

@ -0,0 +1,2 @@
- hosts: all
tasks: []

View File

@ -0,0 +1,2 @@
- hosts: all
tasks: []

View File

@ -0,0 +1,2 @@
- hosts: all
tasks: []

View File

@ -0,0 +1,2 @@
- hosts: all
tasks: []

View File

@ -0,0 +1,2 @@
- hosts: all
tasks: []

View File

@ -0,0 +1,197 @@
- pipeline:
name: check
manager: independent
trigger:
gerrit:
- event: patchset-created
success:
gerrit:
Verified: 1
failure:
gerrit:
Verified: -1
- pipeline:
name: gate
manager: dependent
success-message: Build succeeded (gate).
trigger:
gerrit:
- event: comment-added
approval:
- Approved: 1
success:
gerrit:
Verified: 2
submit: true
failure:
gerrit:
Verified: -2
start:
gerrit:
Verified: 0
precedence: high
- pipeline:
name: post
manager: independent
trigger:
gerrit:
- event: ref-updated
ref: ^(?!refs/).*$
precedence: low
- job:
name: base
parent: null
- job:
name: project-merge
hold-following-changes: true
nodeset:
nodes:
- name: controller
label: label1
run: playbooks/project-merge.yaml
- job:
name: project-test1
attempts: 4
nodeset:
nodes:
- name: controller
label: label1
run: playbooks/project-test1.yaml
- job:
name: project-test1
branches: stable
nodeset:
nodes:
- name: controller
label: label2
run: playbooks/project-test1.yaml
- job:
name: project-post
nodeset:
nodes:
- name: static
label: ubuntu-xenial
run: playbooks/project-post.yaml
- job:
name: project-test2
nodeset:
nodes:
- name: controller
label: label1
run: playbooks/project-test2.yaml
- job:
name: project1-project2-integration
nodeset:
nodes:
- name: controller
label: label1
run: playbooks/project1-project2-integration.yaml
- job:
name: project-testfile
files:
- .*-requires
run: playbooks/project-testfile.yaml
- project:
name: org/project
check:
jobs:
- project-merge
- project-test1:
dependencies: project-merge
- project-test2:
dependencies: project-merge
gate:
jobs:
- project-merge
- project-test1:
dependencies: project-merge
- project-test2:
dependencies: project-merge
- project-testfile:
dependencies: project-merge
post:
jobs:
- project-post
- project:
name: org/project1
check:
jobs:
- project-merge
- project-test1:
dependencies: project-merge
- project-test2:
dependencies: project-merge
- project1-project2-integration:
dependencies: project-merge
gate:
queue: integrated
jobs:
- project-merge
- project-test1:
dependencies: project-merge
- project-test2:
dependencies: project-merge
- project1-project2-integration:
dependencies: project-merge
- project:
name: org/project2
check:
jobs:
- project-merge
- project-test1:
dependencies: project-merge
- project-test2:
dependencies: project-merge
- project1-project2-integration:
dependencies: project-merge
gate:
queue: integrated
jobs:
- project-merge
- project-test1:
dependencies: project-merge
- project-test2:
dependencies: project-merge
- project1-project2-integration:
dependencies: project-merge
- project:
name: common-config
check:
jobs:
- project-merge
- project-test1:
dependencies: project-merge
- project-test2:
dependencies: project-merge
- project1-project2-integration:
dependencies: project-merge
gate:
queue: integrated
jobs:
- project-merge
- project-test1:
dependencies: project-merge
- project-test2:
dependencies: project-merge
- project1-project2-integration:
dependencies: project-merge
- job:
name: test-job
run: playbooks/project-merge.yaml
required-projects:
- org/project

View File

@ -0,0 +1 @@
test

View File

@ -0,0 +1 @@
test

View File

@ -0,0 +1 @@
test

View File

@ -0,0 +1,12 @@
- tenant:
name: tenant-one
admin_rules:
- venkman_rule
source:
gerrit:
config-projects:
- common-config
untrusted-projects:
- org/project
- org/project1
- org/project2

View File

@ -0,0 +1,22 @@
- admin-rule:
name: auth-rule-one
conditions:
- sub: venkman
- sub: zeddemore
- admin-rule:
name: auth-rule-two
conditions:
- sub: gozer
iss: another_dimension
- tenant:
name: tenant-one
admin-rules:
- auth-rule-one
- auth-rule-two
source:
gerrit:
config-projects:
- common-config
untrusted-projects:
- org/project1
- org/project2

View File

@ -16,6 +16,8 @@ import fixtures
import logging
import textwrap
from zuul.configloader import AuthorizationRuleParser
from tests.base import ZuulTestCase
@ -418,6 +420,108 @@ class TestConfigConflict(ZuulTestCase):
jobs)
class TestAuthorizationRuleParser(ZuulTestCase):
tenant_config_file = 'config/tenant-parser/authorizations.yaml'
def test_rules_are_loaded(self):
rules = self.sched.abide.admin_rules
self.assertTrue('auth-rule-one' in rules, self.sched.abide)
self.assertTrue('auth-rule-two' in rules, self.sched.abide)
def test_parse_simplest_rule_from_yaml(self):
rule_d = {'name': 'my-rule',
'conditions': {'sub': 'user1'}
}
rule = AuthorizationRuleParser().fromYaml(rule_d)
self.assertEqual('my-rule', rule.name)
claims = {'iss': 'my-idp',
'sub': 'user1',
'groups': ['admin', 'ghostbusters']}
self.assertTrue(rule(claims))
claims = {'iss': 'my-2nd-idp',
'sub': 'user2',
'groups': ['admin', 'ghostbusters']}
self.assertFalse(rule(claims))
def test_parse_AND_rule_from_yaml(self):
rule_d = {'name': 'my-rule',
'conditions': {'sub': 'user1',
'iss': 'my-idp'}
}
rule = AuthorizationRuleParser().fromYaml(rule_d)
self.assertEqual('my-rule', rule.name)
claims = {'iss': 'my-idp',
'sub': 'user1',
'groups': ['admin', 'ghostbusters']}
self.assertTrue(rule(claims))
claims = {'iss': 'my-2nd-idp',
'sub': 'user1',
'groups': ['admin', 'ghostbusters']}
self.assertFalse(rule(claims))
def test_parse_OR_rule_from_yaml(self):
rule_d = {'name': 'my-rule',
'conditions': [{'sub': 'user1',
'iss': 'my-idp'},
{'sub': 'user2',
'iss': 'my-2nd-idp'}
]
}
rule = AuthorizationRuleParser().fromYaml(rule_d)
self.assertEqual('my-rule', rule.name)
claims = {'iss': 'my-idp',
'sub': 'user1',
'groups': ['admin', 'ghostbusters']}
self.assertTrue(rule(claims))
claims = {'iss': 'my-2nd-idp',
'sub': 'user1',
'groups': ['admin', 'ghostbusters']}
self.assertFalse(rule(claims))
claims = {'iss': 'my-2nd-idp',
'sub': 'user2',
'groups': ['admin', 'ghostbusters']}
self.assertTrue(rule(claims))
def test_parse_rule_with_list_claim_from_yaml(self):
rule_d = {'name': 'my-rule',
'conditions': [{'groups': 'ghostbusters',
'iss': 'my-idp'},
{'sub': 'user2',
'iss': 'my-2nd-idp'}
],
}
rule = AuthorizationRuleParser().fromYaml(rule_d)
self.assertEqual('my-rule', rule.name)
claims = {'iss': 'my-idp',
'sub': 'user1',
'groups': ['admin', 'ghostbusters']}
self.assertTrue(rule(claims))
claims = {'iss': 'my-idp',
'sub': 'user1',
'groups': ['admin', 'ghostbeaters']}
self.assertFalse(rule(claims))
claims = {'iss': 'my-2nd-idp',
'sub': 'user2',
'groups': ['admin', 'ghostbusters']}
self.assertTrue(rule(claims))
def test_check_complex_rule_from_yaml(self):
rule_d = {'name': 'my-rule',
'conditions': [{'hello.this.is': 'a complex value'},
],
}
rule = AuthorizationRuleParser().fromYaml(rule_d)
self.assertEqual('my-rule', rule.name)
claims = {'iss': 'my-idp',
'hello': {
'this': {
'is': 'a complex value'
}
}
}
self.assertTrue(rule(claims))
class TestTenantExtra(TenantParserTestCase):
tenant_config_file = 'config/tenant-parser/extra.yaml'

View File

@ -73,7 +73,8 @@ class WebServer(zuul.cmd.ZuulDaemonApp):
'/var/lib/zuul/web.socket')
params['connections'] = self.connections
params['auths'] = self.auths
params['authenticators'] = self.authenticators
params['authorizations'] = self.authorizations
# Validate config here before we spin up the ZuulWeb object
for conn_name, connection in self.connections.connections.items():
try:
@ -108,9 +109,12 @@ class WebServer(zuul.cmd.ZuulDaemonApp):
self.web.stop()
self.log.info("Zuul Web Server stopped")
def configure_auth(self):
self.auths = zuul.lib.auth.AuthenticatorRegistry()
self.auths.configure(self.config)
def configure_authenticators(self):
self.authenticators = zuul.lib.auth.AuthenticatorRegistry()
self.authenticators.configure(self.config)
def configure_authorizations(self):
self.authorizations = zuul.lib.auth.AuthorizationRegistry()
def run(self):
if self.args.command in zuul.web.COMMANDS:
@ -125,7 +129,8 @@ class WebServer(zuul.cmd.ZuulDaemonApp):
include_drivers=[zuul.driver.sql.SQLDriver,
zuul.driver.github.GithubDriver,
zuul.driver.pagure.PagureDriver])
self.configure_auth()
self.configure_authenticators()
self.configure_authorizations()
self._run()
except Exception:
self.log.exception("Exception from WebServer:")

View File

@ -1317,6 +1317,38 @@ class SemaphoreParser(object):
return semaphore
class AuthorizationRuleParser(object):
def __init__(self):
self.log = logging.getLogger("zuul.AuthorizationRuleParser")
self.schema = self.getSchema()
def getSchema(self):
authRule = {vs.Required('name'): str,
vs.Required('conditions'): to_list(dict)
}
return vs.Schema(authRule)
def fromYaml(self, conf):
self.schema(conf)
a = model.AuthZRuleTree(conf['name'])
def parse_tree(node):
if isinstance(node, list):
return model.OrRule(parse_tree(x) for x in node)
elif isinstance(node, dict):
subrules = []
for claim, value in node.items():
subrules.append(model.ClaimRule(claim, value))
return model.AndRule(subrules)
else:
raise Exception('Invalid claim declaration %r' % node)
a.ruletree = parse_tree(conf['conditions'])
return a
class ParseContext(object):
"""Hold information about a particular run of the parser"""
@ -1419,6 +1451,7 @@ class TenantParser(object):
'allowed-labels': to_list(str),
'default-parent': str,
'default-ansible-version': vs.Any(str, float),
'admin-rules': to_list(str),
}
return vs.Schema(tenant)
@ -2064,6 +2097,7 @@ class ConfigLoader(object):
self.keystorage = None
self.tenant_parser = TenantParser(connections, scheduler,
merger, self.keystorage)
self.admin_rule_parser = AuthorizationRuleParser()
def expandConfigPath(self, config_path):
if config_path:
@ -2105,6 +2139,9 @@ class ConfigLoader(object):
def loadConfig(self, unparsed_abide, ansible_manager):
abide = model.Abide()
for conf_admin_rule in unparsed_abide.admin_rules:
admin_rule = self.admin_rule_parser.fromYaml(conf_admin_rule)
abide.admin_rules[admin_rule.name] = admin_rule
for conf_tenant in unparsed_abide.tenants:
# When performing a full reload, do not use cached data.
tenant = self.tenant_parser.fromYaml(
@ -2123,6 +2160,7 @@ class ConfigLoader(object):
def reloadTenant(self, abide, tenant, ansible_manager):
new_abide = model.Abide()
new_abide.tenants = abide.tenants.copy()
new_abide.admin_rules = abide.admin_rules.copy()
new_abide.unparsed_project_branch_cache = \
abide.unparsed_project_branch_cache

View File

@ -19,6 +19,43 @@ import jwt
from zuul import exceptions
import zuul.driver.auth.jwt as auth_jwt
from zuul.configloader import AuthorizationRuleParser
"""AuthN/AuthZ related library, used by zuul-web."""
class AuthorizationRegistry(object):
"""Registry of authorization rules.
reconfigure(rules) takes a JSON list to create the ruleset; typically
provided by the scheduler."""
log = logging.getLogger("Zuul.AuthorizationRegistry")
def __init__(self):
self.ruleset = {}
def reconfigure(self, rules):
if not isinstance(rules, list):
raise Exception('Authorizations file must be a list of rules')
new_ruleset = {}
ruleparser = AuthorizationRuleParser()
for rule in rules:
if not isinstance(rule, dict):
raise Exception('Invalid rule format for rule "%r"' % rule)
if len(rule.keys()) > 1:
raise Exception('Rules must consist of "rule" element only')
if 'rule' in rule:
rule_tree = ruleparser.fromYaml(rule['rule'])
if rule_tree.name in new_ruleset:
raise Exception(
'Rule "%s" is defined at least twice' % rule_tree.name)
else:
new_ruleset[rule_tree.name] = rule_tree
else:
raise Exception('Unknown element "%s"' % rule.keys()[0])
self.ruleset = new_ruleset
class AuthenticatorRegistry(object):

View File

@ -27,6 +27,8 @@ import textwrap
import types
import itertools
import jsonpath_rw
from zuul import change_matcher
from zuul.lib.config import get_default
from zuul.lib.artifacts import get_artifacts_from_result_data
@ -3443,16 +3445,18 @@ class UnparsedAbideConfig(object):
"""A collection of yaml lists that has not yet been parsed into objects.
An Abide is a collection of tenants.
An Abide is a collection of tenants and access rules to those tenants.
"""
def __init__(self):
self.tenants = []
self.admin_rules = []
self.known_tenants = set()
def extend(self, conf):
if isinstance(conf, UnparsedAbideConfig):
self.tenants.extend(conf.tenants)
self.admin_rules.extend(conf.admin_rules)
return
if not isinstance(conf, list):
@ -3468,6 +3472,8 @@ class UnparsedAbideConfig(object):
self.tenants.append(value)
if 'name' in value:
self.known_tenants.add(value['name'])
elif key == 'admin-rule':
self.admin_rules.append(value)
else:
raise ConfigItemUnknownError()
@ -4237,6 +4243,8 @@ class Tenant(object):
# The per tenant default ansible version
self.default_ansible_version = None
self.authorization_rules = []
def _addProject(self, tpc):
"""Add a project to the project index
@ -4429,6 +4437,7 @@ class UnparsedBranchCache(object):
class Abide(object):
def __init__(self):
self.admin_rules = OrderedDict()
self.tenants = OrderedDict()
# project -> branch -> UnparsedBranchCache
self.unparsed_project_branch_cache = {}
@ -4619,3 +4628,110 @@ class WebInfo(object):
if self.tenant:
d['tenant'] = self.tenant
return d
# AuthZ models
class AuthZRule(object):
"""The base class for authorization rules"""
def __ne__(self, other):
return not self.__eq__(other)
class ClaimRule(AuthZRule):
"""This rule checks the value of a claim.
The check tries to be smart by assessing the type of the tested value."""
def __init__(self, claim=None, value=None):
super(ClaimRule, self).__init__()
self.claim = claim or 'sub'
self.value = value
def __call__(self, claims):
matches = [match.value
for match in jsonpath_rw.parse(self.claim).find(claims)]
if len(matches) == 1:
match = matches[0]
if isinstance(match, list):
return self.value in match
elif isinstance(match, str):
return self.value == match
else:
# unsupported type - don't raise, but this should be notified
return False
else:
# TODO we should differentiate no match and 2+ matches
return False
def __eq__(self, other):
if not isinstance(other, ClaimRule):
return False
return (self.claim == other.claim and self.value == other.value)
def __repr__(self):
return '<ClaimRule "%s":"%s">' % (self.claim, self.value)
def __hash__(self):
return hash(repr(self))
class OrRule(AuthZRule):
def __init__(self, subrules):
super(OrRule, self).__init__()
self.rules = set(subrules)
def __call__(self, claims):
return any(rule(claims) for rule in self.rules)
def __eq__(self, other):
if not isinstance(other, OrRule):
return False
return self.rules == other.rules
def __repr__(self):
return '<OrRule %s>' % (' || '.join(repr(r) for r in self.rules))
def __hash__(self):
return hash(repr(self))
class AndRule(AuthZRule):
def __init__(self, subrules):
super(AndRule, self).__init__()
self.rules = set(subrules)
def __call__(self, claims):
return all(rule(claims) for rule in self.rules)
def __eq__(self, other):
if not isinstance(other, AndRule):
return False
return self.rules == other.rules
def __repr__(self):
return '<AndRule %s>' % (' && '.join(repr(r) for r in self.rules))
def __hash__(self):
return hash(repr(self))
class AuthZRuleTree(object):
def __init__(self, name):
self.name = name
# initialize actions as unauthorized
self.ruletree = None
def __call__(self, claims):
return self.ruletree(claims)
def __eq__(self, other):
if not isinstance(other, AuthZRuleTree):
return False
return (self.name == other.name and
self.ruletree == other.ruletree)
def __repr__(self):
return '<AuthZRuleTree [ %s ]>' % self.ruletree

View File

@ -240,7 +240,7 @@ class ZuulWebAPI(object):
return None
error_header = '''Bearer realm="%s"
error="%s"
error_description="%s"''' % (self.zuulweb.auths.default_realm,
error_description="%s"''' % (self.zuulweb.authenticators.default_realm,
e,
e_desc)
cherrypy.response.status = status
@ -259,7 +259,7 @@ class ZuulWebAPI(object):
# AuthN/AuthZ
rawToken = cherrypy.request.headers['Authorization'][len('Bearer '):]
try:
uid, authz = self.zuulweb.auths.authenticate(rawToken)
uid, authz = self.zuulweb.authenticators.authenticate(rawToken)
except exceptions.AuthTokenException as e:
for header, contents in e.getAdditionalHeaders().items():
cherrypy.response.headers[header] = contents
@ -303,7 +303,7 @@ class ZuulWebAPI(object):
# AuthN/AuthZ
rawToken = cherrypy.request.headers['Authorization'][len('Bearer '):]
try:
uid, authz = self.zuulweb.auths.authenticate(rawToken)
uid, authz = self.zuulweb.authenticators.authenticate(rawToken)
except exceptions.AuthTokenException as e:
for header, contents in e.getAdditionalHeaders().items():
cherrypy.response.headers[header] = contents
@ -380,7 +380,7 @@ class ZuulWebAPI(object):
rawToken = \
cherrypy.request.headers['Authorization'][len('Bearer '):]
try:
uid, authz = self.zuulweb.auths.authenticate(rawToken)
uid, authz = self.zuulweb.authenticators.authenticate(rawToken)
except exceptions.AuthTokenException as e:
for header, contents in e.getAdditionalHeaders().items():
cherrypy.response.headers[header] = contents
@ -967,8 +967,10 @@ class ZuulWeb(object):
info=None,
static_path=None,
zk_hosts=None,
authenticators=None,
authorizations=None,
command_socket=None,
auths=None):
):
self.start_time = time.time()
self.listen_address = listen_address
self.listen_port = listen_port
@ -985,7 +987,8 @@ class ZuulWeb(object):
if zk_hosts:
self.zk.connect(hosts=zk_hosts, read_only=True)
self.connections = connections
self.auths = auths
self.authenticators = authenticators
self.authorizations = authorizations
self.stream_manager = StreamManager()
self.command_socket = commandsocket.CommandSocket(command_socket)
@ -1019,7 +1022,7 @@ class ZuulWeb(object):
route_map.connect('api', '/api/tenant/{tenant}/job/{job_name}',
controller=api, action='job')
# if no auth configured, deactivate admin routes
if self.auths.authenticators:
if self.authenticators.authenticators:
# route order is important, put project actions before the more
# generic tenant/{tenant}/project/{project} route
route_map.connect(
@ -1158,11 +1161,11 @@ class ZuulWeb(object):
if __name__ == "__main__":
logging.basicConfig(level=logging.DEBUG)
import zuul.lib.connections
import zuul.lib.authenticators
import zuul.lib.auth.authenticators
connections = zuul.lib.connections.ConnectionRegistry()
auths = zuul.lib.authenticators.AuthenticatorRegistry()
auths = zuul.lib.auth.authenticators.AuthenticatorRegistry()
z = ZuulWeb(listen_address="127.0.0.1", listen_port=9000,
gear_server="127.0.0.1", gear_port=4730,
connections=connections, auths=auths)
connections=connections, authenticators=auths)
z.start()
cherrypy.engine.block()