Browse Source

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
tags/3.10.0
mhuin 5 months ago
parent
commit
19474fb62f

+ 5
- 2
doc/source/admin/components.rst View File

@@ -830,6 +830,8 @@ sections of ``zuul.conf`` are used by the web server:
830 830
       The Cache-Control max-age response header value for static files served
831 831
       by the zuul-web. Set to 0 during development to disable Cache-Control.
832 832
 
833
+.. _web-server-tenant-scoped-api:
834
+
833 835
 Enabling tenant-scoped access to privileged actions
834 836
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
835 837
 
@@ -853,8 +855,9 @@ protected endpoints and configure JWT validation:
853 855
    .. attr:: allow_authz_override
854 856
       :default: false
855 857
 
856
-      Allow a JWT to override predefined access rules. Since predefined access
857
-      rules are not supported yet, this should be set to ``true``.
858
+      Allow a JWT to override predefined access rules. See the section on
859
+      :ref:`JWT contents <jwt-format>` for more details on how to grant access
860
+      to tenants with a JWT.
858 861
 
859 862
    .. attr:: realm
860 863
 

+ 29
- 2
doc/source/admin/tenant-scoped-rest-api.rst View File

@@ -1,5 +1,7 @@
1 1
 :title: Tenant Scoped REST API
2 2
 
3
+.. _tenant-scoped-rest-api:
4
+
3 5
 Tenant Scoped REST API
4 6
 ======================
5 7
 
@@ -36,8 +38,33 @@ and Tokens should be handed over with discernment.
36 38
 Configuration
37 39
 -------------
38 40
 
39
-See the Zuul Web Server component's section about enabling tenant-scoped access to
40
-privileged actions.
41
+To enable tenant-scoped access to privileged actions, see the Zuul Web Server
42
+component's section.
43
+
44
+To set access rules for a tenant, see :ref:`the documentation about tenant
45
+definition <admin_rule_definition>`.
46
+
47
+Most of the time, only one authenticator will be needed in Zuul's configuration;
48
+namely the configuration matching a third party identity provider service like
49
+dex, auth0, keycloak or others. It can be useful however to add another
50
+authenticator similar to this one:
51
+
52
+.. code-block:: ini
53
+
54
+    [auth zuul_operator]
55
+    driver=HS256
56
+    allow_authz_override=true
57
+    realm=zuul.example.com
58
+    client_id=zuul.example.com
59
+    issuer_id=zuul_operator
60
+    secret=NoDanaOnlyZuul
61
+
62
+With such an authenticator, a Zuul operator can use Zuul's CLI to
63
+issue Tokens overriding a tenant's access rules if need
64
+be. A user can then use these Tokens with Zuul's CLI to perform protected actions
65
+on a tenant temporarily, without having to modify a tenant's access rules.
66
+
67
+.. _jwt-format:
41 68
 
42 69
 JWT Format
43 70
 ----------

+ 124
- 2
doc/source/admin/tenants.rst View File

@@ -15,11 +15,15 @@ rest (no pipelines, jobs, etc are shared between them).
15 15
 A project may appear in more than one tenant; this may be useful if
16 16
 you wish to use common job definitions across multiple tenants.
17 17
 
18
+Actions normally available to the Zuul operator only can be performed by specific
19
+users on Zuul's REST API, if admin rules are listed for the tenant. Admin rules
20
+are also defined in the tenant configuration file.
21
+
18 22
 The tenant configuration file is specified by the
19 23
 :attr:`scheduler.tenant_config` setting in ``zuul.conf``.  It is a
20 24
 YAML file which, like other Zuul configuration files, is a list of
21
-configuration objects, though only one type of object is supported:
22
-``tenant``.
25
+configuration objects, though only two types of objects are supported:
26
+``tenant`` and ``admin-rule``.
23 27
 
24 28
 Alternatively the :attr:`scheduler.tenant_config_script`
25 29
 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:
55 59
 
56 60
    - tenant:
57 61
        name: my-tenant
62
+       admin-rules:
63
+         - acl1
64
+         - acl2
58 65
        source:
59 66
          gerrit:
60 67
            config-projects:
@@ -84,6 +91,17 @@ configuration. Some examples of tenant definitions are:
84 91
       characters (ASCII letters, numbers, hyphen and underscore) and
85 92
       you should avoid changing it unless necessary.
86 93
 
94
+   .. attr:: admin-rules
95
+
96
+      A list of access rules for the tenant. These rules are checked to grant
97
+      privileged actions to users at the tenant level, through Zuul's REST API.
98
+
99
+      At least one rule in the list must match for the user to be allowed the
100
+      privileged action.
101
+
102
+      More information on tenant-scoped actions can be found in
103
+      :ref:`this section <tenant-scoped-rest-api>`.
104
+
87 105
    .. attr:: source
88 106
       :required:
89 107
 
@@ -288,3 +306,107 @@ configuration. Some examples of tenant definitions are:
288 306
       The list of labels regexp a tenant can use in job's nodeset. When set,
289 307
       this setting can be used to restrict what labels a tenant can use.
290 308
       Without this setting, the tenant can use any labels.
309
+
310
+.. _admin_rule_definition:
311
+
312
+Access Rule
313
+-----------
314
+
315
+An access rule is a set of conditions the claims of a user's JWT must match
316
+in order to be allowed to perform protected actions at a tenant's level.
317
+
318
+The protected actions available at tenant level are **autohold**, **enqueue**
319
+or **dequeue**.
320
+
321
+.. note::
322
+
323
+   Rules can be overridden by the ``zuul.admin`` claim in a Token if if matches
324
+   an authenticator configuration where `allow_authz_override` is set to true.
325
+   See :ref:`Zuul web server's configuration <web-server-tenant-scoped-api>` for
326
+   more details.
327
+
328
+Below are some examples of how access rules can be defined:
329
+
330
+.. code-block:: yaml
331
+
332
+   - admin-rule:
333
+       name: ghostbuster_or_gozerian
334
+       conditions:
335
+         - resources_access.account.roles: "ghostbuster"
336
+           iss: columbia_university
337
+         - resources_access.account.roles: "gozerian"
338
+   - admin-rule:
339
+       name: venkman_or_stantz
340
+       conditions:
341
+         - zuul_uid: venkman
342
+         - zuul_uid: stantz
343
+
344
+
345
+.. attr:: admin-rule
346
+
347
+   The following attributes are supported:
348
+
349
+   .. attr:: name
350
+      :required:
351
+
352
+      The name of the rule, so that it can be referenced in the ``admin-rules``
353
+      attribute of a tenant's definition. It must be unique.
354
+
355
+   .. attr:: conditions
356
+      :required:
357
+
358
+      This is the list of conditions that define a rule. A JWT must match **at
359
+      least one** of the conditions for the rule to apply. A condition is a
360
+      dictionary where keys are claims. **All** the associated values must
361
+      match the claims in the user's Token.
362
+
363
+      Zuul's authorization engine will adapt matching tests depending on the
364
+      nature of the claim in the Token, eg:
365
+
366
+      * if the claim is a JSON list, check that the condition value is in the
367
+        claim
368
+      * if the claim is a string, check that the condition value is equal to
369
+        the claim's value
370
+
371
+        In order to allow the parsing of claims with complex structures like
372
+        dictionaries, claim names can be written in the XPath format.
373
+
374
+        The special ``zuul_uid`` claim refers to the ``uid_claim`` setting in an
375
+        authenticator's configuration. By default it refers to the ``sub`` claim
376
+        of a Token. For more details see the :ref:`configuration section
377
+        <web-server-tenant-scoped-api>` for Zuul web server.
378
+
379
+        Under the above example, the following Token would match rules
380
+        ``ghostbuster_or_gozerian`` and ``venkman_or_stantz``:
381
+
382
+        .. code-block:: javascript
383
+
384
+          {
385
+           'iss': 'columbia_university',
386
+           'aud': 'my_zuul_deployment',
387
+           'exp': 1234567890,
388
+           'iat': 1234556780,
389
+           'sub': 'venkman',
390
+           'resources_access': {
391
+               'account': {
392
+                   'roles': ['ghostbuster', 'played_by_bill_murray']
393
+               }
394
+           },
395
+          }
396
+
397
+        And this Token would only match rule ``ghostbuster_or_gozerian``:
398
+
399
+        .. code-block:: javascript
400
+
401
+          {
402
+           'iss': 'some_hellish_dimension',
403
+           'aud': 'my_zuul_deployment',
404
+           'exp': 1234567890,
405
+           'sub': 'vinz_clortho',
406
+           'iat': 1234556780,
407
+           'resources_access': {
408
+               'account': {
409
+                   'roles': ['gozerian', 'keymaster']
410
+               }
411
+           },
412
+          }

+ 0
- 1
etc/zuul.conf-sample View File

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

+ 3
- 2
releasenotes/notes/admin_web_api-1331c81070a3e67f.yaml View File

@@ -3,8 +3,9 @@ features:
3 3
   - |
4 4
     Allow users to perform tenant-scoped, privileged actions either through
5 5
     zuul-web's REST API or zuul's client, based on the JWT standard. The users
6
-    need a valid bearer token to perform such actions; the scope is set via a
7
-    token claim.
6
+    need a valid bearer token to perform such actions; the scope is set by matching
7
+    conditions on tokens' claims; these conditions can be defined in zuul's tenant
8
+    configuration file.
8 9
     Zuul supports token signing and validation using the HS256 or RS256 algorithms.
9 10
     External JWKS are also supported for token validation only.
10 11
     Current tenant-scoped actions are "autohold", "enqueue" and "dequeue".

+ 1
- 3
tests/base.py View File

@@ -2531,7 +2531,6 @@ class ZuulWebFixture(fixtures.Fixture):
2531 2531
                              zuul.driver.pagure.PagureDriver])
2532 2532
         self.authenticators = zuul.lib.auth.AuthenticatorRegistry()
2533 2533
         self.authenticators.configure(config)
2534
-        self.authorizations = zuul.lib.auth.AuthorizationRegistry()
2535 2534
         if info is None:
2536 2535
             self.info = zuul.model.WebInfo()
2537 2536
         else:
@@ -2548,8 +2547,7 @@ class ZuulWebFixture(fixtures.Fixture):
2548 2547
             connections=self.connections,
2549 2548
             zk_hosts=self.zk_hosts,
2550 2549
             command_socket=os.path.join(self.test_root, 'web.socket'),
2551
-            authenticators=self.authenticators,
2552
-            authorizations=self.authorizations)
2550
+            authenticators=self.authenticators)
2553 2551
         self.web.start()
2554 2552
         self.addCleanup(self.stop)
2555 2553
 

+ 0
- 17
tests/fixtures/config/authorization/rules/rules.yaml View File

@@ -1,17 +0,0 @@
1
-- rule:
2
-    name: venkman_rule
3
-    conditions:
4
-      - zuul_uid: venkman
5
-- rule:
6
-    name: columbia_rule
7
-    conditions:
8
-      - sub: stantz
9
-        iss: columbia.edu
10
-      - sub: zeddemore
11
-        iss: columbia.edu
12
-- rule:
13
-    name: gb_rule
14
-    conditions:
15
-      - groups: ghostbusters
16
-    claim_types:
17
-      - groups: list

+ 23
- 1
tests/fixtures/config/authorization/single-tenant/main.yaml View File

@@ -1,7 +1,29 @@
1
+- admin-rule:
2
+    name: venkman_rule
3
+    conditions:
4
+      - zuul_uid: venkman
5
+- admin-rule:
6
+    name: columbia_rule
7
+    conditions:
8
+      - sub: stantz
9
+        iss: columbia.edu
10
+      - sub: zeddemore
11
+        iss: columbia.edu
12
+- admin-rule:
13
+    name: gb_rule
14
+    conditions:
15
+      - groups: ghostbusters
16
+- admin-rule:
17
+    name: car_rule
18
+    conditions:
19
+      - car: ecto-1
1 20
 - tenant:
2 21
     name: tenant-one
3
-    admin_rules:
22
+    admin-rules:
4 23
       - venkman_rule
24
+      - car_rule
25
+      - gb_rule
26
+      - columbia_rule
5 27
     source:
6 28
       gerrit:
7 29
         config-projects:

+ 31
- 0
tests/fixtures/zuul-admin-web-no-override.conf View File

@@ -0,0 +1,31 @@
1
+[gearman]
2
+server=127.0.0.1
3
+
4
+[scheduler]
5
+tenant_config=main.yaml
6
+relative_priority=true
7
+
8
+[merger]
9
+git_dir=/tmp/zuul-test/merger-git
10
+git_user_email=zuul@example.com
11
+git_user_name=zuul
12
+
13
+[executor]
14
+git_dir=/tmp/zuul-test/executor-git
15
+
16
+[connection gerrit]
17
+driver=gerrit
18
+server=review.example.com
19
+user=jenkins
20
+sshkey=fake_id_rsa_path
21
+
22
+[web]
23
+static_cache_expiry=1200
24
+
25
+[auth zuul_operator]
26
+driver=HS256
27
+allow_authz_override=false
28
+realm=zuul.example.com
29
+client_id=zuul.example.com
30
+issuer_id=zuul_operator
31
+secret=NoDanaOnlyZuul

+ 7
- 0
tests/unit/test_configloader.py View File

@@ -427,6 +427,13 @@ class TestAuthorizationRuleParser(ZuulTestCase):
427 427
         rules = self.sched.abide.admin_rules
428 428
         self.assertTrue('auth-rule-one' in rules, self.sched.abide)
429 429
         self.assertTrue('auth-rule-two' in rules, self.sched.abide)
430
+        claims_1 = {'sub': 'venkman'}
431
+        claims_2 = {'sub': 'gozer',
432
+                    'iss': 'another_dimension'}
433
+        self.assertTrue(rules['auth-rule-one'](claims_1))
434
+        self.assertTrue(not rules['auth-rule-one'](claims_2))
435
+        self.assertTrue(not rules['auth-rule-two'](claims_1))
436
+        self.assertTrue(rules['auth-rule-two'](claims_2))
430 437
 
431 438
     def test_parse_simplest_rule_from_yaml(self):
432 439
         rule_d = {'name': 'my-rule',

+ 31
- 0
tests/unit/test_scheduler.py View File

@@ -107,6 +107,37 @@ class TestSchedulerZone(ZuulTestCase):
107 107
                          'label1')
108 108
 
109 109
 
110
+class TestAuthorizeViaRPC(ZuulTestCase):
111
+    tenant_config_file = 'config/authorization/single-tenant/main.yaml'
112
+
113
+    def test_authorize_via_rpc(self):
114
+        client = zuul.rpcclient.RPCClient('127.0.0.1',
115
+                                          self.gearman_server.port)
116
+        self.addCleanup(client.shutdown)
117
+        claims = {'__zuul_uid_claim': 'venkman'}
118
+        authorized = client.submitJob('zuul:authorize_user',
119
+                                      {'tenant': 'tenant-one',
120
+                                       'claims': claims}).data[0]
121
+        self.assertTrue(json.loads(authorized))
122
+        claims = {'sub': 'gozer'}
123
+        authorized = client.submitJob('zuul:authorize_user',
124
+                                      {'tenant': 'tenant-one',
125
+                                       'claims': claims}).data[0]
126
+        self.assertTrue(not json.loads(authorized))
127
+        claims = {'sub': 'stantz',
128
+                  'iss': 'columbia.edu'}
129
+        authorized = client.submitJob('zuul:authorize_user',
130
+                                      {'tenant': 'tenant-one',
131
+                                       'claims': claims}).data[0]
132
+        self.assertTrue(json.loads(authorized))
133
+        claims = {'sub': 'slimer',
134
+                  'groups': ['ghostbusters', 'ectoplasms']}
135
+        authorized = client.submitJob('zuul:authorize_user',
136
+                                      {'tenant': 'tenant-one',
137
+                                       'claims': claims}).data[0]
138
+        self.assertTrue(json.loads(authorized))
139
+
140
+
110 141
 class TestScheduler(ZuulTestCase):
111 142
     tenant_config_file = 'config/single-tenant/main.yaml'
112 143
 

+ 126
- 0
tests/unit/test_web.py View File

@@ -1413,3 +1413,129 @@ class TestTenantScopedWebApi(BaseTestWeb):
1413 1413
         self.executor_server.release()
1414 1414
         self.waitUntilSettled()
1415 1415
         self.assertEqual(self.countJobResults(self.history, 'ABORTED'), 1)
1416
+
1417
+
1418
+class TestTenantScopedWebApiWithAuthRules(BaseTestWeb):
1419
+    config_file = 'zuul-admin-web-no-override.conf'
1420
+    tenant_config_file = 'config/authorization/single-tenant/main.yaml'
1421
+
1422
+    def test_override_not_allowed(self):
1423
+        """Test that authz cannot be overriden if config does not allow it"""
1424
+        args = {"reason": "some reason",
1425
+                "count": 1,
1426
+                'job': 'project-test2',
1427
+                'change': None,
1428
+                'ref': None,
1429
+                'node_hold_expiration': None}
1430
+        authz = {'iss': 'zuul_operator',
1431
+                 'aud': 'zuul.example.com',
1432
+                 'sub': 'testuser',
1433
+                 'zuul': {
1434
+                     'admin': ['tenant-one', ],
1435
+                 },
1436
+                 'exp': time.time() + 3600}
1437
+        token = jwt.encode(authz, key='NoDanaOnlyZuul',
1438
+                           algorithm='HS256').decode('utf-8')
1439
+        req = self.post_url(
1440
+            'api/tenant/tenant-one/project/org/project/autohold',
1441
+            headers={'Authorization': 'Bearer %s' % token},
1442
+            json=args)
1443
+        self.assertEqual(401, req.status_code, req.text)
1444
+
1445
+    def test_tenant_level_rule(self):
1446
+        """Test that authz rules defined at tenant level are checked"""
1447
+        path = "api/tenant/%(tenant)s/project/%(project)s/enqueue"
1448
+
1449
+        def _test_project_enqueue_with_authz(i, project, authz, expected):
1450
+            f_ch = self.fake_gerrit.addFakeChange(project, 'master',
1451
+                                                  '%s %i' % (project, i))
1452
+            f_ch.addApproval('Code-Review', 2)
1453
+            f_ch.addApproval('Approved', 1)
1454
+            change = {'trigger': 'gerrit',
1455
+                      'change': '%i,1' % i,
1456
+                      'pipeline': 'gate', }
1457
+            enqueue_args = {'tenant': 'tenant-one',
1458
+                            'project': project, }
1459
+
1460
+            token = jwt.encode(authz, key='NoDanaOnlyZuul',
1461
+                               algorithm='HS256').decode('utf-8')
1462
+            req = self.post_url(path % enqueue_args,
1463
+                                headers={'Authorization': 'Bearer %s' % token},
1464
+                                json=change)
1465
+            self.assertEqual(expected, req.status_code, req.text)
1466
+            self.waitUntilSettled()
1467
+
1468
+        i = 0
1469
+        for p in ['org/project', 'org/project1', 'org/project2']:
1470
+            i += 1
1471
+            # Authorized sub
1472
+            authz = {'iss': 'zuul_operator',
1473
+                     'aud': 'zuul.example.com',
1474
+                     'sub': 'venkman',
1475
+                     'exp': time.time() + 3600}
1476
+            _test_project_enqueue_with_authz(i, p, authz, 200)
1477
+            i += 1
1478
+            # Unauthorized sub
1479
+            authz = {'iss': 'zuul_operator',
1480
+                     'aud': 'zuul.example.com',
1481
+                     'sub': 'vigo',
1482
+                     'exp': time.time() + 3600}
1483
+            _test_project_enqueue_with_authz(i, p, authz, 403)
1484
+            i += 1
1485
+            # unauthorized issuer
1486
+            authz = {'iss': 'columbia.edu',
1487
+                     'aud': 'zuul.example.com',
1488
+                     'sub': 'stantz',
1489
+                     'exp': time.time() + 3600}
1490
+            _test_project_enqueue_with_authz(i, p, authz, 401)
1491
+        self.waitUntilSettled()
1492
+
1493
+    def test_group_rule(self):
1494
+        """Test a group rule"""
1495
+        A = self.fake_gerrit.addFakeChange('org/project2', 'master', 'A')
1496
+        A.addApproval('Code-Review', 2)
1497
+        A.addApproval('Approved', 1)
1498
+
1499
+        authz = {'iss': 'zuul_operator',
1500
+                 'aud': 'zuul.example.com',
1501
+                 'sub': 'melnitz',
1502
+                 'groups': ['ghostbusters', 'secretary'],
1503
+                 'exp': time.time() + 3600}
1504
+        token = jwt.encode(authz, key='NoDanaOnlyZuul',
1505
+                           algorithm='HS256').decode('utf-8')
1506
+        path = "api/tenant/%(tenant)s/project/%(project)s/enqueue"
1507
+        enqueue_args = {'tenant': 'tenant-one',
1508
+                        'project': 'org/project2', }
1509
+        change = {'trigger': 'gerrit',
1510
+                  'change': '1,1',
1511
+                  'pipeline': 'gate', }
1512
+        req = self.post_url(path % enqueue_args,
1513
+                            headers={'Authorization': 'Bearer %s' % token},
1514
+                            json=change)
1515
+        self.assertEqual(200, req.status_code, req.text)
1516
+        self.waitUntilSettled()
1517
+
1518
+    def test_arbitrary_claim_rule(self):
1519
+        """Test a rule based on a specific claim"""
1520
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
1521
+        A.addApproval('Code-Review', 2)
1522
+        A.addApproval('Approved', 1)
1523
+
1524
+        authz = {'iss': 'zuul_operator',
1525
+                 'aud': 'zuul.example.com',
1526
+                 'sub': 'zeddemore',
1527
+                 'car': 'ecto-1',
1528
+                 'exp': time.time() + 3600}
1529
+        token = jwt.encode(authz, key='NoDanaOnlyZuul',
1530
+                           algorithm='HS256').decode('utf-8')
1531
+        path = "api/tenant/%(tenant)s/project/%(project)s/enqueue"
1532
+        enqueue_args = {'tenant': 'tenant-one',
1533
+                        'project': 'org/project', }
1534
+        change = {'trigger': 'gerrit',
1535
+                  'change': '1,1',
1536
+                  'pipeline': 'gate', }
1537
+        req = self.post_url(path % enqueue_args,
1538
+                            headers={'Authorization': 'Bearer %s' % token},
1539
+                            json=change)
1540
+        self.assertEqual(200, req.status_code, req.text)
1541
+        self.waitUntilSettled()

+ 0
- 5
zuul/cmd/web.py View File

@@ -74,7 +74,6 @@ class WebServer(zuul.cmd.ZuulDaemonApp):
74 74
 
75 75
         params['connections'] = self.connections
76 76
         params['authenticators'] = self.authenticators
77
-        params['authorizations'] = self.authorizations
78 77
         # Validate config here before we spin up the ZuulWeb object
79 78
         for conn_name, connection in self.connections.connections.items():
80 79
             try:
@@ -113,9 +112,6 @@ class WebServer(zuul.cmd.ZuulDaemonApp):
113 112
         self.authenticators = zuul.lib.auth.AuthenticatorRegistry()
114 113
         self.authenticators.configure(self.config)
115 114
 
116
-    def configure_authorizations(self):
117
-        self.authorizations = zuul.lib.auth.AuthorizationRegistry()
118
-
119 115
     def run(self):
120 116
         if self.args.command in zuul.web.COMMANDS:
121 117
             self.send_command(self.args.command)
@@ -130,7 +126,6 @@ class WebServer(zuul.cmd.ZuulDaemonApp):
130 126
                                  zuul.driver.github.GithubDriver,
131 127
                                  zuul.driver.pagure.PagureDriver])
132 128
             self.configure_authenticators()
133
-            self.configure_authorizations()
134 129
             self._run()
135 130
         except Exception:
136 131
             self.log.exception("Exception from WebServer:")

+ 4
- 0
zuul/configloader.py View File

@@ -1340,6 +1340,8 @@ class AuthorizationRuleParser(object):
1340 1340
             elif isinstance(node, dict):
1341 1341
                 subrules = []
1342 1342
                 for claim, value in node.items():
1343
+                    if claim == 'zuul_uid':
1344
+                        claim = '__zuul_uid_claim'
1343 1345
                     subrules.append(model.ClaimRule(claim, value))
1344 1346
                 return model.AndRule(subrules)
1345 1347
             else:
@@ -1465,6 +1467,8 @@ class TenantParser(object):
1465 1467
         if conf.get('exclude-unprotected-branches') is not None:
1466 1468
             tenant.exclude_unprotected_branches = \
1467 1469
                 conf['exclude-unprotected-branches']
1470
+        if conf.get('admin-rules') is not None:
1471
+            tenant.authorization_rules = conf['admin-rules']
1468 1472
         tenant.allowed_triggers = conf.get('allowed-triggers')
1469 1473
         tenant.allowed_reporters = conf.get('allowed-reporters')
1470 1474
         tenant.allowed_labels = conf.get('allowed-labels')

+ 8
- 2
zuul/driver/auth/jwt.py View File

@@ -36,6 +36,11 @@ class JWTAuthenticator(AuthenticatorInterface):
36 36
         self.audience = conf.get('client_id')
37 37
         self.realm = conf.get('realm')
38 38
         self.allow_authz_override = conf.get('allow_authz_override', False)
39
+        if isinstance(self.allow_authz_override, str):
40
+            if self.allow_authz_override.lower() == 'true':
41
+                self.allow_authz_override = True
42
+            else:
43
+                self.allow_authz_override = False
39 44
 
40 45
     def _decode(self, rawToken):
41 46
         raise NotImplementedError
@@ -90,8 +95,9 @@ class JWTAuthenticator(AuthenticatorInterface):
90 95
 
91 96
     def authenticate(self, rawToken):
92 97
         decoded = self.decodeToken(rawToken)
93
-        return (decoded[self.uid_claim],
94
-                decoded.get('zuul', {}).get('admin', []))
98
+        # inject the special authenticator-specific uid
99
+        decoded['__zuul_uid_claim'] = decoded[self.uid_claim]
100
+        return decoded
95 101
 
96 102
 
97 103
 class HS256Authenticator(JWTAuthenticator):

+ 0
- 34
zuul/lib/auth.py View File

@@ -19,45 +19,11 @@ import jwt
19 19
 
20 20
 from zuul import exceptions
21 21
 import zuul.driver.auth.jwt as auth_jwt
22
-from zuul.configloader import AuthorizationRuleParser
23 22
 
24 23
 
25 24
 """AuthN/AuthZ related library, used by zuul-web."""
26 25
 
27 26
 
28
-class AuthorizationRegistry(object):
29
-    """Registry of authorization rules.
30
-
31
-    reconfigure(rules) takes a JSON list to create the ruleset; typically
32
-    provided by the scheduler."""
33
-
34
-    log = logging.getLogger("Zuul.AuthorizationRegistry")
35
-
36
-    def __init__(self):
37
-        self.ruleset = {}
38
-
39
-    def reconfigure(self, rules):
40
-        if not isinstance(rules, list):
41
-            raise Exception('Authorizations file must be a list of rules')
42
-        new_ruleset = {}
43
-        ruleparser = AuthorizationRuleParser()
44
-        for rule in rules:
45
-            if not isinstance(rule, dict):
46
-                raise Exception('Invalid rule format for rule "%r"' % rule)
47
-            if len(rule.keys()) > 1:
48
-                raise Exception('Rules must consist of "rule" element only')
49
-            if 'rule' in rule:
50
-                rule_tree = ruleparser.fromYaml(rule['rule'])
51
-                if rule_tree.name in new_ruleset:
52
-                    raise Exception(
53
-                        'Rule "%s" is defined at least twice' % rule_tree.name)
54
-                else:
55
-                    new_ruleset[rule_tree.name] = rule_tree
56
-            else:
57
-                raise Exception('Unknown element "%s"' % rule.keys()[0])
58
-        self.ruleset = new_ruleset
59
-
60
-
61 27
 class AuthenticatorRegistry(object):
62 28
     """Registry of authenticators as they are declared in the configuration"""
63 29
 

+ 24
- 0
zuul/rpclistener.py View File

@@ -54,6 +54,7 @@ class RPCListener(object):
54 54
             'key_get',
55 55
             'config_errors_list',
56 56
             'connection_list',
57
+            'authorize_user',
57 58
         ]
58 59
         for func in functions:
59 60
             f = getattr(self, 'handle_%s' % func)
@@ -281,6 +282,29 @@ class RPCListener(object):
281 282
             job_log_stream_address['port'] = build.worker.log_port
282 283
         job.sendWorkComplete(json.dumps(job_log_stream_address))
283 284
 
285
+    def handle_authorize_user(self, job):
286
+        args = json.loads(job.arguments)
287
+        tenant_name = args['tenant']
288
+        claims = args['claims']
289
+        tenant = self.sched.abide.tenants.get(tenant_name)
290
+        authorized = False
291
+        if tenant:
292
+            rules = tenant.authorization_rules
293
+            for rule in rules:
294
+                if rule not in self.sched.abide.admin_rules.keys():
295
+                    self.log.error('Undefined rule "%s"' % rule)
296
+                    continue
297
+                debug_msg = 'Applying rule "%s" from tenant "%s" to claims %s'
298
+                self.log.debug(
299
+                    debug_msg % (rule, tenant, json.dumps(claims)))
300
+                authorized = self.sched.abide.admin_rules[rule](claims)
301
+                if authorized:
302
+                    debug_msg = '%s authorized on tenant "%s" by rule "%s"'
303
+                    self.log.debug(
304
+                        debug_msg % (json.dumps(claims), tenant, rule))
305
+                    break
306
+        job.sendWorkComplete(json.dumps(authorized))
307
+
284 308
     def handle_tenant_list(self, job):
285 309
         output = []
286 310
         for tenant_name, tenant in self.sched.abide.tenants.items():

+ 30
- 33
zuul/web/__init__.py View File

@@ -44,16 +44,6 @@ cherrypy.tools.websocket = WebSocketTool()
44 44
 COMMANDS = ['stop', 'repl', 'norepl']
45 45
 
46 46
 
47
-def is_authorized(uid, tenant, authN=None):
48
-    """Simple authorization checker. For now, relies on the passed authN
49
-    dictionary to figure out whether 'uid' is allowed 'action' on
50
-    'tenant/project'.
51
-    This is just a stub that will be expanded in subsequent patches."""
52
-    if authN is None:
53
-        authN = []
54
-    return (tenant in authN)
55
-
56
-
57 47
 class SaveParamsTool(cherrypy.Tool):
58 48
     """
59 49
     Save the URL parameters to allow them to take precedence over query
@@ -259,19 +249,17 @@ class ZuulWebAPI(object):
259 249
         # AuthN/AuthZ
260 250
         rawToken = cherrypy.request.headers['Authorization'][len('Bearer '):]
261 251
         try:
262
-            uid, authz = self.zuulweb.authenticators.authenticate(rawToken)
252
+            claims = self.zuulweb.authenticators.authenticate(rawToken)
263 253
         except exceptions.AuthTokenException as e:
264 254
             for header, contents in e.getAdditionalHeaders().items():
265 255
                 cherrypy.response.headers[header] = contents
266 256
             cherrypy.response.status = e.HTTPError
267 257
             return '<h1>%s</h1>' % e.error_description
268
-        # TODO plug an actual authorization mechanism, for now rely on token
269
-        # content
270
-        if not is_authorized(uid, tenant, authz):
271
-            raise cherrypy.HTTPError(403)
258
+        self.is_authorized(claims, tenant)
259
+        msg = 'User "%s" requesting "%s" on %s/%s'
272 260
         self.log.info(
273
-            'User "%s" requesting "%s" on %s/%s' % (uid, 'dequeue',
274
-                                                    tenant, project))
261
+            msg % (claims['__zuul_uid_claim'], 'dequeue',
262
+                   tenant, project))
275 263
 
276 264
         body = cherrypy.request.json
277 265
         if 'pipeline' in body and (
@@ -303,19 +291,17 @@ class ZuulWebAPI(object):
303 291
         # AuthN/AuthZ
304 292
         rawToken = cherrypy.request.headers['Authorization'][len('Bearer '):]
305 293
         try:
306
-            uid, authz = self.zuulweb.authenticators.authenticate(rawToken)
294
+            claims = self.zuulweb.authenticators.authenticate(rawToken)
307 295
         except exceptions.AuthTokenException as e:
308 296
             for header, contents in e.getAdditionalHeaders().items():
309 297
                 cherrypy.response.headers[header] = contents
310 298
             cherrypy.response.status = e.HTTPError
311 299
             return '<h1>%s</h1>' % e.error_description
312
-        # TODO plug an actual authorization mechanism, for now rely on token
313
-        # content
314
-        if not is_authorized(uid, tenant, authz):
315
-            raise cherrypy.HTTPError(403)
300
+        self.is_authorized(claims, tenant)
301
+        msg = 'User "%s" requesting "%s" on %s/%s'
316 302
         self.log.info(
317
-            'User "%s" requesting "%s" on %s/%s' % (uid, 'enqueue',
318
-                                                    tenant, project))
303
+            msg % (claims['__zuul_uid_claim'], 'enqueue',
304
+                   tenant, project))
319 305
 
320 306
         body = cherrypy.request.json
321 307
         if all(p in body for p in ['trigger', 'change', 'pipeline']):
@@ -380,19 +366,17 @@ class ZuulWebAPI(object):
380 366
             rawToken = \
381 367
                 cherrypy.request.headers['Authorization'][len('Bearer '):]
382 368
             try:
383
-                uid, authz = self.zuulweb.authenticators.authenticate(rawToken)
369
+                claims = self.zuulweb.authenticators.authenticate(rawToken)
384 370
             except exceptions.AuthTokenException as e:
385 371
                 for header, contents in e.getAdditionalHeaders().items():
386 372
                     cherrypy.response.headers[header] = contents
387 373
                 cherrypy.response.status = e.HTTPError
388 374
                 return '<h1>%s</h1>' % e.error_description
389
-            # TODO plug an actual authorization mechanism, for now rely on
390
-            # token content
391
-            if not is_authorized(uid, tenant, authz):
392
-                raise cherrypy.HTTPError(403)
375
+            self.is_authorized(claims, tenant)
376
+            msg = 'User "%s" requesting "%s" on %s/%s'
393 377
             self.log.info(
394
-                'User "%s" requesting "%s" on %s/%s' % (uid, 'autohold',
395
-                                                        tenant, project))
378
+                msg % (claims['__zuul_uid_claim'], 'autohold',
379
+                       tenant, project))
396 380
 
397 381
             length = int(cherrypy.request.headers['Content-Length'])
398 382
             body = cherrypy.request.body.read(length)
@@ -502,6 +486,21 @@ class ZuulWebAPI(object):
502 486
         resp.last_modified = self.zuulweb.start_time
503 487
         return ret
504 488
 
489
+    def is_authorized(self, claims, tenant):
490
+        # First, check for zuul.admin override
491
+        override = claims.get('zuul', {}).get('admin', [])
492
+        if (override == '*' or
493
+            (isinstance(override, list) and tenant in override)):
494
+            return True
495
+        # Next, get the rules for tenant
496
+        data = {'tenant': tenant, 'claims': claims}
497
+        # TODO: it is probably worth caching
498
+        job = self.rpc.submitJob('zuul:authorize_user', data)
499
+        user_authz = json.loads(job.data[0])
500
+        if not user_authz:
501
+            raise cherrypy.HTTPError(403)
502
+        return user_authz
503
+
505 504
     @cherrypy.expose
506 505
     @cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
507 506
     def tenants(self):
@@ -968,7 +967,6 @@ class ZuulWeb(object):
968 967
                  static_path=None,
969 968
                  zk_hosts=None,
970 969
                  authenticators=None,
971
-                 authorizations=None,
972 970
                  command_socket=None,
973 971
                  ):
974 972
         self.start_time = time.time()
@@ -988,7 +986,6 @@ class ZuulWeb(object):
988 986
             self.zk.connect(hosts=zk_hosts, read_only=True)
989 987
         self.connections = connections
990 988
         self.authenticators = authenticators
991
-        self.authorizations = authorizations
992 989
         self.stream_manager = StreamManager()
993 990
 
994 991
         self.command_socket = commandsocket.CommandSocket(command_socket)

Loading…
Cancel
Save