Merge "REST API: improve tenant scoping of autohold, authorizations"

This commit is contained in:
Zuul 2020-11-06 18:58:24 +00:00 committed by Gerrit Code Review
commit 2986cc09cf
3 changed files with 164 additions and 27 deletions

View File

@ -0,0 +1,11 @@
---
features:
- |
REST API: authorizations: add a tenant-scoped endpoint at
'/api/tenant/{tenant}/authorizations'. Calling the endpoint will return
a list of admin tenants limited to the scoped tenant, if the user has admin
privileges on it.
deprecations:
- |
REST API: authorizations: the /api/user/authorizations endpoint is deprecated
in favor of the tenant-scoped endpoint. It will be removed next release.

View File

@ -832,6 +832,10 @@ class TestWeb(BaseTestWeb):
self.assertEqual("reason text", request['reason'])
self.assertEqual([], request['nodes'])
# Scope the request to tenant-two, not found
resp = self.get_url("api/tenant/tenant-two/autohold/%s" % request_id)
self.assertEqual(404, resp.status_code, resp.text)
def test_autohold_list(self):
"""test listing autoholds through zuul-web"""
client = zuul.rpcclient.RPCClient('127.0.0.1',
@ -1515,14 +1519,7 @@ class TestTenantScopedWebApi(BaseTestWeb):
self.assertEqual("some reason", request['reason'])
self.assertEqual(1, request['max_count'])
def test_autohold_delete(self):
authz = {'iss': 'zuul_operator',
'aud': 'zuul.example.com',
'sub': 'testuser',
'zuul': {
'admin': ['tenant-one', ]
},
'exp': time.time() + 3600}
def _init_autohold_delete(self, authz):
token = jwt.encode(authz, key='NoDanaOnlyZuul',
algorithm='HS256').decode('utf-8')
@ -1541,13 +1538,51 @@ class TestTenantScopedWebApi(BaseTestWeb):
self.assertNotEqual([], autohold_requests)
self.assertEqual(1, len(autohold_requests))
request_id = autohold_requests[0]['id']
return client, request_id, token
def test_autohold_delete_wrong_tenant(self):
"""Make sure authorization rules are applied"""
authz = {'iss': 'zuul_operator',
'aud': 'zuul.example.com',
'sub': 'testuser',
'zuul': {
'admin': ['tenant-one', ]
},
'exp': time.time() + 3600}
client, request_id, _ = self._init_autohold_delete(authz)
# now try the autohold-delete API
bad_authz = {'iss': 'zuul_operator',
'aud': 'zuul.example.com',
'sub': 'testuser',
'zuul': {
'admin': ['tenant-two', ]
},
'exp': time.time() + 3600}
bad_token = jwt.encode(bad_authz, key='NoDanaOnlyZuul',
algorithm='HS256').decode('utf-8')
resp = self.delete_url(
"api/tenant/tenant-one/autohold/%s" % request_id,
headers={'Authorization': 'Bearer %s' % bad_token})
# Throw a "Forbidden" error, because user is authenticated but not
# authorized for tenant-one
self.assertEqual(403, resp.status_code, resp.text)
# clean up
r = client.autohold_delete(request_id)
self.assertTrue(r)
def test_autohold_delete(self):
authz = {'iss': 'zuul_operator',
'aud': 'zuul.example.com',
'sub': 'testuser',
'zuul': {
'admin': ['tenant-one', ]
},
'exp': time.time() + 3600}
client, request_id, token = self._init_autohold_delete(authz)
resp = self.delete_url(
"api/tenant/tenant-one/autohold/%s" % request_id,
headers={'Authorization': 'Bearer %s' % token})
self.assertEqual(204, resp.status_code, resp.text)
# autohold-list should be empty now
resp = self.get_url(
"api/tenant/tenant-one/autohold")
@ -1703,12 +1738,16 @@ class TestTenantScopedWebApi(BaseTestWeb):
{'action': 'autohold_by_request_id',
'path': 'api/tenant/my-tenant/autohold/123',
'allowed_methods': ['GET', 'DELETE', ]},
{'action': 'authorizations',
'path': 'api/user/authorizations',
'allowed_methods': ['GET', ]},
{'action': 'dequeue',
'path': 'api/tenant/my-tenant/project/my-project/enqueue',
'allowed_methods': ['POST', ]},
{'action': 'authorizations',
'path': 'api/tenant/my-tenant/authorizations',
'allowed_methods': ['GET', ]},
# TODO (mhu) deprecated, remove in next version
{'action': 'authorizations',
'path': 'api/user/authorizations',
'allowed_methods': ['GET', ]},
]
for endpoint in endpoints:
preflight = self.options_url(
@ -1878,9 +1917,13 @@ class TestTenantScopedWebApiWithAuthRules(BaseTestWeb):
'exp': time.time() + 3600}
token = jwt.encode(authz, key='NoDanaOnlyZuul',
algorithm='HS256').decode('utf-8')
# TODO(mhu) deprecated, remove after next release
req = self.get_url('/api/user/authorizations',
headers={'Authorization': 'Bearer %s' % token})
self.assertEqual(401, req.status_code, req.text)
req = self.get_url('/api/tenant/tenant-one/authorizations',
headers={'Authorization': 'Bearer %s' % token})
self.assertEqual(401, req.status_code, req.text)
def test_user_actions(self):
"""Test that users get the right 'zuul.actions' trees"""
@ -1916,6 +1959,7 @@ class TestTenantScopedWebApiWithAuthRules(BaseTestWeb):
authz['exp'] = time.time() + 3600
token = jwt.encode(authz, key='NoDanaOnlyZuul',
algorithm='HS256').decode('utf-8')
# TODO(mhu) deprecated, remove after next release
req = self.get_url('/api/user/authorizations',
headers={'Authorization': 'Bearer %s' % token})
self.assertEqual(200, req.status_code, req.text)
@ -1928,8 +1972,41 @@ class TestTenantScopedWebApiWithAuthRules(BaseTestWeb):
data['zuul']['admin'],
"%s got %s" % (authz['sub'], data))
req = self.get_url('/api/tenant/tenant-one/authorizations',
headers={'Authorization': 'Bearer %s' % token})
self.assertEqual(200, req.status_code, req.text)
data = req.json()
self.assertTrue('zuul' in data,
"%s got %s" % (authz['sub'], data))
self.assertTrue('admin' in data['zuul'],
"%s got %s" % (authz['sub'], data))
self.assertEqual('tenant-one' in test_user['zuul.admin'],
data['zuul']['admin'],
"%s got %s" % (authz['sub'], data))
self.assertEqual(['tenant-one', ],
data['zuul']['scope'],
"%s got %s" % (authz['sub'], data))
req = self.get_url('/api/tenant/tenant-two/authorizations',
headers={'Authorization': 'Bearer %s' % token})
self.assertEqual(200, req.status_code, req.text)
data = req.json()
self.assertTrue('zuul' in data,
"%s got %s" % (authz['sub'], data))
self.assertTrue('admin' in data['zuul'],
"%s got %s" % (authz['sub'], data))
self.assertEqual('tenant-two' in test_user['zuul.admin'],
data['zuul']['admin'],
"%s got %s" % (authz['sub'], data))
self.assertEqual(['tenant-two', ],
data['zuul']['scope'],
"%s got %s" % (authz['sub'], data))
def test_authorizations_no_header(self):
"""Test that missing Authorization header results in HTTP 401"""
req = self.get_url('/api/tenant/tenant-one/authorizations')
self.assertEqual(401, req.status_code, req.text)
# TODO(mhu) deprecated, remove after next release
req = self.get_url('/api/user/authorizations')
self.assertEqual(401, req.status_code, req.text)

View File

@ -503,13 +503,13 @@ class ZuulWebAPI(object):
@cherrypy.tools.handle_options(allowed_methods=['GET', 'DELETE', ])
def autohold_by_request_id(self, tenant, request_id):
if cherrypy.request.method == 'GET':
return self._autohold_info(request_id)
return self._autohold_info(tenant, request_id)
elif cherrypy.request.method == 'DELETE':
return self._autohold_delete(request_id)
return self._autohold_delete(tenant, request_id)
else:
raise cherrypy.HTTPError(405)
def _autohold_info(self, request_id):
def _autohold_info(self, tenant, request_id):
job = self.rpc.submitJob('zuul:autohold_info',
{'request_id': request_id})
if job.failure:
@ -518,7 +518,11 @@ class ZuulWebAPI(object):
request = json.loads(job.data[0])
if not request:
raise cherrypy.HTTPError(
404, 'Hold request %s does not exist.' % request_id)
404, 'Hold request %s not found.' % request_id)
if tenant != request['tenant']:
# return 404 rather than 403 to avoid leaking tenant info
raise cherrypy.HTTPError(
404, 'Hold request %s not found.' % request_id)
resp = cherrypy.response
resp.headers['Access-Control-Allow-Origin'] = '*'
return {
@ -535,10 +539,9 @@ class ZuulWebAPI(object):
'nodes': request['nodes']
}
def _autohold_delete(self, request_id):
def _autohold_delete(self, tenant, request_id):
# We need tenant info from the request for authz
request = self._autohold_info(request_id)
request = self._autohold_info(tenant, request_id)
basic_error = self._basic_auth_header_check()
if basic_error is not None:
return basic_error
@ -589,12 +592,16 @@ class ZuulWebAPI(object):
'buildsets': '/api/tenant/{tenant}/buildsets',
'buildset': '/api/tenant/{tenant}/buildset/{uuid}',
'config_errors': '/api/tenant/{tenant}/config-errors',
# TODO(mhu) remove after next release
'authorizations': '/api/user/authorizations',
'tenant_authorizations': ('/api/tenant/{tenant}'
'/authorizations'),
'autohold': '/api/tenant/{tenant}/project/{project:.*}/autohold',
'autohold_list': '/api/tenant/{tenant}/autohold',
'autohold_by_request_id': '/api/tenant/{tenant}/'
'autohold/{request_id}',
'autohold_delete': '/api/tenant/{tenant}/autohold/{request_id}',
'autohold_by_request_id': ('/api/tenant/{tenant}'
'/autohold/{request_id}'),
'autohold_delete': ('/api/tenant/{tenant}'
'/autohold/{request_id}'),
'enqueue': '/api/tenant/{tenant}/project/{project:.*}/enqueue',
'dequeue': '/api/tenant/{tenant}/project/{project:.*}/dequeue',
'promote': '/api/tenant/{tenant}/promote',
@ -638,11 +645,11 @@ class ZuulWebAPI(object):
raise cherrypy.HTTPError(403)
return user_authz
# TODO good candidate for caching
# TODO(mhu) deprecated, remove next version
@cherrypy.expose
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
@cherrypy.tools.handle_options(allowed_methods=['GET', ])
def user_authorizations(self):
def authorizations(self):
basic_error = self._basic_auth_header_check()
if basic_error is not None:
return basic_error
@ -650,12 +657,50 @@ class ZuulWebAPI(object):
claims, token_error = self._auth_token_check()
if token_error is not None:
return token_error
try:
admin_tenants = self._authorizations()
except exceptions.AuthTokenException as e:
for header, contents in e.getAdditionalHeaders().items():
cherrypy.response.headers[header] = contents
cherrypy.response.status = e.HTTPError
return {'description': e.error_description,
'error': e.error,
'realm': e.realm}
return {'zuul': {'admin': admin_tenants}, }
@cherrypy.expose
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
@cherrypy.tools.handle_options(allowed_methods=['GET', ])
def tenant_authorizations(self, tenant):
basic_error = self._basic_auth_header_check()
if basic_error is not None:
return basic_error
# AuthN/AuthZ
claims, token_error = self._auth_token_check()
if token_error is not None:
return token_error
try:
admin_tenants = self._authorizations()
except exceptions.AuthTokenException as e:
for header, contents in e.getAdditionalHeaders().items():
cherrypy.response.headers[header] = contents
cherrypy.response.status = e.HTTPError
return {'description': e.error_description,
'error': e.error,
'realm': e.realm}
return {'zuul': {'admin': tenant in admin_tenants,
'scope': [tenant, ]}, }
# TODO good candidate for caching
def _authorizations(self):
rawToken = cherrypy.request.headers['Authorization'][len('Bearer '):]
claims = self.zuulweb.authenticators.authenticate(rawToken)
if 'zuul' in claims and 'admin' in claims.get('zuul', {}):
return {'zuul': {'admin': claims['zuul']['admin']}, }
job = self.rpc.submitJob('zuul:get_admin_tenants',
{'claims': claims})
admin_tenants = json.loads(job.data[0])
return {'zuul': {'admin': admin_tenants}, }
return admin_tenants
@cherrypy.expose
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
@ -1222,7 +1267,10 @@ class ZuulWeb(object):
# route order is important, put project actions before the more
# generic tenant/{tenant}/project/{project} route
route_map.connect('api', '/api/user/authorizations',
controller=api, action='user_authorizations')
controller=api, action='authorizations')
route_map.connect('api', '/api/tenant/{tenant}/authorizations',
controller=api,
action='tenant_authorizations')
route_map.connect(
'api',
'/api/tenant/{tenant}/project/{project:.*}/autohold',
@ -1240,7 +1288,8 @@ class ZuulWeb(object):
'/api/tenant/{tenant}/promote',
controller=api, action='promote')
route_map.connect('api', '/api/tenant/{tenant}/autohold/{request_id}',
controller=api, action='autohold_by_request_id')
controller=api,
action='autohold_by_request_id')
route_map.connect('api', '/api/tenant/{tenant}/autohold',
controller=api, action='autohold_list')
route_map.connect('api', '/api/tenant/{tenant}/projects',