diff --git a/aetos/controllers/api/v1/query.py b/aetos/controllers/api/v1/query.py index 026ed7d..fa0435f 100644 --- a/aetos/controllers/api/v1/query.py +++ b/aetos/controllers/api/v1/query.py @@ -13,12 +13,15 @@ # License for the specific language governing permissions and limitations # under the License. +from observabilityclient import rbac as obsc_rbac from oslo_log import log import pecan +from webob import exc from wsme import types as wtypes import wsmeext.pecan as wsme_pecan from aetos.controllers.api.v1 import base +from aetos import rbac LOG = log.getLogger(__name__) @@ -27,11 +30,33 @@ class QueryController(base.Base): @wsme_pecan.wsexpose(wtypes.text, wtypes.text) def get(self, query): """Query endpoint""" - # TODO(jwysogla): - # - policy handling - # - query modification + target = {"project_id": pecan.request.headers.get('X-Project-Id')} + try: + rbac.enforce('query:all_projects', pecan.request.headers, + pecan.request.enforcer, target) + privileged = True + LOG.debug( + "Received a high privilege request for the query endpoint" + ) + except exc.HTTPForbidden: + rbac.enforce('query', pecan.request.headers, + pecan.request.enforcer, target) + privileged = False + LOG.debug( + "Received a low privilege request for the query endpoint" + ) + self.create_prometheus_client(pecan.request.cfg) - modified_query = query + + modified_query = "" + if privileged: + modified_query = query + else: + promQLRbac = obsc_rbac.PromQLRbac( + self.prometheus_client, + target['project_id'] + ) + modified_query = promQLRbac.modify_query(query) LOG.debug("Unmodified query: %s", query) LOG.debug("Query sent to prometheus: %s", modified_query) diff --git a/aetos/policies.py b/aetos/policies.py index 2eeca89..f288fc2 100644 --- a/aetos/policies.py +++ b/aetos/policies.py @@ -24,6 +24,8 @@ UNPROTECTED = '' PROJECT_ADMIN = 'role:admin and project_id:%(project_id)s' PROJECT_MEMBER = 'role:member and project_id:%(project_id)s' PROJECT_READER = 'role:reader and project_id:%(project_id)s' +SERVICE = 'role:service' +PROJECT_ADMIN_OR_SERVICE = f'({PROJECT_ADMIN}) or ({SERVICE})' rules = [ policy.RuleDefault( @@ -66,6 +68,30 @@ rules = [ } ], ), + policy.DocumentedRuleDefault( + name="telemetry:query", + check_str=PROJECT_READER, + scope_types=['project'], + description='Prometheus Query endpoint with tenancy enforced.', + operations=[ + { + 'path': '/api/v1/query', + 'method': 'GET' + } + ], + ), + policy.DocumentedRuleDefault( + name="telemetry:query:all_projects", + check_str=PROJECT_ADMIN_OR_SERVICE, + scope_types=['project'], + description='Prometheus Query endpoint without tenancy enforced.', + operations=[ + { + 'path': '/api/v1/query', + 'method': 'GET' + } + ], + ), ] diff --git a/aetos/tests/functional/base.py b/aetos/tests/functional/base.py index ee5a981..7fec9b8 100644 --- a/aetos/tests/functional/base.py +++ b/aetos/tests/functional/base.py @@ -43,12 +43,13 @@ class TestCase(base.TestCase): def setUp(self): super().setUp() + self.project_id = 'project1' self.admin_auth_headers = {'X-User-Id': 'admin_user', - 'X-Project-Id': 'project1', + 'X-Project-Id': self.project_id, 'X-Roles': 'admin'} self.reader_auth_headers = {'X-User-Id': 'reader_user', - 'X-Project-Id': 'project1', + 'X-Project-Id': self.project_id, 'X-Roles': 'reader'} conf = service.prepare_service(argv=[], config_files=[]) @@ -106,8 +107,6 @@ class TestCase(base.TestCase): extra_environ=extra_environ, expect_errors=expect_errors, status=status) - if not expect_errors: - response = response.json return response diff --git a/aetos/tests/functional/policy.yaml-test b/aetos/tests/functional/policy.yaml-test new file mode 100644 index 0000000..b09195d --- /dev/null +++ b/aetos/tests/functional/policy.yaml-test @@ -0,0 +1,2 @@ +"telemetry:query": "!" +"telemetry:query:all_projects": "!" diff --git a/aetos/tests/functional/test_core_endpoints.py b/aetos/tests/functional/test_core_endpoints.py index fff8cac..9b0580a 100644 --- a/aetos/tests/functional/test_core_endpoints.py +++ b/aetos/tests/functional/test_core_endpoints.py @@ -17,13 +17,55 @@ test_core_endpoints Tests for endpoints under /api/v1 """ -from unittest import mock - from observabilityclient import prometheus_client +from observabilityclient import rbac +import os +from unittest import mock +import webtest +from aetos import app from aetos.tests.functional import base +class TestCoreEndpointsForbidden(base.TestCase): + def setUp(self): + super().setUp() + self.expected_status_code = 403 + self.expected_fault_string = "RBAC Authorization Failed" + + pf = os.path.abspath('aetos/tests/functional/policy.yaml-test') + self.CONF.set_override('policy_file', pf, group='oslo_policy') + self.CONF.set_override('auth_mode', None, group=None) + self.app = webtest.TestApp(app.load_app(self.CONF)) + + def test_label(self): + pass + + def test_labels(self): + pass + + def test_query(self): + query_string = 'ceilometer_image_size' + params = {'query': query_string} + + result = self.get_json('/query', **params, + headers=self.reader_auth_headers, + status=self.expected_status_code) + + self.assertEqual(self.expected_status_code, result.status_code) + self.assertEqual(self.expected_fault_string, + result.json['error_message']['faultstring']) + + def test_series(self): + pass + + def test_status(self): + pass + + def test_targets(self): + pass + + class TestCoreEndpointsAsUser(base.TestCase): def test_label(self): pass @@ -32,7 +74,56 @@ class TestCoreEndpointsAsUser(base.TestCase): pass def test_query(self): - pass + expected_status_code = 200 + returned_from_prometheus = { + "status": "success", + "data": { + "resultType": "vector", + "result": [ + { + "metric": { + "__name__": "ceilometer_image_size", + "counter": "image.size", + "image": "828ab616-8904-48fb-a4bb-d037473cee7d", + "instance": "localhost:3000", + "job": "sg-core", + "project": "2dd8edd6c8c24f49bf04670534f6b357", + "publisher": "localhost.localdomain", + "resource": "828ab616-8904-48fb-a4bb-d037473cee7d", + "resource_name": "cirros-0.6.2-x86_64-disk", + "type": "size", + "unit": "B" + }, + "value": [ + 1748273657.273, + "21430272" + ] + } + ] + } + } + + query_string = 'ceilometer_image_size' + modified_query_string = \ + f'ceilometer_image_size{{project_id={self.project_id}}}' + params = {'query': query_string} + modified_params = {'query': modified_query_string} + + with ( + mock.patch.object(prometheus_client.PrometheusAPIClient, '_get', + return_value=returned_from_prometheus + ) as get_mock, + mock.patch.object(rbac.PromQLRbac, 'modify_query', + return_value=modified_query_string) as rbac_mock + ): + result = self.get_json('/query', **params, + headers=self.reader_auth_headers, + status=expected_status_code) + + self.assertEqual(returned_from_prometheus, result.json) + self.assertEqual(expected_status_code, result.status_code) + get_mock.assert_called_once_with('query', modified_params) + rbac_mock.assert_called_once_with(query_string) def test_series(self): pass @@ -52,7 +143,52 @@ class TestCoreEndpointsAsAdmin(base.TestCase): pass def test_query(self): - pass + expected_status_code = 200 + returned_from_prometheus = { + "status": "success", + "data": { + "resultType": "vector", + "result": [ + { + "metric": { + "__name__": "ceilometer_image_size", + "counter": "image.size", + "image": "828ab616-8904-48fb-a4bb-d037473cee7d", + "instance": "localhost:3000", + "job": "sg-core", + "project": "2dd8edd6c8c24f49bf04670534f6b357", + "publisher": "localhost.localdomain", + "resource": "828ab616-8904-48fb-a4bb-d037473cee7d", + "resource_name": "cirros-0.6.2-x86_64-disk", + "type": "size", + "unit": "B" + }, + "value": [ + 1748273657.273, + "21430272" + ] + } + ] + } + } + + query_string = 'ceilometer_image_size' + params = {'query': query_string} + + with ( + mock.patch.object(prometheus_client.PrometheusAPIClient, '_get', + return_value=returned_from_prometheus + ) as get_mock, + mock.patch.object(rbac.PromQLRbac, 'modify_query') as rbac_mock + ): + result = self.get_json('/query', **params, + headers=self.admin_auth_headers, + status=expected_status_code) + + self.assertEqual(returned_from_prometheus, result.json) + self.assertEqual(expected_status_code, result.status_code) + get_mock.assert_called_once_with('query', params) + rbac_mock.assert_not_called() def test_series(self): pass