From be42f2035a087dac3923c6e365b8bb785eee998b Mon Sep 17 00:00:00 2001 From: Fabio Giannetti Date: Tue, 18 Nov 2014 22:42:59 -0800 Subject: [PATCH] RBAC Support for Ceilometer API Implementation This patch adds policy based Role Based Access Control to the Ceilometer V2 APIs. Validation/Enforcement of the policy is executed for the different controllers and hence it is possible to granularly control access. Co-Authored-By: Fabio Giannetti Change-Id: I788b9b31c8cfba9f3caa19f1f6d465a3f81101ad --- ceilometer/api/acl.py | 50 ---------------- ceilometer/api/controllers/v2.py | 86 ++++++++++++++++++++++++---- ceilometer/api/rbac.py | 95 +++++++++++++++++++++++++++++++ etc/ceilometer/policy.json | 5 +- etc/ceilometer/policy.json.sample | 31 ++++++++++ 5 files changed, 205 insertions(+), 62 deletions(-) delete mode 100644 ceilometer/api/acl.py create mode 100644 ceilometer/api/rbac.py create mode 100644 etc/ceilometer/policy.json.sample diff --git a/ceilometer/api/acl.py b/ceilometer/api/acl.py deleted file mode 100644 index b8f6ca71c..000000000 --- a/ceilometer/api/acl.py +++ /dev/null @@ -1,50 +0,0 @@ -# -# Copyright 2012 New Dream Network, LLC (DreamHost) -# -# Author: Doug Hellmann -# -# 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. - -"""Access Control Lists (ACL's) control access the API server.""" - -from ceilometer.openstack.common import policy - -_ENFORCER = None - - -def get_limited_to(headers): - """Return the user and project the request should be limited to. - - :param headers: HTTP headers dictionary - :return: A tuple of (user, project), set to None if there's no limit on - one of these. - - """ - global _ENFORCER - if not _ENFORCER: - _ENFORCER = policy.Enforcer() - if not _ENFORCER.enforce('context_is_admin', - {}, - {'roles': headers.get('X-Roles', "").split(",")}): - return headers.get('X-User-Id'), headers.get('X-Project-Id') - return None, None - - -def get_limited_to_project(headers): - """Return the project the request should be limited to. - - :param headers: HTTP headers dictionary - :return: A project, or None if there's no limit on it. - - """ - return get_limited_to(headers)[1] diff --git a/ceilometer/api/controllers/v2.py b/ceilometer/api/controllers/v2.py index a1a0429f4..56722ad26 100644 --- a/ceilometer/api/controllers/v2.py +++ b/ceilometer/api/controllers/v2.py @@ -3,6 +3,7 @@ # Copyright 2013 IBM Corp. # Copyright 2013 eNovance # Copyright Ericsson AB 2013. All rights reserved +# Copyright 2014 Hewlett-Packard Company # # Authors: Doug Hellmann # Angus Salkeld @@ -10,6 +11,7 @@ # Julien Danjou # Ildiko Vancsa # Balazs Gibizer +# Fabio Giannetti # # 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 @@ -50,7 +52,7 @@ import wsmeext.pecan as wsme_pecan import ceilometer from ceilometer.alarm import service as alarm_service from ceilometer.alarm.storage import models as alarm_models -from ceilometer.api import acl +from ceilometer.api import rbac from ceilometer.event.storage import models as event_models from ceilometer import messaging from ceilometer.openstack.common import context @@ -350,7 +352,7 @@ def _get_auth_project(on_behalf_of=None): # hence for null auth_project (indicating admin-ness) we check if # the creating tenant differs from the tenant on whose behalf the # alarm is being created - auth_project = acl.get_limited_to_project(pecan.request.headers) + auth_project = rbac.get_limited_to_project(pecan.request.headers) created_by = pecan.request.headers.get('X-Project-Id') is_admin = auth_project is None @@ -386,7 +388,7 @@ def _sanitize_query(query, db_func, on_behalf_of=None): def _verify_query_segregation(query, auth_project=None): """Ensure non-admin queries are not constrained to another project.""" auth_project = (auth_project or - acl.get_limited_to_project(pecan.request.headers)) + rbac.get_limited_to_project(pecan.request.headers)) if not auth_project: return @@ -896,6 +898,9 @@ class MeterController(rest.RestController): :param q: Filter rules for the data to be returned. :param limit: Maximum number of samples to return. """ + + rbac.enforce('get_samples', pecan.request) + q = q or [] if limit and limit < 0: raise ClientSideError(_("Limit must be positive")) @@ -912,8 +917,11 @@ class MeterController(rest.RestController): :param samples: a list of samples within the request body. """ + + rbac.enforce('create_samples', pecan.request) + now = timeutils.utcnow() - auth_project = acl.get_limited_to_project(pecan.request.headers) + auth_project = rbac.get_limited_to_project(pecan.request.headers) def_source = pecan.request.cfg.sample_source def_project_id = pecan.request.headers.get('X-Project-Id') def_user_id = pecan.request.headers.get('X-User-Id') @@ -976,6 +984,9 @@ class MeterController(rest.RestController): period long of that number of seconds. :param aggregate: The selectable aggregation functions to be applied. """ + + rbac.enforce('compute_statistics', pecan.request) + q = q or [] groupby = groupby or [] aggregate = aggregate or [] @@ -1076,6 +1087,9 @@ class MetersController(rest.RestController): :param q: Filter rules for the meters to be returned. """ + + rbac.enforce('get_meters', pecan.request) + q = q or [] # Timestamp field is not supported for Meter queries @@ -1167,6 +1181,9 @@ class SamplesController(rest.RestController): :param q: Filter rules for the samples to be returned. :param limit: Maximum number of samples to be returned. """ + + rbac.enforce('get_samples', pecan.request) + q = q or [] if limit and limit < 0: @@ -1182,6 +1199,9 @@ class SamplesController(rest.RestController): :param sample_id: the id of the sample. """ + + rbac.enforce('get_sample', pecan.request) + f = storage.SampleFilter(message_id=sample_id) samples = list(pecan.request.storage_conn.get_samples(f)) @@ -1417,7 +1437,7 @@ class ValidatedComplexQuery(object): If the tenant is not admin insert an extra "and =" clause to the query. """ - authorized_project = acl.get_limited_to_project(pecan.request.headers) + authorized_project = rbac.get_limited_to_project(pecan.request.headers) is_admin = authorized_project is None if not is_admin: self._restrict_to_project(authorized_project, visibility_field) @@ -1553,7 +1573,10 @@ class ResourcesController(rest.RestController): :param resource_id: The UUID of the resource. """ - authorized_project = acl.get_limited_to_project(pecan.request.headers) + + rbac.enforce('get_resource', pecan.request) + + authorized_project = rbac.get_limited_to_project(pecan.request.headers) resources = list(pecan.request.storage_conn.get_resources( resource=resource_id, project=authorized_project)) if not resources: @@ -1568,6 +1591,9 @@ class ResourcesController(rest.RestController): :param q: Filter rules for the resources to be returned. :param meter_links: option to include related meter links """ + + rbac.enforce('get_resources', pecan.request) + q = q or [] kwargs = _query_to_kwargs(q, pecan.request.storage_conn.get_resources) resources = [ @@ -1983,7 +2009,7 @@ class AlarmController(rest.RestController): def _alarm(self): self.conn = pecan.request.alarm_storage_conn - auth_project = acl.get_limited_to_project(pecan.request.headers) + auth_project = rbac.get_limited_to_project(pecan.request.headers) alarms = list(self.conn.get_alarms(alarm_id=self._id, project=auth_project)) if not alarms: @@ -2020,6 +2046,9 @@ class AlarmController(rest.RestController): @wsme_pecan.wsexpose(Alarm) def get(self): """Return this alarm.""" + + rbac.enforce('get_alarm', pecan.request) + return Alarm.from_db_model(self._alarm()) @wsme_pecan.wsexpose(Alarm, body=Alarm) @@ -2028,13 +2057,16 @@ class AlarmController(rest.RestController): :param data: an alarm within the request body. """ + + rbac.enforce('change_alarm', pecan.request) + # Ensure alarm exists alarm_in = self._alarm() now = timeutils.utcnow() data.alarm_id = self._id - user, project = acl.get_limited_to(pecan.request.headers) + user, project = rbac.get_limited_to(pecan.request.headers) if user: data.user_id = user elif data.user_id == wtypes.Unset: @@ -2084,6 +2116,9 @@ class AlarmController(rest.RestController): @wsme_pecan.wsexpose(None, status_code=204) def delete(self): """Delete this alarm.""" + + rbac.enforce('delete_alarm', pecan.request) + # ensure alarm exists before deleting alarm = self._alarm() self.conn.delete_alarm(alarm.alarm_id) @@ -2100,11 +2135,14 @@ class AlarmController(rest.RestController): :param q: Filter rules for the changes to be described. """ + + rbac.enforce('alarm_history', pecan.request) + q = q or [] # allow history to be returned for deleted alarms, but scope changes # returned to those carried out on behalf of the auth'd tenant, to # avoid inappropriate cross-tenant visibility of alarm history - auth_project = acl.get_limited_to_project(pecan.request.headers) + auth_project = rbac.get_limited_to_project(pecan.request.headers) conn = pecan.request.alarm_storage_conn kwargs = _query_to_kwargs(q, conn.get_alarm_changes, ['on_behalf_of', 'alarm_id']) @@ -2119,6 +2157,9 @@ class AlarmController(rest.RestController): :param state: an alarm state within the request body. """ + + rbac.enforce('change_alarm_state', pecan.request) + # note(sileht): body are not validated by wsme # Workaround for https://bugs.launchpad.net/wsme/+bug/1227229 if state not in state_kind: @@ -2136,6 +2177,9 @@ class AlarmController(rest.RestController): @wsme_pecan.wsexpose(state_kind_enum) def get_state(self): """Get the state of this alarm.""" + + rbac.enforce('get_alarm_state', pecan.request) + alarm = self._alarm() return alarm.state @@ -2179,11 +2223,14 @@ class AlarmsController(rest.RestController): :param data: an alarm within the request body. """ + + rbac.enforce('create_alarm', pecan.request) + conn = pecan.request.alarm_storage_conn now = timeutils.utcnow() data.alarm_id = str(uuid.uuid4()) - user_limit, project_limit = acl.get_limited_to(pecan.request.headers) + user_limit, project_limit = rbac.get_limited_to(pecan.request.headers) def _set_ownership(aspect, owner_limitation, header): attr = '%s_id' % aspect @@ -2234,6 +2281,9 @@ class AlarmsController(rest.RestController): :param q: Filter rules for the alarms to be returned. """ + + rbac.enforce('get_alarms', pecan.request) + q = q or [] # Timestamp is not supported field for Simple Alarm queries kwargs = _query_to_kwargs(q, @@ -2348,11 +2398,16 @@ class Event(_Base): ) +# TODO(fabiog): this decorator should disappear and have a more unified +# way of controlling access and scope. Before messing with this, though +# I feel this file should be re-factored in smaller chunks one for each +# controller (e.g. meters, alarms and so on ...). Right now its size is +# overwhelming. def requires_admin(func): @functools.wraps(func) def wrapped(*args, **kwargs): - usr_limit, proj_limit = acl.get_limited_to(pecan.request.headers) + usr_limit, proj_limit = rbac.get_limited_to(pecan.request.headers) # If User and Project are None, you have full access. if usr_limit and proj_limit: # since this decorator get's called out of wsme context @@ -2488,6 +2543,9 @@ class QuerySamplesController(rest.RestController): :param body: Query rules for the samples to be returned. """ + + rbac.enforce('query_sample', pecan.request) + sample_name_mapping = {"resource": "resource_id", "meter": "counter_name", "type": "counter_type", @@ -2514,6 +2572,9 @@ class QueryAlarmHistoryController(rest.RestController): :param body: Query rules for the alarm history to be returned. """ + + rbac.enforce('query_alarm_history', pecan.request) + query = ValidatedComplexQuery(body, alarm_models.AlarmChange) query.validate(visibility_field="on_behalf_of") @@ -2534,6 +2595,9 @@ class QueryAlarmsController(rest.RestController): :param body: Query rules for the alarms to be returned. """ + + rbac.enforce('query_alarm', pecan.request) + query = ValidatedComplexQuery(body, alarm_models.Alarm) query.validate(visibility_field="project_id") diff --git a/ceilometer/api/rbac.py b/ceilometer/api/rbac.py new file mode 100644 index 000000000..473079adb --- /dev/null +++ b/ceilometer/api/rbac.py @@ -0,0 +1,95 @@ +# +# Copyright 2012 New Dream Network, LLC (DreamHost) +# Copyright 2014 Hewlett-Packard Company +# +# Author: Doug Hellmann +# Fabio Giannetti +# +# 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. + +"""Access Control Lists (ACL's) control access the API server.""" + +import pecan + +from ceilometer.openstack.common import policy + +_ENFORCER = None + + +def enforce(policy_name, request): + """Return the user and project the request should be limited to. + + :param request: HTTP request + :param policy_name: the policy name to validate authz against. + + + """ + global _ENFORCER + if not _ENFORCER: + _ENFORCER = policy.Enforcer() + _ENFORCER.load_rules() + + rule_method = "telemetry:" + policy_name + headers = request.headers + + policy_dict = dict() + policy_dict['roles'] = headers.get('X-Roles', "").split(",") + policy_dict['target.user_id'] = (headers.get('X-User-Id')) + policy_dict['target.project_id'] = (headers.get('X-Project-Id')) + + for rule_name in _ENFORCER.rules.keys(): + if rule_method == rule_name: + if not _ENFORCER.enforce( + rule_name, + {}, + policy_dict): + pecan.core.abort(status_code=403, + detail='RBAC Authorization Failed') + + +# TODO(fabiog): these methods are still used because the scoping part is really +# convoluted and difficult to separate out. + +def get_limited_to(headers): + """Return the user and project the request should be limited to. + + :param headers: HTTP headers dictionary + :return: A tuple of (user, project), set to None if there's no limit on + one of these. + + """ + global _ENFORCER + if not _ENFORCER: + _ENFORCER = policy.Enforcer() + _ENFORCER.load_rules() + + policy_dict = dict() + policy_dict['roles'] = headers.get('X-Roles', "").split(",") + policy_dict['target.user_id'] = (headers.get('X-User-Id')) + policy_dict['target.project_id'] = (headers.get('X-Project-Id')) + + if not _ENFORCER.enforce('segregation', + {}, + policy_dict): + return headers.get('X-User-Id'), headers.get('X-Project-Id') + return None, None + + +def get_limited_to_project(headers): + """Return the project the request should be limited to. + + :param headers: HTTP headers dictionary + :return: A project, or None if there's no limit on it. + + """ + return get_limited_to(headers)[1] diff --git a/etc/ceilometer/policy.json b/etc/ceilometer/policy.json index 373c5688b..4c3ec47af 100644 --- a/etc/ceilometer/policy.json +++ b/etc/ceilometer/policy.json @@ -1,3 +1,6 @@ { - "context_is_admin": [["role:admin"]] + "context_is_admin": "role:admin", + "context_is_project": "project_id:%(target.project_id)s", + "context_is_owner": "user_id:%(target.user_id)s", + "segregation": "rule:context_is_admin" } diff --git a/etc/ceilometer/policy.json.sample b/etc/ceilometer/policy.json.sample new file mode 100644 index 000000000..56b27f394 --- /dev/null +++ b/etc/ceilometer/policy.json.sample @@ -0,0 +1,31 @@ +{ + "context_is_admin": "role:admin", + "context_is_project": "project_id:%(target.project_id)s", + "context_is_owner": "user_id:%(target.user_id)s", + "segregation": "rule:context_is_admin", + "service_role": "role:service", + "iaas_role": "role:iaas", + + "telemetry:get_samples": "rule:service_role or rule:iaas_role", + "telemetry:get_sample": "rule:context_is_project", + "telemetry:query_sample": "rule:context_is_admin", + "telemetry:create_samples": "rule:context_is_admin", + + "telemetry:compute_statistics": "rule:context_is_admin", + "telemetry:get_meters": "rule:context_is_admin", + + "telemetry:get_resource": "rule:context_is_admin", + "telemetry:get_resources": "rule:context_is_admin", + + "telemetry:get_alarm": "rule:context_is_admin", + "telemetry:query_alarm": "rule:context_is_admin", + "telemetry:get_alarm_state": "rule:context_is_admin", + "telemetry:get_alarms": "rule:context_is_admin", + "telemetry:create_alarm": "rule:context_is_admin", + "telemetry:set_alarm": "rule:context_is_admin", + "telemetry:delete_alarm": "rule:context_is_admin", + + "telemetry:alarm_history": "rule:context_is_admin", + "telemetry:change_alarm_state": "rule:context_is_admin", + "telemetry:query_alarm_history": "rule:context_is_admin" +}