Rbac restrictions to prometheus alarms
For nonadmin users, restrict prometheus alarms to the current project. For admins, restrict the alarms to the specified project (when explicitly specified) or leave them unrestricted. This is very similar to what we already do with Gnocchi alarms in https://opendev.org/openstack/aodh/src/branch/master/aodh/api/controllers/v2/alarm_rules/gnocchi.py#L181 Change-Id: I6f1b0a1bc67376dca3e00585f04bec697781113c Signed-off-by: Jaromir Wysoglad <jwysogla@redhat.com>
This commit is contained in:
@@ -18,6 +18,7 @@ import wsme
|
||||
from wsme import types as wtypes
|
||||
|
||||
from aodh.api.controllers.v2 import base
|
||||
from aodh.api.controllers.v2 import utils as v2_utils
|
||||
|
||||
|
||||
LOG = log.getLogger(__name__)
|
||||
@@ -35,12 +36,19 @@ class PrometheusRule(base.AlarmRule):
|
||||
query = wsme.wsattr(wtypes.text, mandatory=True)
|
||||
"The Prometheus query"
|
||||
|
||||
@staticmethod
|
||||
def validate(rule):
|
||||
# TO-DO(mmagr): validate Prometheus query maybe?
|
||||
@classmethod
|
||||
def validate_alarm(cls, alarm):
|
||||
super().validate_alarm(alarm)
|
||||
|
||||
rule = alarm.prometheus_rule
|
||||
|
||||
auth_project = v2_utils.get_auth_project(alarm.project_id)
|
||||
cls.scope_to_project = None
|
||||
if auth_project:
|
||||
cls.scope_to_project = auth_project
|
||||
return rule
|
||||
|
||||
def as_dict(self):
|
||||
rule = self.as_dict_from_keys(['comparison_operator', 'threshold',
|
||||
'query'])
|
||||
'query', 'scope_to_project'])
|
||||
return rule
|
||||
|
||||
@@ -17,6 +17,7 @@ from oslo_config import cfg
|
||||
from oslo_log import log
|
||||
|
||||
from observabilityclient import client
|
||||
from observabilityclient import rbac as obsc_rbac
|
||||
|
||||
from aodh.evaluator import threshold
|
||||
from aodh import keystone_client
|
||||
@@ -32,7 +33,13 @@ OPTS = [
|
||||
"It's not possible to correctly use "
|
||||
"client-side rbac enforcement from within "
|
||||
"services. Using it can cause issues.",
|
||||
deprecated_since="Flamingo")
|
||||
deprecated_since="Flamingo"),
|
||||
cfg.StrOpt('prometheus_project_label_name',
|
||||
default="project",
|
||||
help='Name of label used to identify metric project IDs '
|
||||
'in Prometheus. This label name will be used to '
|
||||
'restrict queries to the appropriate project.'
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -40,6 +47,7 @@ class PrometheusBase(threshold.ThresholdEvaluator):
|
||||
def __init__(self, conf):
|
||||
super().__init__(conf)
|
||||
self._set_obsclient(conf)
|
||||
self.conf = conf
|
||||
|
||||
def _set_obsclient(self, conf):
|
||||
session = keystone_client.get_session(conf)
|
||||
@@ -49,7 +57,7 @@ class PrometheusBase(threshold.ThresholdEvaluator):
|
||||
|
||||
def _get_metric_data(self, query):
|
||||
LOG.debug(f'Querying Prometheus instance on: {query}')
|
||||
return self._prom.query.query(query, disable_rbac=True)
|
||||
return self._prom.query.query(query)
|
||||
|
||||
|
||||
class PrometheusEvaluator(PrometheusBase):
|
||||
@@ -66,10 +74,19 @@ class PrometheusEvaluator(PrometheusBase):
|
||||
:returns: state, trending state, statistics, number of samples outside
|
||||
threshold and reason
|
||||
"""
|
||||
metrics = self._get_metric_data(alarm_rule['query'])
|
||||
scope_to_project = alarm_rule.get('scope_to_project', False)
|
||||
query = alarm_rule['query']
|
||||
if scope_to_project:
|
||||
promQLRbac = obsc_rbac.PromQLRbac(
|
||||
self._prom.prometheus_client,
|
||||
scope_to_project,
|
||||
project_label=self.conf.prometheus_project_label_name
|
||||
)
|
||||
query = promQLRbac.modify_query(query)
|
||||
metrics = self._get_metric_data(query)
|
||||
if not metrics:
|
||||
LOG.warning("Empty result fetched from Prometheus for query"
|
||||
f" {alarm_rule['query']}")
|
||||
f" {query}")
|
||||
|
||||
statistics = self._sanitize(metrics)
|
||||
if not statistics:
|
||||
|
||||
@@ -18,6 +18,7 @@ from oslo_config import cfg
|
||||
|
||||
import aodh.api
|
||||
import aodh.api.controllers.v2.alarm_rules.gnocchi
|
||||
import aodh.api.controllers.v2.alarm_rules.prometheus
|
||||
import aodh.api.controllers.v2.alarms
|
||||
import aodh.coordination
|
||||
import aodh.evaluator
|
||||
|
||||
@@ -1365,6 +1365,74 @@ class TestAlarmsRuleGnocchi(TestAlarmsBase):
|
||||
self._verify_alarm(json, alarms[0])
|
||||
|
||||
|
||||
class TestAlarmsRulePrometheus(TestAlarmsBase):
|
||||
|
||||
def _do_post_alarm(self, headers={}, project=None, status=201):
|
||||
json = {
|
||||
'name': 'added_prometheus_alarm',
|
||||
'enabled': True,
|
||||
'type': 'prometheus',
|
||||
'ok_actions': ['http://something/ok'],
|
||||
'prometheus_rule': {
|
||||
'query': 'ceilometer_cpu',
|
||||
'comparison_operator': 'le',
|
||||
'threshold': 50,
|
||||
}
|
||||
}
|
||||
if project:
|
||||
json['project_id'] = project
|
||||
|
||||
response = self.post_json(
|
||||
'/alarms', params=json, headers=headers, status=status
|
||||
)
|
||||
return response
|
||||
|
||||
def test_post_prometheus_alarm_as_admin(self):
|
||||
auth_headers = self.auth_headers
|
||||
auth_headers['X-Roles'] = 'admin'
|
||||
resp = self._do_post_alarm(headers=auth_headers)
|
||||
|
||||
alarms = list(self.alarm_conn.get_alarms(
|
||||
alarm_id=resp.json['alarm_id']))
|
||||
|
||||
self.assertEqual(1, len(alarms))
|
||||
self.assertIsNone(alarms[0].rule['scope_to_project'])
|
||||
|
||||
def test_post_prometheus_alarm_as_admin_on_behalf_of_another_project(self):
|
||||
auth_headers = self.auth_headers
|
||||
auth_headers['X-Roles'] = 'admin'
|
||||
project_id = uuidutils.generate_uuid()
|
||||
resp = self._do_post_alarm(
|
||||
headers=auth_headers, project=project_id
|
||||
)
|
||||
|
||||
alarms = list(self.alarm_conn.get_alarms(
|
||||
alarm_id=resp.json['alarm_id']))
|
||||
self.assertEqual(1, len(alarms))
|
||||
self.assertEqual(project_id, alarms[0].rule['scope_to_project'])
|
||||
|
||||
def test_post_prometheus_alarm_as_nonadmin(self):
|
||||
auth_headers = self.auth_headers
|
||||
auth_headers['X-Roles'] = 'nonadmin'
|
||||
resp = self._do_post_alarm(headers=auth_headers)
|
||||
|
||||
alarms = list(self.alarm_conn.get_alarms(
|
||||
alarm_id=resp.json['alarm_id']))
|
||||
self.assertEqual(1, len(alarms))
|
||||
self.assertEqual(self.project_id, alarms[0].rule['scope_to_project'])
|
||||
|
||||
def test_post_prometheus_alarm_as_nonadmin_on_behalf_of_another_project(
|
||||
self
|
||||
):
|
||||
auth_headers = self.auth_headers
|
||||
auth_headers['X-Roles'] = 'nonadmin'
|
||||
resp = self._do_post_alarm(
|
||||
headers=auth_headers, project='another_project_id', status=401
|
||||
)
|
||||
self.assertEqual("Not Authorized to access project another_project_id",
|
||||
resp.json['error_message']['faultstring'])
|
||||
|
||||
|
||||
class TestAlarmsCompositeRule(TestAlarmsBase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
142
aodh/tests/unit/evaluator/test_prometheus.py
Normal file
142
aodh/tests/unit/evaluator/test_prometheus.py
Normal file
@@ -0,0 +1,142 @@
|
||||
#
|
||||
# Copyright 2025 Red Hat, Inc
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import fixtures
|
||||
from unittest import mock
|
||||
|
||||
from oslo_utils import uuidutils
|
||||
|
||||
from aodh.evaluator import prometheus
|
||||
from aodh.storage import models
|
||||
from aodh.tests import constants
|
||||
from aodh.tests.unit.evaluator import base
|
||||
|
||||
|
||||
class TestPrometheusEvaluator(base.TestEvaluatorBase):
|
||||
EVALUATOR = prometheus.PrometheusEvaluator
|
||||
|
||||
def setUp(self):
|
||||
self.client = self.useFixture(fixtures.MockPatch(
|
||||
'aodh.evaluator.prometheus.client'
|
||||
)).mock.Client.return_value
|
||||
self.prepared_alarms = [
|
||||
models.Alarm(name='instance_running_hot',
|
||||
description='instance_running_hot',
|
||||
type='prometheus',
|
||||
enabled=True,
|
||||
user_id='foobar',
|
||||
project_id='123',
|
||||
alarm_id=uuidutils.generate_uuid(),
|
||||
state='insufficient data',
|
||||
state_reason='insufficient data',
|
||||
state_timestamp=constants.MIN_DATETIME,
|
||||
timestamp=constants.MIN_DATETIME,
|
||||
insufficient_data_actions=[],
|
||||
ok_actions=[],
|
||||
alarm_actions=[],
|
||||
repeat_actions=False,
|
||||
time_constraints=[],
|
||||
severity='low',
|
||||
rule=dict(
|
||||
comparison_operator='gt',
|
||||
threshold=80.0,
|
||||
evaluation_periods=5,
|
||||
query='ceilometer_cpu')
|
||||
),
|
||||
models.Alarm(name='group_running_idle',
|
||||
description='group_running_idle',
|
||||
type='prometheus',
|
||||
enabled=True,
|
||||
user_id='foobar',
|
||||
project_id='123',
|
||||
state='insufficient data',
|
||||
state_reason='insufficient data',
|
||||
state_timestamp=constants.MIN_DATETIME,
|
||||
timestamp=constants.MIN_DATETIME,
|
||||
insufficient_data_actions=[],
|
||||
ok_actions=[],
|
||||
alarm_actions=[],
|
||||
repeat_actions=False,
|
||||
alarm_id=uuidutils.generate_uuid(),
|
||||
time_constraints=[],
|
||||
rule=dict(
|
||||
comparison_operator='le',
|
||||
threshold=10.0,
|
||||
evaluation_periods=4,
|
||||
query='ceilometer_memory'),
|
||||
),
|
||||
|
||||
]
|
||||
super().setUp()
|
||||
|
||||
def prepare_alarms(self):
|
||||
self.alarms = self.prepared_alarms[0:1]
|
||||
|
||||
def test_project_scoping_old_alarm(self):
|
||||
# Alarm created before scope_to_project was introduced
|
||||
self.client.query.query.side_effect = None
|
||||
with (
|
||||
mock.patch('aodh.evaluator.prometheus.'
|
||||
'obsc_rbac.PromQLRbac.modify_query',
|
||||
) as mock_modify_query,
|
||||
mock.patch('aodh.evaluator.prometheus.'
|
||||
'obsc_rbac.PromQLRbac.__init__',
|
||||
return_value=None) as mock_rbac_init):
|
||||
self._evaluate_all_alarms()
|
||||
mock_rbac_init.assert_not_called()
|
||||
mock_modify_query.assert_not_called()
|
||||
|
||||
def test_project_scoping_not_scoped(self):
|
||||
# Alarm likely created by admin. This shouldn't get scoped
|
||||
# to a single project.
|
||||
self.client.query.query.side_effect = None
|
||||
self.alarms[0].rule['scope_to_project'] = None
|
||||
with (
|
||||
mock.patch('aodh.evaluator.prometheus.'
|
||||
'obsc_rbac.PromQLRbac.modify_query',
|
||||
) as mock_modify_query,
|
||||
mock.patch('aodh.evaluator.prometheus.'
|
||||
'obsc_rbac.PromQLRbac.__init__',
|
||||
return_value=None) as mock_rbac_init):
|
||||
self._evaluate_all_alarms()
|
||||
mock_rbac_init.assert_not_called()
|
||||
mock_modify_query.assert_not_called()
|
||||
|
||||
def test_project_scoping_scoped(self):
|
||||
# Alarm which needs to be scoped to a single project
|
||||
project_id = 123
|
||||
project_label = 'custom_label'
|
||||
self.conf.set_override('prometheus_project_label_name', project_label)
|
||||
self.alarms[0].rule['scope_to_project'] = project_id
|
||||
self.client.query.query.side_effect = None
|
||||
|
||||
with (
|
||||
mock.patch('aodh.evaluator.prometheus.'
|
||||
'obsc_rbac.PromQLRbac.modify_query',
|
||||
return_value='') as mock_modify_query,
|
||||
mock.patch('aodh.evaluator.prometheus.'
|
||||
'obsc_rbac.PromQLRbac.__init__',
|
||||
return_value=None) as mock_rbac_init):
|
||||
self._evaluate_all_alarms()
|
||||
|
||||
mock_rbac_init.assert_called_once()
|
||||
self.assertEqual(
|
||||
project_id, mock_rbac_init.call_args.args[1])
|
||||
self.assertEqual(
|
||||
project_label,
|
||||
mock_rbac_init.call_args.kwargs['project_label'])
|
||||
|
||||
mock_modify_query.assert_called_once_with(
|
||||
'ceilometer_cpu')
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
features:
|
||||
- |
|
||||
Prometheus queries for nonadmin users or for admin users who
|
||||
specify a particular project are now being restricted to that
|
||||
project or to the nonadmin user's current project. Admins can
|
||||
still create alarms for resources across multiple projects without
|
||||
restrictions. This works similarly to how gnocchi alarms work for
|
||||
some time already. Use the [DEFAULT].prometheus_project_label_name
|
||||
configuration option to configure label name used for
|
||||
distinguishing between projects.
|
||||
@@ -33,7 +33,7 @@ WSME>=0.12.1 # MIT
|
||||
cachetools>=1.1.6 # MIT
|
||||
cotyledon>=1.7.3 # Apache-2.0
|
||||
keystoneauth1>=2.1 # Apache-2.0
|
||||
python-observabilityclient>=0.0.4 # Apache-2.0
|
||||
python-observabilityclient>=1.1.0 # Apache-2.0
|
||||
python-octaviaclient>=1.8.0 # Apache-2.0
|
||||
python-dateutil>=2.5.3 # BSD
|
||||
python-heatclient>=1.17.0 # Apache-2.0
|
||||
|
||||
Reference in New Issue
Block a user