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:
Jaromir Wysoglad
2025-05-28 09:19:23 -04:00
parent 135bc52262
commit 9dcb65ec66
5 changed files with 200 additions and 12 deletions

View File

@@ -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)

View File

@@ -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'
}
],
),
]

View File

@@ -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

View File

@@ -0,0 +1,2 @@
"telemetry:query": "!"
"telemetry:query:all_projects": "!"

View File

@@ -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