Add policy and tenancy for query endpoint
This adds the telemetry:query and telemetry:query:all_projects policies, which are used for the query endpoint. telemetry:query is accessible by project readers and telemetry:query:all_projects is accessible by admins and services. This allows to distinguish between a "low privilege" and "high privilege" access. When accessing the endpoint with a low privilege, the PromQLRbac.modify_query from observabilityclient is used to add project label to each query to enforce tenancy. When accessing with high privilege, the query is relayed to prometheus without modification allowing to query metrics across projects or to query metrics which have no project label. This completes the work on the query endpoint code. Once this patch goes through the review and merges, it'll be replicated in a similar way across all other endpoints, which will complete the minimal functionality of Aetos. Change-Id: Ic8de79094f9d2a3b629ef3b59f2c5931421bb3b1
This commit is contained in:
@@ -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)
|
||||
|
@@ -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'
|
||||
}
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
||||
|
2
aetos/tests/functional/policy.yaml-test
Normal file
2
aetos/tests/functional/policy.yaml-test
Normal file
@@ -0,0 +1,2 @@
|
||||
"telemetry:query": "!"
|
||||
"telemetry:query:all_projects": "!"
|
@@ -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
|
||||
|
Reference in New Issue
Block a user