Web: plug the authorization engine
Add an "authorize_user" RPC call allowing to test a set of claims against the rules of a given tenant. Make zuul-web use this call to authorize access to tenant-scoped privileged actions. Change-Id: I50575f25b6db06f56b231bb47f8ad675febb9d82
This commit is contained in:
parent
7a622a5823
commit
19474fb62f
|
@ -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
|
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.
|
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
|
Enabling tenant-scoped access to privileged actions
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
@ -853,8 +855,9 @@ protected endpoints and configure JWT validation:
|
||||||
.. attr:: allow_authz_override
|
.. attr:: allow_authz_override
|
||||||
:default: false
|
:default: false
|
||||||
|
|
||||||
Allow a JWT to override predefined access rules. Since predefined access
|
Allow a JWT to override predefined access rules. See the section on
|
||||||
rules are not supported yet, this should be set to ``true``.
|
:ref:`JWT contents <jwt-format>` for more details on how to grant access
|
||||||
|
to tenants with a JWT.
|
||||||
|
|
||||||
.. attr:: realm
|
.. attr:: realm
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
:title: Tenant Scoped REST API
|
:title: Tenant Scoped REST API
|
||||||
|
|
||||||
|
.. _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
|
Configuration
|
||||||
-------------
|
-------------
|
||||||
|
|
||||||
See the Zuul Web Server component's section about enabling tenant-scoped access to
|
To enable tenant-scoped access to privileged actions, see the Zuul Web Server
|
||||||
privileged actions.
|
component's section.
|
||||||
|
|
||||||
|
To set access rules for a tenant, see :ref:`the documentation about tenant
|
||||||
|
definition <admin_rule_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
|
JWT Format
|
||||||
----------
|
----------
|
||||||
|
|
|
@ -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
|
A project may appear in more than one tenant; this may be useful if
|
||||||
you wish to use common job definitions across multiple tenants.
|
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
|
The tenant configuration file is specified by the
|
||||||
:attr:`scheduler.tenant_config` setting in ``zuul.conf``. It is a
|
:attr:`scheduler.tenant_config` setting in ``zuul.conf``. It is a
|
||||||
YAML file which, like other Zuul configuration files, is a list of
|
YAML file which, like other Zuul configuration files, is a list of
|
||||||
configuration objects, though only one type of object is supported:
|
configuration objects, though only two types of objects are supported:
|
||||||
``tenant``.
|
``tenant`` and ``admin-rule``.
|
||||||
|
|
||||||
Alternatively the :attr:`scheduler.tenant_config_script`
|
Alternatively the :attr:`scheduler.tenant_config_script`
|
||||||
can be the path to an executable that will be executed and its stdout
|
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:
|
- tenant:
|
||||||
name: my-tenant
|
name: my-tenant
|
||||||
|
admin-rules:
|
||||||
|
- acl1
|
||||||
|
- acl2
|
||||||
source:
|
source:
|
||||||
gerrit:
|
gerrit:
|
||||||
config-projects:
|
config-projects:
|
||||||
|
@ -84,6 +91,17 @@ configuration. Some examples of tenant definitions are:
|
||||||
characters (ASCII letters, numbers, hyphen and underscore) and
|
characters (ASCII letters, numbers, hyphen and underscore) and
|
||||||
you should avoid changing it unless necessary.
|
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 <tenant-scoped-rest-api>`.
|
||||||
|
|
||||||
.. attr:: source
|
.. attr:: source
|
||||||
:required:
|
: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,
|
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.
|
this setting can be used to restrict what labels a tenant can use.
|
||||||
Without this setting, the tenant can use any labels.
|
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 <web-server-tenant-scoped-api>` 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
|
||||||
|
<web-server-tenant-scoped-api>` 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']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
|
@ -39,7 +39,6 @@ listen_address=127.0.0.1
|
||||||
port=9000
|
port=9000
|
||||||
static_cache_expiry=0
|
static_cache_expiry=0
|
||||||
status_url=https://zuul.example.com/status
|
status_url=https://zuul.example.com/status
|
||||||
authorizations_config=/etc/zuul/authorizations.yaml
|
|
||||||
|
|
||||||
[webclient]
|
[webclient]
|
||||||
url=https://zuul.example.com
|
url=https://zuul.example.com
|
||||||
|
|
|
@ -3,8 +3,9 @@ features:
|
||||||
- |
|
- |
|
||||||
Allow users to perform tenant-scoped, privileged actions either through
|
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
|
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
|
need a valid bearer token to perform such actions; the scope is set by matching
|
||||||
token claim.
|
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.
|
Zuul supports token signing and validation using the HS256 or RS256 algorithms.
|
||||||
External JWKS are also supported for token validation only.
|
External JWKS are also supported for token validation only.
|
||||||
Current tenant-scoped actions are "autohold", "enqueue" and "dequeue".
|
Current tenant-scoped actions are "autohold", "enqueue" and "dequeue".
|
||||||
|
|
|
@ -2531,7 +2531,6 @@ class ZuulWebFixture(fixtures.Fixture):
|
||||||
zuul.driver.pagure.PagureDriver])
|
zuul.driver.pagure.PagureDriver])
|
||||||
self.authenticators = zuul.lib.auth.AuthenticatorRegistry()
|
self.authenticators = zuul.lib.auth.AuthenticatorRegistry()
|
||||||
self.authenticators.configure(config)
|
self.authenticators.configure(config)
|
||||||
self.authorizations = zuul.lib.auth.AuthorizationRegistry()
|
|
||||||
if info is None:
|
if info is None:
|
||||||
self.info = zuul.model.WebInfo()
|
self.info = zuul.model.WebInfo()
|
||||||
else:
|
else:
|
||||||
|
@ -2548,8 +2547,7 @@ class ZuulWebFixture(fixtures.Fixture):
|
||||||
connections=self.connections,
|
connections=self.connections,
|
||||||
zk_hosts=self.zk_hosts,
|
zk_hosts=self.zk_hosts,
|
||||||
command_socket=os.path.join(self.test_root, 'web.socket'),
|
command_socket=os.path.join(self.test_root, 'web.socket'),
|
||||||
authenticators=self.authenticators,
|
authenticators=self.authenticators)
|
||||||
authorizations=self.authorizations)
|
|
||||||
self.web.start()
|
self.web.start()
|
||||||
self.addCleanup(self.stop)
|
self.addCleanup(self.stop)
|
||||||
|
|
||||||
|
|
|
@ -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
|
|
|
@ -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:
|
- tenant:
|
||||||
name: tenant-one
|
name: tenant-one
|
||||||
admin_rules:
|
admin-rules:
|
||||||
- venkman_rule
|
- venkman_rule
|
||||||
|
- car_rule
|
||||||
|
- gb_rule
|
||||||
|
- columbia_rule
|
||||||
source:
|
source:
|
||||||
gerrit:
|
gerrit:
|
||||||
config-projects:
|
config-projects:
|
||||||
|
|
|
@ -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
|
|
@ -427,6 +427,13 @@ class TestAuthorizationRuleParser(ZuulTestCase):
|
||||||
rules = self.sched.abide.admin_rules
|
rules = self.sched.abide.admin_rules
|
||||||
self.assertTrue('auth-rule-one' in rules, self.sched.abide)
|
self.assertTrue('auth-rule-one' in rules, self.sched.abide)
|
||||||
self.assertTrue('auth-rule-two' 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):
|
def test_parse_simplest_rule_from_yaml(self):
|
||||||
rule_d = {'name': 'my-rule',
|
rule_d = {'name': 'my-rule',
|
||||||
|
|
|
@ -107,6 +107,37 @@ class TestSchedulerZone(ZuulTestCase):
|
||||||
'label1')
|
'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):
|
class TestScheduler(ZuulTestCase):
|
||||||
tenant_config_file = 'config/single-tenant/main.yaml'
|
tenant_config_file = 'config/single-tenant/main.yaml'
|
||||||
|
|
||||||
|
|
|
@ -1413,3 +1413,129 @@ class TestTenantScopedWebApi(BaseTestWeb):
|
||||||
self.executor_server.release()
|
self.executor_server.release()
|
||||||
self.waitUntilSettled()
|
self.waitUntilSettled()
|
||||||
self.assertEqual(self.countJobResults(self.history, 'ABORTED'), 1)
|
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()
|
||||||
|
|
|
@ -74,7 +74,6 @@ class WebServer(zuul.cmd.ZuulDaemonApp):
|
||||||
|
|
||||||
params['connections'] = self.connections
|
params['connections'] = self.connections
|
||||||
params['authenticators'] = self.authenticators
|
params['authenticators'] = self.authenticators
|
||||||
params['authorizations'] = self.authorizations
|
|
||||||
# Validate config here before we spin up the ZuulWeb object
|
# Validate config here before we spin up the ZuulWeb object
|
||||||
for conn_name, connection in self.connections.connections.items():
|
for conn_name, connection in self.connections.connections.items():
|
||||||
try:
|
try:
|
||||||
|
@ -113,9 +112,6 @@ class WebServer(zuul.cmd.ZuulDaemonApp):
|
||||||
self.authenticators = zuul.lib.auth.AuthenticatorRegistry()
|
self.authenticators = zuul.lib.auth.AuthenticatorRegistry()
|
||||||
self.authenticators.configure(self.config)
|
self.authenticators.configure(self.config)
|
||||||
|
|
||||||
def configure_authorizations(self):
|
|
||||||
self.authorizations = zuul.lib.auth.AuthorizationRegistry()
|
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
if self.args.command in zuul.web.COMMANDS:
|
if self.args.command in zuul.web.COMMANDS:
|
||||||
self.send_command(self.args.command)
|
self.send_command(self.args.command)
|
||||||
|
@ -130,7 +126,6 @@ class WebServer(zuul.cmd.ZuulDaemonApp):
|
||||||
zuul.driver.github.GithubDriver,
|
zuul.driver.github.GithubDriver,
|
||||||
zuul.driver.pagure.PagureDriver])
|
zuul.driver.pagure.PagureDriver])
|
||||||
self.configure_authenticators()
|
self.configure_authenticators()
|
||||||
self.configure_authorizations()
|
|
||||||
self._run()
|
self._run()
|
||||||
except Exception:
|
except Exception:
|
||||||
self.log.exception("Exception from WebServer:")
|
self.log.exception("Exception from WebServer:")
|
||||||
|
|
|
@ -1340,6 +1340,8 @@ class AuthorizationRuleParser(object):
|
||||||
elif isinstance(node, dict):
|
elif isinstance(node, dict):
|
||||||
subrules = []
|
subrules = []
|
||||||
for claim, value in node.items():
|
for claim, value in node.items():
|
||||||
|
if claim == 'zuul_uid':
|
||||||
|
claim = '__zuul_uid_claim'
|
||||||
subrules.append(model.ClaimRule(claim, value))
|
subrules.append(model.ClaimRule(claim, value))
|
||||||
return model.AndRule(subrules)
|
return model.AndRule(subrules)
|
||||||
else:
|
else:
|
||||||
|
@ -1465,6 +1467,8 @@ class TenantParser(object):
|
||||||
if conf.get('exclude-unprotected-branches') is not None:
|
if conf.get('exclude-unprotected-branches') is not None:
|
||||||
tenant.exclude_unprotected_branches = \
|
tenant.exclude_unprotected_branches = \
|
||||||
conf['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_triggers = conf.get('allowed-triggers')
|
||||||
tenant.allowed_reporters = conf.get('allowed-reporters')
|
tenant.allowed_reporters = conf.get('allowed-reporters')
|
||||||
tenant.allowed_labels = conf.get('allowed-labels')
|
tenant.allowed_labels = conf.get('allowed-labels')
|
||||||
|
|
|
@ -36,6 +36,11 @@ class JWTAuthenticator(AuthenticatorInterface):
|
||||||
self.audience = conf.get('client_id')
|
self.audience = conf.get('client_id')
|
||||||
self.realm = conf.get('realm')
|
self.realm = conf.get('realm')
|
||||||
self.allow_authz_override = conf.get('allow_authz_override', False)
|
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):
|
def _decode(self, rawToken):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
@ -90,8 +95,9 @@ class JWTAuthenticator(AuthenticatorInterface):
|
||||||
|
|
||||||
def authenticate(self, rawToken):
|
def authenticate(self, rawToken):
|
||||||
decoded = self.decodeToken(rawToken)
|
decoded = self.decodeToken(rawToken)
|
||||||
return (decoded[self.uid_claim],
|
# inject the special authenticator-specific uid
|
||||||
decoded.get('zuul', {}).get('admin', []))
|
decoded['__zuul_uid_claim'] = decoded[self.uid_claim]
|
||||||
|
return decoded
|
||||||
|
|
||||||
|
|
||||||
class HS256Authenticator(JWTAuthenticator):
|
class HS256Authenticator(JWTAuthenticator):
|
||||||
|
|
|
@ -19,45 +19,11 @@ import jwt
|
||||||
|
|
||||||
from zuul import exceptions
|
from zuul import exceptions
|
||||||
import zuul.driver.auth.jwt as auth_jwt
|
import zuul.driver.auth.jwt as auth_jwt
|
||||||
from zuul.configloader import AuthorizationRuleParser
|
|
||||||
|
|
||||||
|
|
||||||
"""AuthN/AuthZ related library, used by zuul-web."""
|
"""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):
|
class AuthenticatorRegistry(object):
|
||||||
"""Registry of authenticators as they are declared in the configuration"""
|
"""Registry of authenticators as they are declared in the configuration"""
|
||||||
|
|
||||||
|
|
|
@ -54,6 +54,7 @@ class RPCListener(object):
|
||||||
'key_get',
|
'key_get',
|
||||||
'config_errors_list',
|
'config_errors_list',
|
||||||
'connection_list',
|
'connection_list',
|
||||||
|
'authorize_user',
|
||||||
]
|
]
|
||||||
for func in functions:
|
for func in functions:
|
||||||
f = getattr(self, 'handle_%s' % func)
|
f = getattr(self, 'handle_%s' % func)
|
||||||
|
@ -281,6 +282,29 @@ class RPCListener(object):
|
||||||
job_log_stream_address['port'] = build.worker.log_port
|
job_log_stream_address['port'] = build.worker.log_port
|
||||||
job.sendWorkComplete(json.dumps(job_log_stream_address))
|
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):
|
def handle_tenant_list(self, job):
|
||||||
output = []
|
output = []
|
||||||
for tenant_name, tenant in self.sched.abide.tenants.items():
|
for tenant_name, tenant in self.sched.abide.tenants.items():
|
||||||
|
|
|
@ -44,16 +44,6 @@ cherrypy.tools.websocket = WebSocketTool()
|
||||||
COMMANDS = ['stop', 'repl', 'norepl']
|
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):
|
class SaveParamsTool(cherrypy.Tool):
|
||||||
"""
|
"""
|
||||||
Save the URL parameters to allow them to take precedence over query
|
Save the URL parameters to allow them to take precedence over query
|
||||||
|
@ -259,19 +249,17 @@ class ZuulWebAPI(object):
|
||||||
# AuthN/AuthZ
|
# AuthN/AuthZ
|
||||||
rawToken = cherrypy.request.headers['Authorization'][len('Bearer '):]
|
rawToken = cherrypy.request.headers['Authorization'][len('Bearer '):]
|
||||||
try:
|
try:
|
||||||
uid, authz = self.zuulweb.authenticators.authenticate(rawToken)
|
claims = self.zuulweb.authenticators.authenticate(rawToken)
|
||||||
except exceptions.AuthTokenException as e:
|
except exceptions.AuthTokenException as e:
|
||||||
for header, contents in e.getAdditionalHeaders().items():
|
for header, contents in e.getAdditionalHeaders().items():
|
||||||
cherrypy.response.headers[header] = contents
|
cherrypy.response.headers[header] = contents
|
||||||
cherrypy.response.status = e.HTTPError
|
cherrypy.response.status = e.HTTPError
|
||||||
return '<h1>%s</h1>' % e.error_description
|
return '<h1>%s</h1>' % e.error_description
|
||||||
# TODO plug an actual authorization mechanism, for now rely on token
|
self.is_authorized(claims, tenant)
|
||||||
# content
|
msg = 'User "%s" requesting "%s" on %s/%s'
|
||||||
if not is_authorized(uid, tenant, authz):
|
|
||||||
raise cherrypy.HTTPError(403)
|
|
||||||
self.log.info(
|
self.log.info(
|
||||||
'User "%s" requesting "%s" on %s/%s' % (uid, 'dequeue',
|
msg % (claims['__zuul_uid_claim'], 'dequeue',
|
||||||
tenant, project))
|
tenant, project))
|
||||||
|
|
||||||
body = cherrypy.request.json
|
body = cherrypy.request.json
|
||||||
if 'pipeline' in body and (
|
if 'pipeline' in body and (
|
||||||
|
@ -303,19 +291,17 @@ class ZuulWebAPI(object):
|
||||||
# AuthN/AuthZ
|
# AuthN/AuthZ
|
||||||
rawToken = cherrypy.request.headers['Authorization'][len('Bearer '):]
|
rawToken = cherrypy.request.headers['Authorization'][len('Bearer '):]
|
||||||
try:
|
try:
|
||||||
uid, authz = self.zuulweb.authenticators.authenticate(rawToken)
|
claims = self.zuulweb.authenticators.authenticate(rawToken)
|
||||||
except exceptions.AuthTokenException as e:
|
except exceptions.AuthTokenException as e:
|
||||||
for header, contents in e.getAdditionalHeaders().items():
|
for header, contents in e.getAdditionalHeaders().items():
|
||||||
cherrypy.response.headers[header] = contents
|
cherrypy.response.headers[header] = contents
|
||||||
cherrypy.response.status = e.HTTPError
|
cherrypy.response.status = e.HTTPError
|
||||||
return '<h1>%s</h1>' % e.error_description
|
return '<h1>%s</h1>' % e.error_description
|
||||||
# TODO plug an actual authorization mechanism, for now rely on token
|
self.is_authorized(claims, tenant)
|
||||||
# content
|
msg = 'User "%s" requesting "%s" on %s/%s'
|
||||||
if not is_authorized(uid, tenant, authz):
|
|
||||||
raise cherrypy.HTTPError(403)
|
|
||||||
self.log.info(
|
self.log.info(
|
||||||
'User "%s" requesting "%s" on %s/%s' % (uid, 'enqueue',
|
msg % (claims['__zuul_uid_claim'], 'enqueue',
|
||||||
tenant, project))
|
tenant, project))
|
||||||
|
|
||||||
body = cherrypy.request.json
|
body = cherrypy.request.json
|
||||||
if all(p in body for p in ['trigger', 'change', 'pipeline']):
|
if all(p in body for p in ['trigger', 'change', 'pipeline']):
|
||||||
|
@ -380,19 +366,17 @@ class ZuulWebAPI(object):
|
||||||
rawToken = \
|
rawToken = \
|
||||||
cherrypy.request.headers['Authorization'][len('Bearer '):]
|
cherrypy.request.headers['Authorization'][len('Bearer '):]
|
||||||
try:
|
try:
|
||||||
uid, authz = self.zuulweb.authenticators.authenticate(rawToken)
|
claims = self.zuulweb.authenticators.authenticate(rawToken)
|
||||||
except exceptions.AuthTokenException as e:
|
except exceptions.AuthTokenException as e:
|
||||||
for header, contents in e.getAdditionalHeaders().items():
|
for header, contents in e.getAdditionalHeaders().items():
|
||||||
cherrypy.response.headers[header] = contents
|
cherrypy.response.headers[header] = contents
|
||||||
cherrypy.response.status = e.HTTPError
|
cherrypy.response.status = e.HTTPError
|
||||||
return '<h1>%s</h1>' % e.error_description
|
return '<h1>%s</h1>' % e.error_description
|
||||||
# TODO plug an actual authorization mechanism, for now rely on
|
self.is_authorized(claims, tenant)
|
||||||
# token content
|
msg = 'User "%s" requesting "%s" on %s/%s'
|
||||||
if not is_authorized(uid, tenant, authz):
|
|
||||||
raise cherrypy.HTTPError(403)
|
|
||||||
self.log.info(
|
self.log.info(
|
||||||
'User "%s" requesting "%s" on %s/%s' % (uid, 'autohold',
|
msg % (claims['__zuul_uid_claim'], 'autohold',
|
||||||
tenant, project))
|
tenant, project))
|
||||||
|
|
||||||
length = int(cherrypy.request.headers['Content-Length'])
|
length = int(cherrypy.request.headers['Content-Length'])
|
||||||
body = cherrypy.request.body.read(length)
|
body = cherrypy.request.body.read(length)
|
||||||
|
@ -502,6 +486,21 @@ class ZuulWebAPI(object):
|
||||||
resp.last_modified = self.zuulweb.start_time
|
resp.last_modified = self.zuulweb.start_time
|
||||||
return ret
|
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.expose
|
||||||
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
|
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
|
||||||
def tenants(self):
|
def tenants(self):
|
||||||
|
@ -968,7 +967,6 @@ class ZuulWeb(object):
|
||||||
static_path=None,
|
static_path=None,
|
||||||
zk_hosts=None,
|
zk_hosts=None,
|
||||||
authenticators=None,
|
authenticators=None,
|
||||||
authorizations=None,
|
|
||||||
command_socket=None,
|
command_socket=None,
|
||||||
):
|
):
|
||||||
self.start_time = time.time()
|
self.start_time = time.time()
|
||||||
|
@ -988,7 +986,6 @@ class ZuulWeb(object):
|
||||||
self.zk.connect(hosts=zk_hosts, read_only=True)
|
self.zk.connect(hosts=zk_hosts, read_only=True)
|
||||||
self.connections = connections
|
self.connections = connections
|
||||||
self.authenticators = authenticators
|
self.authenticators = authenticators
|
||||||
self.authorizations = authorizations
|
|
||||||
self.stream_manager = StreamManager()
|
self.stream_manager = StreamManager()
|
||||||
|
|
||||||
self.command_socket = commandsocket.CommandSocket(command_socket)
|
self.command_socket = commandsocket.CommandSocket(command_socket)
|
||||||
|
|
Loading…
Reference in New Issue