From 6d3f3605832fdfe7c9e66c1cd323a337b53a9cd7 Mon Sep 17 00:00:00 2001 From: Gordon Chung Date: Thu, 26 Jun 2014 18:16:41 -0400 Subject: [PATCH] Adding audit middleware to keystonemiddleware move audit middleware from pyCADF to keystonemiddleware Implements: blueprint audit-middleware Change-Id: I2069806cf7c1fd926e791809da011c244942a4ab --- keystonemiddleware/audit.py | 162 +++++++++++++++ .../tests/test_audit_middleware.py | 195 ++++++++++++++++++ requirements.txt | 1 + test-requirements.txt | 1 + 4 files changed, 359 insertions(+) create mode 100644 keystonemiddleware/audit.py create mode 100644 keystonemiddleware/tests/test_audit_middleware.py diff --git a/keystonemiddleware/audit.py b/keystonemiddleware/audit.py new file mode 100644 index 00000000..eb03175a --- /dev/null +++ b/keystonemiddleware/audit.py @@ -0,0 +1,162 @@ +# +# 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. + +""" +Build open standard audit information based on incoming requests + +AuditMiddleware filter should be placed after keystonemiddleware.auth_token +in the pipeline so that it can utilise the information the Identity server +provides. +""" + +import functools +import logging +import os.path +import sys + +from oslo.config import cfg +try: + import oslo.messaging + messaging = True +except ImportError: + messaging = False +import pycadf +from pycadf.audit import api +import webob.dec + +from keystonemiddleware.i18n import _LE, _LI +from keystonemiddleware.openstack.common import context + + +LOG = None + + +def log_and_ignore_error(fn): + @functools.wraps(fn) + def wrapper(*args, **kwargs): + try: + return fn(*args, **kwargs) + except Exception as e: + LOG.exception(_LE('An exception occurred processing ' + 'the API call: %s '), e) + return wrapper + + +class AuditMiddleware(object): + """Create an audit event based on request/response.""" + + @staticmethod + def _get_aliases(proj): + aliases = {} + if proj: + # Aliases to support backward compatibility + aliases = { + '%s.openstack.common.rpc.impl_kombu' % proj: 'rabbit', + '%s.openstack.common.rpc.impl_qpid' % proj: 'qpid', + '%s.openstack.common.rpc.impl_zmq' % proj: 'zmq', + '%s.rpc.impl_kombu' % proj: 'rabbit', + '%s.rpc.impl_qpid' % proj: 'qpid', + '%s.rpc.impl_zmq' % proj: 'zmq', + } + return aliases + + def __init__(self, app, **conf): + self.application = app + global LOG + LOG = logging.getLogger(conf.get('log_name', __name__)) + self.service_name = conf.get('service_name') + self.ignore_req_list = [x.upper().strip() for x in + conf.get('ignore_req_list', '').split(',')] + self.cadf_audit = api.OpenStackAuditApi( + conf.get('audit_map_file')) + + transport_aliases = AuditMiddleware._get_aliases(cfg.CONF.project) + if messaging: + self.notifier = oslo.messaging.Notifier( + oslo.messaging.get_transport(cfg.CONF, + aliases=transport_aliases), + os.path.basename(sys.argv[0])) + + def _emit_audit(self, context, event_type, payload): + """Emit audit notification + + if oslo.messaging enabled, send notification. if not, log event. + """ + + if messaging: + self.notifier.info(context, event_type, payload) + else: + LOG.info(_LI('Event type: %(event_type)s, Context: %(context)s, ' + 'Payload: %(payload)s'), {'context': context, + 'event_type': event_type, + 'payload': payload}) + + @log_and_ignore_error + def process_request(self, request): + correlation_id = pycadf.identifier.generate_uuid() + self.event = self.cadf_audit.create_event(request, correlation_id) + + self._emit_audit(context.get_admin_context().to_dict(), + 'audit.http.request', self.event.as_dict()) + + @log_and_ignore_error + def process_response(self, request, response=None): + if not hasattr(self, 'event'): + # NOTE(gordc): handle case where error processing request + correlation_id = pycadf.identifier.generate_uuid() + self.event = self.cadf_audit.create_event(request, correlation_id) + + if response: + if response.status_int >= 200 and response.status_int < 400: + result = pycadf.cadftaxonomy.OUTCOME_SUCCESS + else: + result = pycadf.cadftaxonomy.OUTCOME_FAILURE + self.event.reason = pycadf.reason.Reason( + reasonType='HTTP', reasonCode=str(response.status_int)) + else: + result = pycadf.cadftaxonomy.UNKNOWN + + self.event.outcome = result + self.event.add_reporterstep( + pycadf.reporterstep.Reporterstep( + role=pycadf.cadftype.REPORTER_ROLE_MODIFIER, + reporter=pycadf.resource.Resource(id='target'), + reporterTime=pycadf.timestamp.get_utc_now())) + + self._emit_audit(context.get_admin_context().to_dict(), + 'audit.http.response', self.event.as_dict()) + + @webob.dec.wsgify + def __call__(self, req): + if req.method in self.ignore_req_list: + return req.get_response(self.application) + + self.process_request(req) + try: + response = req.get_response(self.application) + except Exception: + self.process_response(req) + raise + else: + self.process_response(req, response) + return response + + +def filter_factory(global_conf, **local_conf): + """Returns a WSGI filter app for use with paste.deploy.""" + conf = global_conf.copy() + conf.update(local_conf) + + def audit_filter(app): + return AuditMiddleware(app, **conf) + return audit_filter diff --git a/keystonemiddleware/tests/test_audit_middleware.py b/keystonemiddleware/tests/test_audit_middleware.py new file mode 100644 index 00000000..651a7d69 --- /dev/null +++ b/keystonemiddleware/tests/test_audit_middleware.py @@ -0,0 +1,195 @@ +# +# 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 os +import tempfile + +import mock +from oslo.config import cfg +import testtools +from testtools import matchers +import webob + +from keystonemiddleware import audit + + +class FakeApp(object): + def __call__(self, env, start_response): + body = 'Some response' + start_response('200 OK', [ + ('Content-Type', 'text/plain'), + ('Content-Length', str(sum(map(len, body)))) + ]) + return [body] + + +class FakeFailingApp(object): + def __call__(self, env, start_response): + raise Exception('It happens!') + + +@mock.patch('oslo.messaging.get_transport', mock.MagicMock()) +class AuditMiddlewareTest(testtools.TestCase): + + def setUp(self): + super(AuditMiddlewareTest, self).setUp() + (self.fd, self.audit_map) = tempfile.mkstemp() + cfg.CONF([], project='keystonemiddleware') + + self.addCleanup(lambda: os.close(self.fd)) + self.addCleanup(cfg.CONF.reset) + + @staticmethod + def _get_environ_header(req_type): + env_headers = {'HTTP_X_SERVICE_CATALOG': + '''[{"endpoints_links": [], + "endpoints": [{"adminURL": + "http://host:8774/v2/admin", + "region": "RegionOne", + "publicURL": + "http://host:8774/v2/public", + "internalURL": + "http://host:8774/v2/internal", + "id": "resource_id"}], + "type": "compute", + "name": "nova"},]''', + 'HTTP_X_USER_ID': 'user_id', + 'HTTP_X_USER_NAME': 'user_name', + 'HTTP_X_AUTH_TOKEN': 'token', + 'HTTP_X_PROJECT_ID': 'tenant_id', + 'HTTP_X_IDENTITY_STATUS': 'Confirmed'} + env_headers['REQUEST_METHOD'] = req_type + return env_headers + + def test_api_request(self): + middleware = audit.AuditMiddleware( + FakeApp(), + audit_map_file=self.audit_map, + service_name='pycadf') + req = webob.Request.blank('/foo/bar', + environ=self._get_environ_header('GET')) + with mock.patch('oslo.messaging.Notifier.info') as notify: + middleware(req) + # Check first notification with only 'request' + call_args = notify.call_args_list[0][0] + self.assertEqual('audit.http.request', call_args[1]) + self.assertEqual('/foo/bar', call_args[2]['requestPath']) + self.assertEqual('pending', call_args[2]['outcome']) + self.assertNotIn('reason', call_args[2]) + self.assertNotIn('reporterchain', call_args[2]) + + # Check second notification with request + response + call_args = notify.call_args_list[1][0] + self.assertEqual('audit.http.response', call_args[1]) + self.assertEqual('/foo/bar', call_args[2]['requestPath']) + self.assertEqual('success', call_args[2]['outcome']) + self.assertIn('reason', call_args[2]) + self.assertIn('reporterchain', call_args[2]) + + def test_api_request_failure(self): + middleware = audit.AuditMiddleware( + FakeFailingApp(), + audit_map_file=self.audit_map, + service_name='pycadf') + req = webob.Request.blank('/foo/bar', + environ=self._get_environ_header('GET')) + with mock.patch('oslo.messaging.Notifier.info') as notify: + try: + middleware(req) + self.fail('Application exception has not been re-raised') + except Exception: + pass + # Check first notification with only 'request' + call_args = notify.call_args_list[0][0] + self.assertEqual('audit.http.request', call_args[1]) + self.assertEqual('/foo/bar', call_args[2]['requestPath']) + self.assertEqual('pending', call_args[2]['outcome']) + self.assertNotIn('reporterchain', call_args[2]) + + # Check second notification with request + response + call_args = notify.call_args_list[1][0] + self.assertEqual('audit.http.response', call_args[1]) + self.assertEqual('/foo/bar', call_args[2]['requestPath']) + self.assertEqual('unknown', call_args[2]['outcome']) + self.assertIn('reporterchain', call_args[2]) + + def test_process_request_fail(self): + middleware = audit.AuditMiddleware( + FakeApp(), + audit_map_file=self.audit_map, + service_name='pycadf') + req = webob.Request.blank('/foo/bar', + environ=self._get_environ_header('GET')) + with mock.patch('oslo.messaging.Notifier.info', + side_effect=Exception('error')) as notify: + middleware.process_request(req) + self.assertTrue(notify.called) + + def test_process_response_fail(self): + middleware = audit.AuditMiddleware( + FakeApp(), + audit_map_file=self.audit_map, + service_name='pycadf') + req = webob.Request.blank('/foo/bar', + environ=self._get_environ_header('GET')) + with mock.patch('oslo.messaging.Notifier.info', + side_effect=Exception('error')) as notify: + middleware.process_response(req, webob.response.Response()) + self.assertTrue(notify.called) + + def test_ignore_req_opt(self): + middleware = audit.AuditMiddleware(FakeApp(), + audit_map_file=self.audit_map, + ignore_req_list='get, PUT') + req = webob.Request.blank('/skip/foo', + environ=self._get_environ_header('GET')) + req1 = webob.Request.blank('/skip/foo', + environ=self._get_environ_header('PUT')) + req2 = webob.Request.blank('/accept/foo', + environ=self._get_environ_header('POST')) + with mock.patch('oslo.messaging.Notifier.info') as notify: + # Check GET/PUT request does not send notification + middleware(req) + middleware(req1) + self.assertEqual([], notify.call_args_list) + + # Check non-GET/PUT request does send notification + middleware(req2) + self.assertThat(notify.call_args_list, matchers.HasLength(2)) + call_args = notify.call_args_list[0][0] + self.assertEqual('audit.http.request', call_args[1]) + self.assertEqual('/accept/foo', call_args[2]['requestPath']) + + call_args = notify.call_args_list[1][0] + self.assertEqual('audit.http.response', call_args[1]) + self.assertEqual('/accept/foo', call_args[2]['requestPath']) + + def test_api_request_no_messaging(self): + middleware = audit.AuditMiddleware( + FakeApp(), + audit_map_file=self.audit_map, + service_name='pycadf') + req = webob.Request.blank('/foo/bar', + environ=self._get_environ_header('GET')) + with mock.patch('keystonemiddleware.audit.messaging', None): + with mock.patch('keystonemiddleware.audit.LOG.info') as log: + middleware(req) + # Check first notification with only 'request' + call_args = log.call_args_list[0][0] + self.assertEqual('audit.http.request', + call_args[1]['event_type']) + + # Check second notification with request + response + call_args = log.call_args_list[1][0] + self.assertEqual('audit.http.response', + call_args[1]['event_type']) diff --git a/requirements.txt b/requirements.txt index 16a1c410..a19244bc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,7 @@ oslo.i18n>=1.0.0 # Apache-2.0 oslo.serialization>=1.0.0 # Apache-2.0 oslo.utils>=1.0.0 # Apache-2.0 pbr>=0.6,!=0.7,<1.0 +pycadf>=0.6.0 python-keystoneclient>=0.11.1 requests>=2.2.0,!=2.4.0 six>=1.7.0 diff --git a/test-requirements.txt b/test-requirements.txt index d17bdbce..7f7baf91 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -10,6 +10,7 @@ mock>=1.0 pycrypto>=2.6 oslosphinx>=2.2.0 # Apache-2.0 oslotest>=1.2.0 # Apache-2.0 +oslo.messaging>=1.4.0 requests-mock>=0.5.1 # Apache-2.0 sphinx>=1.1.2,!=1.2.0,!=1.3b1,<1.3 testrepository>=0.0.18