diff --git a/blazar/enforcement/filters/__init__.py b/blazar/enforcement/filters/__init__.py index b66132f1..62e53b47 100644 --- a/blazar/enforcement/filters/__init__.py +++ b/blazar/enforcement/filters/__init__.py @@ -13,9 +13,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +from blazar.enforcement.filters.external_service_filter import ( + ExternalServiceFilter) from blazar.enforcement.filters.max_lease_duration_filter import ( MaxLeaseDurationFilter) -__all__ = ['MaxLeaseDurationFilter'] +__all__ = ['ExternalServiceFilter', 'MaxLeaseDurationFilter'] all_filters = __all__ diff --git a/blazar/enforcement/filters/external_service_filter.py b/blazar/enforcement/filters/external_service_filter.py new file mode 100644 index 00000000..687231ba --- /dev/null +++ b/blazar/enforcement/filters/external_service_filter.py @@ -0,0 +1,156 @@ +# Copyright (c) 2022 University of Chicago. +# +# 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 datetime +import json +import requests + +from blazar.enforcement.filters import base_filter +from blazar import exceptions +from blazar.i18n import _ +from blazar.utils.openstack.keystone import BlazarKeystoneClient + +from oslo_config import cfg +from oslo_log import log as logging + +LOG = logging.getLogger(__name__) + + +class DateTimeEncoder(json.JSONEncoder): + def default(self, o): + if isinstance(o, datetime.datetime): + return str(o) + + return json.JSONEncoder.default(self, o) + + +class ExternalServiceUnsupportedHTTPResponse(exceptions.BlazarException): + code = 400 + msg_fmt = _('External service enforcement filter returned a %(status)s ' + 'HTTP response. Only 204 and 403 responses are supported.') + + +class ExternalServiceUnsupportedDeniedResponse(exceptions.BlazarException): + code = 400 + msg_fmt = _('External service enforcement filter returned a 403 HTTP ' + 'response %(response)s without a valid JSON dictionary ' + 'containing a "message" key.') + + +class ExternalServiceFilterException(exceptions.NotAuthorized): + code = 400 + msg_fmt = _('%(message)s') + + +class ExternalServiceFilter(base_filter.BaseFilter): + + enforcement_opts = [ + cfg.StrOpt( + 'external_service_endpoint', + default=None, + help='The URL of the external service API.'), + cfg.StrOpt( + 'external_service_check_create', + default=None, + help='Overwrite check-create endpoint with absolute URL.'), + cfg.StrOpt( + 'external_service_check_update', + default=None, + help='Overwrite check-update endpoint with absolute URL.'), + cfg.StrOpt( + 'external_service_on_end', + default=None, + help='Overwrite on-end endpoint with absolute URL.'), + cfg.StrOpt( + 'external_service_token', + default="", + help='Token used for authentication with the external service.') + ] + + def __init__(self, conf=None): + super(ExternalServiceFilter, self).__init__(conf=conf) + + def get_headers(self): + headers = {'Content-Type': 'application/json'} + + if self.external_service_token: + headers['X-Auth-Token'] = (self.external_service_token) + else: + client = BlazarKeystoneClient() + headers['X-Auth-Token'] = client.session.get_token() + + return headers + + def _get_absolute_url(self, path): + url = self.external_service_endpoint + + if url[-1] == '/': + url += path[1:] + else: + url += path + + return url + + def post(self, url, body): + body = json.dumps(body, cls=DateTimeEncoder) + req = requests.post(url, headers=self.get_headers(), data=body) + + if req.status_code == 204: + return True + elif req.status_code == 403: + try: + message = req.json()['message'] + except (requests.JSONDecodeError, KeyError): + raise ExternalServiceUnsupportedDeniedResponse( + response=req.content) + + raise ExternalServiceFilterException(message=message) + else: + raise ExternalServiceUnsupportedHTTPResponse( + status=req.status_code) + + def check_create(self, context, lease_values): + body = dict(context=context, lease=lease_values) + if self.external_service_check_create: + self.post(self.external_service_check_create, body) + return + + if self.external_service_endpoint: + path = '/check-create' + self.post(self._get_absolute_url(path), body) + return + + def check_update(self, context, current_lease_values, new_lease_values): + body = dict(context=context, current_lease=current_lease_values, + lease=new_lease_values) + if self.external_service_check_update: + self.post(self.external_service_check_update, body) + return + + if self.external_service_endpoint: + path = '/check-update' + self.post(self._get_absolute_url(path), body) + return + + def on_end(self, context, lease_values): + body = dict(context=context, lease=lease_values) + if self.external_service_on_end: + self.post(self.external_service_on_end, body) + return + + if self.external_service_endpoint: + path = '/on-end' + self.post(self._get_absolute_url(path), body) + return diff --git a/blazar/opts.py b/blazar/opts.py index 518481b1..82c09969 100644 --- a/blazar/opts.py +++ b/blazar/opts.py @@ -45,6 +45,8 @@ def list_opts(): ('manager', itertools.chain(blazar.manager.opts, blazar.manager.service.manager_opts)), ('enforcement', itertools.chain( + blazar.enforcement.filters.external_service_filter + .ExternalServiceFilter.enforcement_opts, blazar.enforcement.filters.max_lease_duration_filter.MaxLeaseDurationFilter.enforcement_opts, # noqa blazar.enforcement.enforcement.enforcement_opts)), ('notifications', blazar.notification.notifier.notification_opts), diff --git a/doc/source/admin/usage-enforcement.rst b/doc/source/admin/usage-enforcement.rst index f1018586..449decbd 100644 --- a/doc/source/admin/usage-enforcement.rst +++ b/doc/source/admin/usage-enforcement.rst @@ -6,16 +6,17 @@ Synopsis ======== Usage enforcement and lease constraints can be implemented by operators via -custom usage enforcement filters. +custom usage enforcement filters or an external service. Description =========== Usage enforcement filters are called on ``lease_create``, ``lease_update`` and ``on_end`` operations. The filters check whether or not lease values or -allocation criteria pass admin defined thresholds. There is currently one -filter provided out-of-the-box. The ``MaxLeaseDurationFilter`` restricts the -duration of leases. +allocation criteria pass admin defined thresholds. There are currently two +filters provided out-of-the-box. ``MaxLeaseDurationFilter`` restricts the +duration of leases. ``ExternalServiceFilter`` calls a third-party service for +implementing policies using a URL configured in ``blazar.conf``. Options ======= @@ -48,3 +49,84 @@ supports two configuration options: See the :doc:`../configuration/blazar-conf` page for a description of these options. + + +ExternalServiceFilter +--------------------- + +This filter delegates the decision for each API to an external HTTP service. +The service must use token-based authentication and implement the following +endpoints for POST method: + +* ``POST /v1/check-create`` +* ``POST /v1/check-update`` +* ``POST /v1/on-end`` + +The external service should return ``204 No Content`` if the parameters meet +defined criteria and ``403 Forbidden`` if not. + +Example format of data the external service will receive in a request body: + +* Request example: + +.. sourcecode:: json + + { + "context": { + "user_id": "c631173e-dec0-4bb7-a0c3-f7711153c06c", + "project_id": "a0b86a98-b0d3-43cb-948e-00689182efd4", + "auth_url": "https://api.example.com:5000/v3", + "region_name": "RegionOne" + }, + "current_lease": { + "start_date": "2020-05-13 00:00", + "end_time": "2020-05-14 23:59", + "reservations": [ + { + "resource_type": "physical:host", + "min": 1, + "max": 2, + "hypervisor_properties": "[]", + "resource_properties": "[\"==\", \"$availability_zone\", \"az1\"]", + "allocations": [ + { + "id": "1", + "hypervisor_hostname": "32af5a7a-e7a3-4883-a643-828e3f63bf54", + "extra": { + "availability_zone": "az1" + } + } + ] + } + ] + }, + "lease": { + "start_date": "2020-05-13 00:00", + "end_time": "2020-05-14 23:59", + "reservations": [ + { + "resource_type": "physical:host", + "min": 2, + "max": 3, + "hypervisor_properties": "[]", + "resource_properties": "[\"==\", \"$availability_zone\", \"az1\"]", + "allocations": [ + { + "id": "1", + "hypervisor_hostname": "32af5a7a-e7a3-4883-a643-828e3f63bf54", + "extra": { + "availability_zone": "az1" + } + }, + { + "id": "2", + "hypervisor_hostname": "af69aabd-8386-4053-a6dd-1a983787bd7f", + "extra": { + "availability_zone": "az1" + } + } + ] + } + ] + } + } diff --git a/releasenotes/notes/external-enforcement-filter-6e8fee75580fc169.yaml b/releasenotes/notes/external-enforcement-filter-6e8fee75580fc169.yaml new file mode 100644 index 00000000..0425a8f4 --- /dev/null +++ b/releasenotes/notes/external-enforcement-filter-6e8fee75580fc169.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + Add a usage enforcement filter delegating decisions to an external HTTP + service. This new filter is called ``ExternalServiceFilter``.