diff --git a/doc/source/admin/components.rst b/doc/source/admin/components.rst index 19e5680ef8..74ca471a12 100644 --- a/doc/source/admin/components.rst +++ b/doc/source/admin/components.rst @@ -830,6 +830,8 @@ sections of ``zuul.conf`` are used by the web server: The Cache-Control max-age response header value for static files served by the zuul-web. Set to 0 during development to disable Cache-Control. +.. _web-server-tenant-scoped-api: + Enabling tenant-scoped access to privileged actions ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -853,8 +855,9 @@ protected endpoints and configure JWT validation: .. attr:: allow_authz_override :default: false - Allow a JWT to override predefined access rules. Since predefined access - rules are not supported yet, this should be set to ``true``. + Allow a JWT to override predefined access rules. See the section on + :ref:`JWT contents ` for more details on how to grant access + to tenants with a JWT. .. attr:: realm diff --git a/doc/source/admin/tenant-scoped-rest-api.rst b/doc/source/admin/tenant-scoped-rest-api.rst index 786d920e10..4d411bf393 100644 --- a/doc/source/admin/tenant-scoped-rest-api.rst +++ b/doc/source/admin/tenant-scoped-rest-api.rst @@ -1,5 +1,7 @@ :title: Tenant Scoped REST API +.. _tenant-scoped-rest-api: + Tenant Scoped REST API ====================== @@ -36,8 +38,33 @@ and Tokens should be handed over with discernment. Configuration ------------- -See the Zuul Web Server component's section about enabling tenant-scoped access to -privileged actions. +To enable tenant-scoped access to privileged actions, see the Zuul Web Server +component's section. + +To set access rules for a tenant, see :ref:`the documentation about tenant +definition `. + +Most of the time, only one authenticator will be needed in Zuul's configuration; +namely the configuration matching a third party identity provider service like +dex, auth0, keycloak or others. It can be useful however to add another +authenticator similar to this one: + +.. code-block:: ini + + [auth zuul_operator] + driver=HS256 + allow_authz_override=true + realm=zuul.example.com + client_id=zuul.example.com + issuer_id=zuul_operator + secret=NoDanaOnlyZuul + +With such an authenticator, a Zuul operator can use Zuul's CLI to +issue Tokens overriding a tenant's access rules if need +be. A user can then use these Tokens with Zuul's CLI to perform protected actions +on a tenant temporarily, without having to modify a tenant's access rules. + +.. _jwt-format: JWT Format ---------- diff --git a/doc/source/admin/tenants.rst b/doc/source/admin/tenants.rst index fd8592e45a..0e619d1d37 100644 --- a/doc/source/admin/tenants.rst +++ b/doc/source/admin/tenants.rst @@ -15,11 +15,15 @@ rest (no pipelines, jobs, etc are shared between them). A project may appear in more than one tenant; this may be useful if you wish to use common job definitions across multiple tenants. +Actions normally available to the Zuul operator only can be performed by specific +users on Zuul's REST API, if admin rules are listed for the tenant. Admin rules +are also defined in the tenant configuration file. + The tenant configuration file is specified by the :attr:`scheduler.tenant_config` setting in ``zuul.conf``. It is a YAML file which, like other Zuul configuration files, is a list of -configuration objects, though only one type of object is supported: -``tenant``. +configuration objects, though only two types of objects are supported: +``tenant`` and ``admin-rule``. Alternatively the :attr:`scheduler.tenant_config_script` can be the path to an executable that will be executed and its stdout @@ -55,6 +59,9 @@ configuration. Some examples of tenant definitions are: - tenant: name: my-tenant + admin-rules: + - acl1 + - acl2 source: gerrit: config-projects: @@ -84,6 +91,17 @@ configuration. Some examples of tenant definitions are: characters (ASCII letters, numbers, hyphen and underscore) and you should avoid changing it unless necessary. + .. attr:: admin-rules + + A list of access rules for the tenant. These rules are checked to grant + privileged actions to users at the tenant level, through Zuul's REST API. + + At least one rule in the list must match for the user to be allowed the + privileged action. + + More information on tenant-scoped actions can be found in + :ref:`this section `. + .. attr:: source :required: @@ -288,3 +306,107 @@ configuration. Some examples of tenant definitions are: The list of labels regexp a tenant can use in job's nodeset. When set, this setting can be used to restrict what labels a tenant can use. Without this setting, the tenant can use any labels. + +.. _admin_rule_definition: + +Access Rule +----------- + +An access rule is a set of conditions the claims of a user's JWT must match +in order to be allowed to perform protected actions at a tenant's level. + +The protected actions available at tenant level are **autohold**, **enqueue** +or **dequeue**. + +.. note:: + + Rules can be overridden by the ``zuul.admin`` claim in a Token if if matches + an authenticator configuration where `allow_authz_override` is set to true. + See :ref:`Zuul web server's configuration ` for + more details. + +Below are some examples of how access rules can be defined: + +.. code-block:: yaml + + - admin-rule: + name: ghostbuster_or_gozerian + conditions: + - resources_access.account.roles: "ghostbuster" + iss: columbia_university + - resources_access.account.roles: "gozerian" + - admin-rule: + name: venkman_or_stantz + conditions: + - zuul_uid: venkman + - zuul_uid: stantz + + +.. attr:: admin-rule + + The following attributes are supported: + + .. attr:: name + :required: + + The name of the rule, so that it can be referenced in the ``admin-rules`` + attribute of a tenant's definition. It must be unique. + + .. attr:: conditions + :required: + + This is the list of conditions that define a rule. A JWT must match **at + least one** of the conditions for the rule to apply. A condition is a + dictionary where keys are claims. **All** the associated values must + match the claims in the user's Token. + + Zuul's authorization engine will adapt matching tests depending on the + nature of the claim in the Token, eg: + + * if the claim is a JSON list, check that the condition value is in the + claim + * if the claim is a string, check that the condition value is equal to + the claim's value + + In order to allow the parsing of claims with complex structures like + dictionaries, claim names can be written in the XPath format. + + The special ``zuul_uid`` claim refers to the ``uid_claim`` setting in an + authenticator's configuration. By default it refers to the ``sub`` claim + of a Token. For more details see the :ref:`configuration section + ` for Zuul web server. + + Under the above example, the following Token would match rules + ``ghostbuster_or_gozerian`` and ``venkman_or_stantz``: + + .. code-block:: javascript + + { + 'iss': 'columbia_university', + 'aud': 'my_zuul_deployment', + 'exp': 1234567890, + 'iat': 1234556780, + 'sub': 'venkman', + 'resources_access': { + 'account': { + 'roles': ['ghostbuster', 'played_by_bill_murray'] + } + }, + } + + And this Token would only match rule ``ghostbuster_or_gozerian``: + + .. code-block:: javascript + + { + 'iss': 'some_hellish_dimension', + 'aud': 'my_zuul_deployment', + 'exp': 1234567890, + 'sub': 'vinz_clortho', + 'iat': 1234556780, + 'resources_access': { + 'account': { + 'roles': ['gozerian', 'keymaster'] + } + }, + } diff --git a/etc/zuul.conf-sample b/etc/zuul.conf-sample index c054169629..c0916c6c43 100644 --- a/etc/zuul.conf-sample +++ b/etc/zuul.conf-sample @@ -39,7 +39,6 @@ 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 diff --git a/releasenotes/notes/admin_web_api-1331c81070a3e67f.yaml b/releasenotes/notes/admin_web_api-1331c81070a3e67f.yaml index 786d5de1e4..91631e097a 100644 --- a/releasenotes/notes/admin_web_api-1331c81070a3e67f.yaml +++ b/releasenotes/notes/admin_web_api-1331c81070a3e67f.yaml @@ -3,8 +3,9 @@ features: - | Allow users to perform tenant-scoped, privileged actions either through zuul-web's REST API or zuul's client, based on the JWT standard. The users - need a valid bearer token to perform such actions; the scope is set via a - token claim. + need a valid bearer token to perform such actions; the scope is set by matching + conditions on tokens' claims; these conditions can be defined in zuul's tenant + configuration file. Zuul supports token signing and validation using the HS256 or RS256 algorithms. External JWKS are also supported for token validation only. Current tenant-scoped actions are "autohold", "enqueue" and "dequeue". diff --git a/tests/base.py b/tests/base.py index a30af1b101..81d7b05f05 100644 --- a/tests/base.py +++ b/tests/base.py @@ -2531,7 +2531,6 @@ class ZuulWebFixture(fixtures.Fixture): zuul.driver.pagure.PagureDriver]) 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: @@ -2548,8 +2547,7 @@ class ZuulWebFixture(fixtures.Fixture): connections=self.connections, zk_hosts=self.zk_hosts, command_socket=os.path.join(self.test_root, 'web.socket'), - authenticators=self.authenticators, - authorizations=self.authorizations) + authenticators=self.authenticators) self.web.start() self.addCleanup(self.stop) diff --git a/tests/fixtures/config/authorization/rules/rules.yaml b/tests/fixtures/config/authorization/rules/rules.yaml deleted file mode 100644 index dea5c15816..0000000000 --- a/tests/fixtures/config/authorization/rules/rules.yaml +++ /dev/null @@ -1,17 +0,0 @@ -- 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 diff --git a/tests/fixtures/config/authorization/single-tenant/main.yaml b/tests/fixtures/config/authorization/single-tenant/main.yaml index 8baca519ce..cc5eb8af1c 100644 --- a/tests/fixtures/config/authorization/single-tenant/main.yaml +++ b/tests/fixtures/config/authorization/single-tenant/main.yaml @@ -1,7 +1,29 @@ +- admin-rule: + name: venkman_rule + conditions: + - zuul_uid: venkman +- admin-rule: + name: columbia_rule + conditions: + - sub: stantz + iss: columbia.edu + - sub: zeddemore + iss: columbia.edu +- admin-rule: + name: gb_rule + conditions: + - groups: ghostbusters +- admin-rule: + name: car_rule + conditions: + - car: ecto-1 - tenant: name: tenant-one - admin_rules: + admin-rules: - venkman_rule + - car_rule + - gb_rule + - columbia_rule source: gerrit: config-projects: diff --git a/tests/fixtures/zuul-admin-web-no-override.conf b/tests/fixtures/zuul-admin-web-no-override.conf new file mode 100644 index 0000000000..897f18557b --- /dev/null +++ b/tests/fixtures/zuul-admin-web-no-override.conf @@ -0,0 +1,31 @@ +[gearman] +server=127.0.0.1 + +[scheduler] +tenant_config=main.yaml +relative_priority=true + +[merger] +git_dir=/tmp/zuul-test/merger-git +git_user_email=zuul@example.com +git_user_name=zuul + +[executor] +git_dir=/tmp/zuul-test/executor-git + +[connection gerrit] +driver=gerrit +server=review.example.com +user=jenkins +sshkey=fake_id_rsa_path + +[web] +static_cache_expiry=1200 + +[auth zuul_operator] +driver=HS256 +allow_authz_override=false +realm=zuul.example.com +client_id=zuul.example.com +issuer_id=zuul_operator +secret=NoDanaOnlyZuul diff --git a/tests/unit/test_configloader.py b/tests/unit/test_configloader.py index 4729d86557..e2a8111331 100644 --- a/tests/unit/test_configloader.py +++ b/tests/unit/test_configloader.py @@ -427,6 +427,13 @@ class TestAuthorizationRuleParser(ZuulTestCase): 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) + claims_1 = {'sub': 'venkman'} + claims_2 = {'sub': 'gozer', + 'iss': 'another_dimension'} + self.assertTrue(rules['auth-rule-one'](claims_1)) + self.assertTrue(not rules['auth-rule-one'](claims_2)) + self.assertTrue(not rules['auth-rule-two'](claims_1)) + self.assertTrue(rules['auth-rule-two'](claims_2)) def test_parse_simplest_rule_from_yaml(self): rule_d = {'name': 'my-rule', diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py index 6bcf359327..e8e71ea925 100644 --- a/tests/unit/test_scheduler.py +++ b/tests/unit/test_scheduler.py @@ -107,6 +107,37 @@ class TestSchedulerZone(ZuulTestCase): 'label1') +class TestAuthorizeViaRPC(ZuulTestCase): + tenant_config_file = 'config/authorization/single-tenant/main.yaml' + + def test_authorize_via_rpc(self): + client = zuul.rpcclient.RPCClient('127.0.0.1', + self.gearman_server.port) + self.addCleanup(client.shutdown) + claims = {'__zuul_uid_claim': 'venkman'} + authorized = client.submitJob('zuul:authorize_user', + {'tenant': 'tenant-one', + 'claims': claims}).data[0] + self.assertTrue(json.loads(authorized)) + claims = {'sub': 'gozer'} + authorized = client.submitJob('zuul:authorize_user', + {'tenant': 'tenant-one', + 'claims': claims}).data[0] + self.assertTrue(not json.loads(authorized)) + claims = {'sub': 'stantz', + 'iss': 'columbia.edu'} + authorized = client.submitJob('zuul:authorize_user', + {'tenant': 'tenant-one', + 'claims': claims}).data[0] + self.assertTrue(json.loads(authorized)) + claims = {'sub': 'slimer', + 'groups': ['ghostbusters', 'ectoplasms']} + authorized = client.submitJob('zuul:authorize_user', + {'tenant': 'tenant-one', + 'claims': claims}).data[0] + self.assertTrue(json.loads(authorized)) + + class TestScheduler(ZuulTestCase): tenant_config_file = 'config/single-tenant/main.yaml' diff --git a/tests/unit/test_web.py b/tests/unit/test_web.py index faa8fa3436..1b0c4df32a 100644 --- a/tests/unit/test_web.py +++ b/tests/unit/test_web.py @@ -1413,3 +1413,129 @@ class TestTenantScopedWebApi(BaseTestWeb): self.executor_server.release() self.waitUntilSettled() self.assertEqual(self.countJobResults(self.history, 'ABORTED'), 1) + + +class TestTenantScopedWebApiWithAuthRules(BaseTestWeb): + config_file = 'zuul-admin-web-no-override.conf' + tenant_config_file = 'config/authorization/single-tenant/main.yaml' + + def test_override_not_allowed(self): + """Test that authz cannot be overriden if config does not allow it""" + args = {"reason": "some reason", + "count": 1, + 'job': 'project-test2', + 'change': None, + 'ref': None, + 'node_hold_expiration': None} + authz = {'iss': 'zuul_operator', + 'aud': 'zuul.example.com', + 'sub': 'testuser', + 'zuul': { + 'admin': ['tenant-one', ], + }, + 'exp': time.time() + 3600} + token = jwt.encode(authz, key='NoDanaOnlyZuul', + algorithm='HS256').decode('utf-8') + req = self.post_url( + 'api/tenant/tenant-one/project/org/project/autohold', + headers={'Authorization': 'Bearer %s' % token}, + json=args) + self.assertEqual(401, req.status_code, req.text) + + def test_tenant_level_rule(self): + """Test that authz rules defined at tenant level are checked""" + path = "api/tenant/%(tenant)s/project/%(project)s/enqueue" + + def _test_project_enqueue_with_authz(i, project, authz, expected): + f_ch = self.fake_gerrit.addFakeChange(project, 'master', + '%s %i' % (project, i)) + f_ch.addApproval('Code-Review', 2) + f_ch.addApproval('Approved', 1) + change = {'trigger': 'gerrit', + 'change': '%i,1' % i, + 'pipeline': 'gate', } + enqueue_args = {'tenant': 'tenant-one', + 'project': project, } + + token = jwt.encode(authz, key='NoDanaOnlyZuul', + algorithm='HS256').decode('utf-8') + req = self.post_url(path % enqueue_args, + headers={'Authorization': 'Bearer %s' % token}, + json=change) + self.assertEqual(expected, req.status_code, req.text) + self.waitUntilSettled() + + i = 0 + for p in ['org/project', 'org/project1', 'org/project2']: + i += 1 + # Authorized sub + authz = {'iss': 'zuul_operator', + 'aud': 'zuul.example.com', + 'sub': 'venkman', + 'exp': time.time() + 3600} + _test_project_enqueue_with_authz(i, p, authz, 200) + i += 1 + # Unauthorized sub + authz = {'iss': 'zuul_operator', + 'aud': 'zuul.example.com', + 'sub': 'vigo', + 'exp': time.time() + 3600} + _test_project_enqueue_with_authz(i, p, authz, 403) + i += 1 + # unauthorized issuer + authz = {'iss': 'columbia.edu', + 'aud': 'zuul.example.com', + 'sub': 'stantz', + 'exp': time.time() + 3600} + _test_project_enqueue_with_authz(i, p, authz, 401) + self.waitUntilSettled() + + def test_group_rule(self): + """Test a group rule""" + A = self.fake_gerrit.addFakeChange('org/project2', 'master', 'A') + A.addApproval('Code-Review', 2) + A.addApproval('Approved', 1) + + authz = {'iss': 'zuul_operator', + 'aud': 'zuul.example.com', + 'sub': 'melnitz', + 'groups': ['ghostbusters', 'secretary'], + 'exp': time.time() + 3600} + token = jwt.encode(authz, key='NoDanaOnlyZuul', + algorithm='HS256').decode('utf-8') + path = "api/tenant/%(tenant)s/project/%(project)s/enqueue" + enqueue_args = {'tenant': 'tenant-one', + 'project': 'org/project2', } + change = {'trigger': 'gerrit', + 'change': '1,1', + 'pipeline': 'gate', } + req = self.post_url(path % enqueue_args, + headers={'Authorization': 'Bearer %s' % token}, + json=change) + self.assertEqual(200, req.status_code, req.text) + self.waitUntilSettled() + + def test_arbitrary_claim_rule(self): + """Test a rule based on a specific claim""" + A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A') + A.addApproval('Code-Review', 2) + A.addApproval('Approved', 1) + + authz = {'iss': 'zuul_operator', + 'aud': 'zuul.example.com', + 'sub': 'zeddemore', + 'car': 'ecto-1', + 'exp': time.time() + 3600} + token = jwt.encode(authz, key='NoDanaOnlyZuul', + algorithm='HS256').decode('utf-8') + path = "api/tenant/%(tenant)s/project/%(project)s/enqueue" + enqueue_args = {'tenant': 'tenant-one', + 'project': 'org/project', } + change = {'trigger': 'gerrit', + 'change': '1,1', + 'pipeline': 'gate', } + req = self.post_url(path % enqueue_args, + headers={'Authorization': 'Bearer %s' % token}, + json=change) + self.assertEqual(200, req.status_code, req.text) + self.waitUntilSettled() diff --git a/zuul/cmd/web.py b/zuul/cmd/web.py index 1c88f56d49..e2ecc2bb1e 100755 --- a/zuul/cmd/web.py +++ b/zuul/cmd/web.py @@ -74,7 +74,6 @@ class WebServer(zuul.cmd.ZuulDaemonApp): params['connections'] = self.connections 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: @@ -113,9 +112,6 @@ class WebServer(zuul.cmd.ZuulDaemonApp): 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: self.send_command(self.args.command) @@ -130,7 +126,6 @@ class WebServer(zuul.cmd.ZuulDaemonApp): zuul.driver.github.GithubDriver, zuul.driver.pagure.PagureDriver]) self.configure_authenticators() - self.configure_authorizations() self._run() except Exception: self.log.exception("Exception from WebServer:") diff --git a/zuul/configloader.py b/zuul/configloader.py index 52c539053e..617897815c 100644 --- a/zuul/configloader.py +++ b/zuul/configloader.py @@ -1340,6 +1340,8 @@ class AuthorizationRuleParser(object): elif isinstance(node, dict): subrules = [] for claim, value in node.items(): + if claim == 'zuul_uid': + claim = '__zuul_uid_claim' subrules.append(model.ClaimRule(claim, value)) return model.AndRule(subrules) else: @@ -1465,6 +1467,8 @@ class TenantParser(object): if conf.get('exclude-unprotected-branches') is not None: tenant.exclude_unprotected_branches = \ conf['exclude-unprotected-branches'] + if conf.get('admin-rules') is not None: + tenant.authorization_rules = conf['admin-rules'] tenant.allowed_triggers = conf.get('allowed-triggers') tenant.allowed_reporters = conf.get('allowed-reporters') tenant.allowed_labels = conf.get('allowed-labels') diff --git a/zuul/driver/auth/jwt.py b/zuul/driver/auth/jwt.py index 40442d24e0..2579c3a33d 100644 --- a/zuul/driver/auth/jwt.py +++ b/zuul/driver/auth/jwt.py @@ -36,6 +36,11 @@ class JWTAuthenticator(AuthenticatorInterface): self.audience = conf.get('client_id') self.realm = conf.get('realm') self.allow_authz_override = conf.get('allow_authz_override', False) + if isinstance(self.allow_authz_override, str): + if self.allow_authz_override.lower() == 'true': + self.allow_authz_override = True + else: + self.allow_authz_override = False def _decode(self, rawToken): raise NotImplementedError @@ -90,8 +95,9 @@ class JWTAuthenticator(AuthenticatorInterface): def authenticate(self, rawToken): decoded = self.decodeToken(rawToken) - return (decoded[self.uid_claim], - decoded.get('zuul', {}).get('admin', [])) + # inject the special authenticator-specific uid + decoded['__zuul_uid_claim'] = decoded[self.uid_claim] + return decoded class HS256Authenticator(JWTAuthenticator): diff --git a/zuul/lib/auth.py b/zuul/lib/auth.py index 14f5e0c0ac..3e96fd140f 100644 --- a/zuul/lib/auth.py +++ b/zuul/lib/auth.py @@ -19,45 +19,11 @@ 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): """Registry of authenticators as they are declared in the configuration""" diff --git a/zuul/rpclistener.py b/zuul/rpclistener.py index 998abc0494..2e1441323c 100644 --- a/zuul/rpclistener.py +++ b/zuul/rpclistener.py @@ -54,6 +54,7 @@ class RPCListener(object): 'key_get', 'config_errors_list', 'connection_list', + 'authorize_user', ] for func in functions: f = getattr(self, 'handle_%s' % func) @@ -281,6 +282,29 @@ class RPCListener(object): job_log_stream_address['port'] = build.worker.log_port job.sendWorkComplete(json.dumps(job_log_stream_address)) + def handle_authorize_user(self, job): + args = json.loads(job.arguments) + tenant_name = args['tenant'] + claims = args['claims'] + tenant = self.sched.abide.tenants.get(tenant_name) + authorized = False + if tenant: + rules = tenant.authorization_rules + for rule in rules: + if rule not in self.sched.abide.admin_rules.keys(): + self.log.error('Undefined rule "%s"' % rule) + continue + debug_msg = 'Applying rule "%s" from tenant "%s" to claims %s' + self.log.debug( + debug_msg % (rule, tenant, json.dumps(claims))) + authorized = self.sched.abide.admin_rules[rule](claims) + if authorized: + debug_msg = '%s authorized on tenant "%s" by rule "%s"' + self.log.debug( + debug_msg % (json.dumps(claims), tenant, rule)) + break + job.sendWorkComplete(json.dumps(authorized)) + def handle_tenant_list(self, job): output = [] for tenant_name, tenant in self.sched.abide.tenants.items(): diff --git a/zuul/web/__init__.py b/zuul/web/__init__.py index 14810414ce..68767d34b8 100755 --- a/zuul/web/__init__.py +++ b/zuul/web/__init__.py @@ -44,16 +44,6 @@ cherrypy.tools.websocket = WebSocketTool() COMMANDS = ['stop', 'repl', 'norepl'] -def is_authorized(uid, tenant, authN=None): - """Simple authorization checker. For now, relies on the passed authN - dictionary to figure out whether 'uid' is allowed 'action' on - 'tenant/project'. - This is just a stub that will be expanded in subsequent patches.""" - if authN is None: - authN = [] - return (tenant in authN) - - class SaveParamsTool(cherrypy.Tool): """ Save the URL parameters to allow them to take precedence over query @@ -259,19 +249,17 @@ class ZuulWebAPI(object): # AuthN/AuthZ rawToken = cherrypy.request.headers['Authorization'][len('Bearer '):] try: - uid, authz = self.zuulweb.authenticators.authenticate(rawToken) + claims = self.zuulweb.authenticators.authenticate(rawToken) except exceptions.AuthTokenException as e: for header, contents in e.getAdditionalHeaders().items(): cherrypy.response.headers[header] = contents cherrypy.response.status = e.HTTPError return '

%s

' % e.error_description - # TODO plug an actual authorization mechanism, for now rely on token - # content - if not is_authorized(uid, tenant, authz): - raise cherrypy.HTTPError(403) + self.is_authorized(claims, tenant) + msg = 'User "%s" requesting "%s" on %s/%s' self.log.info( - 'User "%s" requesting "%s" on %s/%s' % (uid, 'dequeue', - tenant, project)) + msg % (claims['__zuul_uid_claim'], 'dequeue', + tenant, project)) body = cherrypy.request.json if 'pipeline' in body and ( @@ -303,19 +291,17 @@ class ZuulWebAPI(object): # AuthN/AuthZ rawToken = cherrypy.request.headers['Authorization'][len('Bearer '):] try: - uid, authz = self.zuulweb.authenticators.authenticate(rawToken) + claims = self.zuulweb.authenticators.authenticate(rawToken) except exceptions.AuthTokenException as e: for header, contents in e.getAdditionalHeaders().items(): cherrypy.response.headers[header] = contents cherrypy.response.status = e.HTTPError return '

%s

' % e.error_description - # TODO plug an actual authorization mechanism, for now rely on token - # content - if not is_authorized(uid, tenant, authz): - raise cherrypy.HTTPError(403) + self.is_authorized(claims, tenant) + msg = 'User "%s" requesting "%s" on %s/%s' self.log.info( - 'User "%s" requesting "%s" on %s/%s' % (uid, 'enqueue', - tenant, project)) + msg % (claims['__zuul_uid_claim'], 'enqueue', + tenant, project)) body = cherrypy.request.json if all(p in body for p in ['trigger', 'change', 'pipeline']): @@ -380,19 +366,17 @@ class ZuulWebAPI(object): rawToken = \ cherrypy.request.headers['Authorization'][len('Bearer '):] try: - uid, authz = self.zuulweb.authenticators.authenticate(rawToken) + claims = self.zuulweb.authenticators.authenticate(rawToken) except exceptions.AuthTokenException as e: for header, contents in e.getAdditionalHeaders().items(): cherrypy.response.headers[header] = contents cherrypy.response.status = e.HTTPError return '

%s

' % e.error_description - # TODO plug an actual authorization mechanism, for now rely on - # token content - if not is_authorized(uid, tenant, authz): - raise cherrypy.HTTPError(403) + self.is_authorized(claims, tenant) + msg = 'User "%s" requesting "%s" on %s/%s' self.log.info( - 'User "%s" requesting "%s" on %s/%s' % (uid, 'autohold', - tenant, project)) + msg % (claims['__zuul_uid_claim'], 'autohold', + tenant, project)) length = int(cherrypy.request.headers['Content-Length']) body = cherrypy.request.body.read(length) @@ -502,6 +486,21 @@ class ZuulWebAPI(object): resp.last_modified = self.zuulweb.start_time return ret + def is_authorized(self, claims, tenant): + # First, check for zuul.admin override + override = claims.get('zuul', {}).get('admin', []) + if (override == '*' or + (isinstance(override, list) and tenant in override)): + return True + # Next, get the rules for tenant + data = {'tenant': tenant, 'claims': claims} + # TODO: it is probably worth caching + job = self.rpc.submitJob('zuul:authorize_user', data) + user_authz = json.loads(job.data[0]) + if not user_authz: + raise cherrypy.HTTPError(403) + return user_authz + @cherrypy.expose @cherrypy.tools.json_out(content_type='application/json; charset=utf-8') def tenants(self): @@ -968,7 +967,6 @@ class ZuulWeb(object): static_path=None, zk_hosts=None, authenticators=None, - authorizations=None, command_socket=None, ): self.start_time = time.time() @@ -988,7 +986,6 @@ class ZuulWeb(object): self.zk.connect(hosts=zk_hosts, read_only=True) self.connections = connections self.authenticators = authenticators - self.authorizations = authorizations self.stream_manager = StreamManager() self.command_socket = commandsocket.CommandSocket(command_socket)