diff options
-rw-r--r-- | etc/pycadf/api_audit_map.conf | 55 | ||||
-rw-r--r-- | pycadf/audit/__init__.py | 28 | ||||
-rw-r--r-- | pycadf/audit/api.py | 224 | ||||
-rw-r--r-- | pycadf/tests/audit/__init__.py | 0 | ||||
-rw-r--r-- | pycadf/tests/audit/test_api.py | 168 | ||||
-rw-r--r-- | pycadf/tests/base.py | 43 | ||||
-rw-r--r-- | test-requirements.txt | 1 |
7 files changed, 519 insertions, 0 deletions
diff --git a/etc/pycadf/api_audit_map.conf b/etc/pycadf/api_audit_map.conf new file mode 100644 index 0000000..a27e67a --- /dev/null +++ b/etc/pycadf/api_audit_map.conf @@ -0,0 +1,55 @@ +[DEFAULT] +api_paths = + add + entries + extensions + limits + servers + metadata + ips + images + flavors + os-agents + os-aggregates + os-cloudpipe + diagnostics + os-fixed-ips + os-extra_specs + os-flavor-access + os-floating-ip-dns + os-floating-ips-bulk + os-floating-ips + os-hosts + os-hypervisors + os-instance-actions + os-keypairs + os-networks + os-quota-sets + os-security-groups + os-security-group-rules + os-server-password + os-services + os-simple-tenant-usage + os-virtual-interfaces + os-volume_attachments + os-volumes + os-volume-types + os-snapshots + +[body_actions] +changePassword = update +reboot = update +rebuild = update +resize = update +confirmResize = read +revertResize = update +createImage = create + +[service_endpoints] +identity = service/security +object-store = service/storage/object +volume = service/storage/block +image = service/storage/image +network = service/network +compute = service/compute +metering = service/bss/metering
\ No newline at end of file diff --git a/pycadf/audit/__init__.py b/pycadf/audit/__init__.py new file mode 100644 index 0000000..3a143f2 --- /dev/null +++ b/pycadf/audit/__init__.py @@ -0,0 +1,28 @@ +# -*- encoding: utf-8 -*- +# +# Copyright 2013 IBM Corp. +# +# +# 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. + +from oslo.config import cfg + +CONF = cfg.CONF + +opts = [ + cfg.StrOpt('api_audit_map', + default='/etc/pycadf/api_audit_map.conf', + help='File containing mapping for api paths and ' + 'service endpoints'), +] +CONF.register_opts(opts, group='audit') diff --git a/pycadf/audit/api.py b/pycadf/audit/api.py new file mode 100644 index 0000000..3f252e4 --- /dev/null +++ b/pycadf/audit/api.py @@ -0,0 +1,224 @@ +# -*- encoding: utf-8 -*- +# +# Copyright 2013 IBM Corp. +# +# +# 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 ast +import ConfigParser +import os +from oslo.config import cfg +import urlparse + +from pycadf import cadftaxonomy as taxonomy +from pycadf import cadftype +from pycadf import eventfactory as factory +from pycadf.openstack.common import log as logging +from pycadf import reason +from pycadf import reporterstep +from pycadf import resource +from pycadf import tag +from pycadf import timestamp + +cfg.CONF.import_opt('api_audit_map', 'pycadf.audit', group='audit') + +LOG = logging.getLogger(__name__) +CONF = cfg.CONF + + +class ServiceResource(resource.Resource): + def __init__(self, admin_url=None, private_url=None, + public_url=None, **kwargs): + super(ServiceResource, self).__init__(**kwargs) + if admin_url is not None: + self.adminURL = admin_url + if private_url is not None: + self.privateURL = private_url + if public_url is not None: + self.publicURL = public_url + + +class ClientResource(resource.Resource): + def __init__(self, client_addr=None, user_agent=None, + token=None, tenant=None, status=None, **kwargs): + super(ClientResource, self).__init__(**kwargs) + if client_addr is not None: + self.client_addr = client_addr + if user_agent is not None: + self.user_agent = user_agent + if token is not None: + self.token = token + if tenant is not None: + self.tenant = tenant + if status is not None: + self.status = status + + +class OpenStackAuditApi(object): + + _API_PATHS = [] + _BODY_ACTIONS = {} + _SERVICE_ENDPOINTS = {} + + def __init__(self): + self._configure_audit_map() + + def _configure_audit_map(self): + """Configure to recognize and map known api paths.""" + + cfg_file = CONF.audit.api_audit_map + if not os.path.exists(CONF.audit.api_audit_map): + cfg_file = cfg.CONF.find_file(CONF.audit.api_audit_map) + LOG.debug("API path config file: %s", cfg_file) + + if cfg_file: + try: + audit_map = ConfigParser.SafeConfigParser() + audit_map.readfp(open(cfg_file)) + + try: + paths = audit_map.get('DEFAULT', 'api_paths') + self._API_PATHS = paths.lstrip().split('\n') + except ConfigParser.NoSectionError: + pass + + try: + self._BODY_ACTIONS = dict(audit_map.items('body_actions')) + except ConfigParser.NoSectionError: + pass + + try: + self._SERVICE_ENDPOINTS = \ + dict(audit_map.items('service_endpoints')) + except ConfigParser.NoSectionError: + pass + except ConfigParser.ParsingError as err: + LOG.error('Error parsing audit map file: %s' % err) + + def _get_action(self, req): + """Take a given Request, parse url path to calculate action type. + + Depending on req.method: + if POST: path ends with action, read the body and get action from map; + request ends with known path, assume is create action; + request ends with unknown path, assume is update action. + if GET: request ends with known path, assume is list action; + request ends with unknown path, assume is read action. + if PUT, assume update action. + if DELETE, assume delete action. + if HEAD, assume read action. + + """ + path = urlparse.urlparse(req.url).path + path = path[:-1] if path.endswith('/') else path + + method = req.method + if method == 'POST': + if path[path.rfind('/') + 1:] == 'action': + if req.json: + body_action = req.json.keys()[0] + action = self._BODY_ACTIONS.get(body_action, + taxonomy.ACTION_CREATE) + else: + action = taxonomy.ACTION_CREATE + elif path[path.rfind('/') + 1:] not in self._API_PATHS: + action = taxonomy.ACTION_UPDATE + else: + action = taxonomy.ACTION_CREATE + elif method == 'GET': + if path[path.rfind('/') + 1:] in self._API_PATHS: + action = taxonomy.ACTION_LIST + else: + action = taxonomy.ACTION_READ + elif method == 'PUT': + action = taxonomy.ACTION_UPDATE + elif method == 'DELETE': + action = taxonomy.ACTION_DELETE + elif method == 'HEAD': + action = taxonomy.ACTION_READ + else: + action = taxonomy.UNKNOWN + + return action + + def gen_event(self, req, correlation_id): + action = self._get_action(req) + catalog = ast.literal_eval(req.environ['HTTP_X_SERVICE_CATALOG']) + for endpoint in catalog: + admin_urlparse = urlparse.urlparse( + endpoint['endpoints'][0]['adminURL']) + public_urlparse = urlparse.urlparse( + endpoint['endpoints'][0]['publicURL']) + req_url = urlparse.urlparse(req.host_url) + if (req_url.netloc == admin_urlparse.netloc + or req_url.netloc == public_urlparse.netloc): + service_type = self._SERVICE_ENDPOINTS.get(endpoint['type'], + taxonomy.UNKNOWN) + service_name = endpoint['name'] + admin_url = endpoint['endpoints'][0]['adminURL'] + private_url = endpoint['endpoints'][0]['internalURL'] + public_url = endpoint['endpoints'][0]['publicURL'] + service_id = endpoint['endpoints'][0]['id'] + break + else: + service_type = service_id = service_name = taxonomy.UNKNOWN + admin_url = private_url = public_url = None + + event = factory.EventFactory().new_event( + eventType=cadftype.EVENTTYPE_ACTIVITY, + outcome=taxonomy.OUTCOME_PENDING, + action=action, + initiator=ClientResource( + typeURI=taxonomy.ACCOUNT_USER, + id=str(req.environ['HTTP_X_USER_ID']), + name=req.environ['HTTP_X_USER_NAME'], + client_addr=req.client_addr, + user_agent=req.user_agent, + token=req.environ['HTTP_X_AUTH_TOKEN'], + tenant=req.environ['HTTP_X_PROJECT_ID'], + status=req.environ['HTTP_X_IDENTITY_STATUS']), + target=ServiceResource(typeURI=service_type, + id=service_id, + name=service_name, + private_url=private_url, + public_url=public_url, + admin_url=admin_url)) + event.add_tag(tag.generate_name_value_tag('correlation_id', + correlation_id)) + return event + + def append_audit_event(self, msg, req, correlation_id): + setattr(req, 'CADF_EVENT_CORRELATION_ID', correlation_id) + event = self.gen_event(req, correlation_id) + event.add_reporterstep( + reporterstep.Reporterstep( + role=cadftype.REPORTER_ROLE_OBSERVER, + reporter='target')) + msg['cadf_event'] = event + + def mod_audit_event(self, msg, response, correlation_id): + if response.status_int >= 200 and response.status_int < 400: + result = taxonomy.OUTCOME_SUCCESS + else: + result = taxonomy.OUTCOME_FAILURE + if 'cadf_event' in msg: + msg['cadf_event'].outcome = result + msg['cadf_event'].reason = \ + reason.Reason(reasonType='HTTP', + reasonCode=str(response.status_int)) + msg['cadf_event'].add_reporterstep( + reporterstep.Reporterstep( + role=cadftype.REPORTER_ROLE_MODIFIER, + reporter='target', + reporterTime=timestamp.get_utc_now())) diff --git a/pycadf/tests/audit/__init__.py b/pycadf/tests/audit/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/pycadf/tests/audit/__init__.py diff --git a/pycadf/tests/audit/test_api.py b/pycadf/tests/audit/test_api.py new file mode 100644 index 0000000..0bf7bd2 --- /dev/null +++ b/pycadf/tests/audit/test_api.py @@ -0,0 +1,168 @@ +# -*- encoding: utf-8 -*- +# +# Copyright 2013 IBM Corp. +# +# +# 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. +from oslo.config import cfg +import uuid +import webob + +from pycadf.audit import api +from pycadf import identifier +from pycadf.tests import base + + +class TestAuditApi(base.TestCase): + 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'} + + def setUp(self): + super(TestAuditApi, self).setUp() + # set nova CONF.host value + # Set a default location for the api_audit_map config file + cfg.CONF.set_override( + 'api_audit_map', + self.path_get('etc/pycadf/api_audit_map.conf'), + group='audit' + ) + self.audit_api = api.OpenStackAuditApi() + + def api_request(self, method, url): + self.ENV_HEADERS['REQUEST_METHOD'] = method + req = webob.Request.blank(url, environ=self.ENV_HEADERS) + msg = {} + self.audit_api.append_audit_event(msg, req, + identifier.generate_uuid()) + return msg + + def test_get_list(self): + msg = self.api_request('GET', 'http://host:8774/v2/public/servers') + payload = msg['cadf_event'].as_dict() + self.assertEqual(payload['action'], 'list') + self.assertEqual(payload['typeURI'], + 'http://schemas.dmtf.org/cloud/audit/1.0/event') + self.assertEqual(payload['outcome'], 'pending') + self.assertEqual(payload['eventType'], 'activity') + self.assertEqual(payload['target']['publicURL'], + 'http://host:8774/v2/public') + self.assertEqual(payload['target']['privateURL'], + 'http://host:8774/v2/internal') + self.assertEqual(payload['target']['adminURL'], + 'http://host:8774/v2/admin') + self.assertEqual(payload['target']['name'], 'nova') + self.assertEqual(payload['target']['id'], 'resource_id') + self.assertEqual(payload['target']['typeURI'], 'service/compute') + self.assertEqual(payload['initiator']['id'], 'user_id') + self.assertEqual(payload['initiator']['name'], 'user_name') + self.assertEqual(payload['initiator']['token'], 'token') + self.assertEqual(payload['initiator']['tenant'], 'tenant_id') + self.assertEqual(payload['initiator']['typeURI'], + 'service/security/account/user') + self.assertNotIn('reason', payload) + self.assertEqual(len(payload['reporterchain']), 1) + self.assertEqual(payload['reporterchain'][0]['role'], 'observer') + self.assertEqual(payload['reporterchain'][0]['reporter'], 'target') + + def test_get_read(self): + msg = self.api_request('GET', + 'http://host:8774/v2/public/servers/' + + str(uuid.uuid4())) + payload = msg['cadf_event'].as_dict() + self.assertEqual(payload['action'], 'read') + self.assertEqual(payload['outcome'], 'pending') + + def test_get_unknown_endpoint(self): + msg = self.api_request('GET', + 'http://unknown:8774/v2/public/servers/') + payload = msg['cadf_event'].as_dict() + self.assertEqual(payload['action'], 'list') + self.assertEqual(payload['outcome'], 'pending') + self.assertEqual(payload['target']['name'], 'unknown') + self.assertEqual(payload['target']['id'], 'unknown') + self.assertEqual(payload['target']['typeURI'], 'unknown') + + def test_put(self): + msg = self.api_request('PUT', 'http://host:8774/v2/public/servers') + payload = msg['cadf_event'].as_dict() + self.assertEqual(payload['action'], 'update') + self.assertEqual(payload['outcome'], 'pending') + + def test_delete(self): + msg = self.api_request('DELETE', 'http://host:8774/v2/public/servers') + payload = msg['cadf_event'].as_dict() + self.assertEqual(payload['action'], 'delete') + self.assertEqual(payload['outcome'], 'pending') + + def test_head(self): + msg = self.api_request('HEAD', 'http://host:8774/v2/public/servers') + payload = msg['cadf_event'].as_dict() + self.assertEqual(payload['action'], 'read') + self.assertEqual(payload['outcome'], 'pending') + + def test_post_update(self): + msg = self.api_request('POST', + 'http://host:8774/v2/public/servers/' + + str(uuid.uuid4())) + payload = msg['cadf_event'].as_dict() + self.assertEqual(payload['action'], 'update') + self.assertEqual(payload['outcome'], 'pending') + + def test_post_create(self): + msg = self.api_request('POST', 'http://host:8774/v2/public/servers') + payload = msg['cadf_event'].as_dict() + self.assertEqual(payload['action'], 'create') + self.assertEqual(payload['outcome'], 'pending') + + def test_post_action(self): + self.ENV_HEADERS['REQUEST_METHOD'] = 'POST' + req = webob.Request.blank('http://host:8774/v2/public/servers/action', + environ=self.ENV_HEADERS) + req.body = '{"createImage" : {"name" : "new-image","metadata": ' \ + '{"ImageType": "Gold","ImageVersion": "2.0"}}}' + msg = {} + self.audit_api.append_audit_event(msg, req, + identifier.generate_uuid()) + payload = msg['cadf_event'].as_dict() + self.assertEqual(payload['action'], 'create') + self.assertEqual(payload['outcome'], 'pending') + + def test_response_mod_msg(self): + msg = self.api_request('GET', 'http://host:8774/v2/public/servers') + payload = msg['cadf_event'].as_dict() + self.audit_api.mod_audit_event(msg, webob.Response(), + identifier.generate_uuid()) + payload2 = msg['cadf_event'].as_dict() + self.assertEqual(payload['id'], payload2['id']) + self.assertEqual(payload['tags'], payload2['tags']) + self.assertEqual(payload2['outcome'], 'success') + self.assertEqual(payload2['reason']['reasonType'], 'HTTP') + self.assertEqual(payload2['reason']['reasonCode'], '200') + self.assertEqual(len(payload2['reporterchain']), 2) + self.assertEqual(payload2['reporterchain'][1]['role'], 'modifier') + self.assertEqual(payload2['reporterchain'][1]['reporter'], 'target') diff --git a/pycadf/tests/base.py b/pycadf/tests/base.py new file mode 100644 index 0000000..729886a --- /dev/null +++ b/pycadf/tests/base.py @@ -0,0 +1,43 @@ +# -*- encoding: utf-8 -*- +# +# Copyright 2013 IBM Corp. +# +# 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. + +"""Test base classes. +""" +import os.path +from oslo.config import cfg +import testtools + + +class TestCase(testtools.TestCase): + + def setUp(self): + super(TestCase, self).setUp() + cfg.CONF([], project='pycadf') + + def path_get(self, project_file=None): + root = os.path.abspath(os.path.join(os.path.dirname(__file__), + '..', + '..', + ) + ) + if project_file: + return os.path.join(root, project_file) + else: + return root + + def tearDown(self): + cfg.CONF.reset() + super(TestCase, self).tearDown() diff --git a/test-requirements.txt b/test-requirements.txt index cdd85f5..e4e171b 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -10,6 +10,7 @@ python-subunit testrepository>=0.0.17 testscenarios<0.5 testtools>=0.9.29 +WebOb>=1.2.3,<1.3 # when we can require tox>= 1.4, this can go into tox.ini: # [testenv:cover] |