Skip to content

Commit

Permalink
mgr/dashboard: Audit REST API calls
Browse files Browse the repository at this point in the history
Fixes: https://tracker.ceph.com/issues/36193

Enable API auditing with 'ceph dashboard set-audit-api-enabled true' (default is false). If you do not want to log the request payload, then disable it via 'set-audit-api-log-payload false' (default is true).

Example output:
2018-10-08 10:25:21.850994 mgr.x [INF] [AUDIT] from='https://[::1]:44410' path='/api/auth' method='POST' user='None' params='{"username": "admin", "password": "admin", "stay_signed_in": false}'

Signed-off-by: Volker Theile <vtheile@suse.com>
  • Loading branch information
votdev committed Oct 8, 2018
1 parent 9d6b323 commit c19c35c
Show file tree
Hide file tree
Showing 3 changed files with 119 additions and 10 deletions.
32 changes: 22 additions & 10 deletions src/pybind/mgr/dashboard/controllers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@
# pylint: disable=wrong-import-position
import cherrypy

from .. import logger
from .. import logger, mgr
from ..security import Scope, Permission
from ..settings import Settings
from ..tools import Session, wraps, getargspec, TaskManager
from ..tools import Session, wraps, getargspec, TaskManager, build_url
from ..exceptions import ViewCacheNoDataException, DashboardException, \
ScopeNotValid, PermissionNotValid
from ..services.exception import serialize_dashboard_exception
Expand Down Expand Up @@ -532,27 +532,39 @@ def inner(*args, **kwargs):
or isinstance(value, str):
kwargs[key] = unquote(value)

# Process method arguments.
if method in ['GET', 'DELETE']:
ret = func(*args, **kwargs)

pass
elif cherrypy.request.headers.get('Content-Type', '') == \
'application/x-www-form-urlencoded':
ret = func(*args, **kwargs)

pass
else:
content_length = int(cherrypy.request.headers['Content-Length'])
body = cherrypy.request.body.read(content_length)
if not body:
ret = func(*args, **kwargs)
else:
if body:
try:
data = json.loads(body.decode('utf-8'))
except Exception as e:
raise cherrypy.HTTPError(400, 'Failed to decode JSON: {}'
.format(str(e)))
kwargs.update(data.items())
ret = func(*args, **kwargs)

# Audit API request.
if Settings.AUDIT_API_ENABLED and method not in ['GET']:
req = cherrypy.request
url = build_url(req.remote.ip, scheme=req.scheme,
port=req.remote.port)
user = None
if hasattr(cherrypy.serving, 'session'):
user = cherrypy.session.get(Session.USERNAME)
msg = '[AUDIT] from=\'{}\' path=\'{}\' method=\'{}\' user=\'{}\''\
.format(url, req.path_info, method, user)
if Settings.AUDIT_API_LOG_PAYLOAD:
params = json.dumps(kwargs)
msg = '{} params=\'{}\''.format(msg, params)
mgr.cluster_log('audit', mgr.CLUSTER_LOG_PRIO_INFO, msg)

ret = func(*args, **kwargs)
if isinstance(ret, bytes):
ret = ret.decode('utf-8')
if json_response:
Expand Down
4 changes: 4 additions & 0 deletions src/pybind/mgr/dashboard/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ class Options(object):
ENABLE_BROWSABLE_API = (True, bool)
REST_REQUESTS_TIMEOUT = (45, int)

# API auditing
AUDIT_API_ENABLED = (False, bool)
AUDIT_API_LOG_PAYLOAD = (True, bool)

# RGW settings
RGW_API_HOST = ('', str)
RGW_API_PORT = (80, int)
Expand Down
93 changes: 93 additions & 0 deletions src/pybind/mgr/dashboard/tests/test_api_auditing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# -*- coding: utf-8 -*-
# pylint: disable=dangerous-default-value,too-many-public-methods
from __future__ import absolute_import

import json
import re
import mock

from .helper import ControllerTestCase
from ..controllers import RESTController, Controller
from .. import mgr


@Controller('/foo', secure=False)
class Foo(RESTController):
def create(self, data):
pass

def get(self, key):
pass

def delete(self, key):
pass

def set(self, key, x):
pass


class ApiAuditingTest(ControllerTestCase):
settings = {}

@classmethod
def mock_set_config(cls, key, val):
cls.settings[key] = val

@classmethod
def mock_get_config(cls, key, default):
return cls.settings.get(key, default)

@classmethod
def setUpClass(cls):
mgr.get_config.side_effect = cls.mock_get_config
mgr.set_config.side_effect = cls.mock_set_config

@classmethod
def setup_server(cls):
cls.setup_controllers([Foo])

def setUp(self):
mgr.cluster_log = mock.Mock()
mgr.set_config('AUDIT_API_ENABLED', True)
mgr.set_config('AUDIT_API_LOG_PAYLOAD', True)

def test_audit(self):
self._put('/foo/test1', {'x': 0})
mgr.cluster_log.assert_called_once()
_, _, msg = mgr.cluster_log.call_args_list[0][0]
pattern = r'^\[AUDIT\] from=\'(.+)\' path=\'(.+)\' ' \
'method=\'(.+)\' user=\'(.+)\' params=\'(.+)\'$'
m = re.match(pattern, msg)
self.assertEqual(m.group(2), '/foo/test1')
self.assertEqual(m.group(3), 'PUT')
self.assertEqual(m.group(4), 'None')
params = json.loads(m.group(5))
self.assertEqual(params['key'], 'test1')
self.assertEqual(params['x'], 0)

def test_no_audit(self):
mgr.set_config('AUDIT_API_ENABLED', False)
self._delete('/foo/test1')
mgr.cluster_log.assert_not_called()

def test_no_payload(self):
mgr.set_config('AUDIT_API_LOG_PAYLOAD', False)
self._delete('/foo/test1')
_, _, msg = mgr.cluster_log.call_args_list[0][0]
self.assertNotIn('params=', msg)

def test_no_audit_get(self):
self._get('/foo/test1')
mgr.cluster_log.assert_not_called()

def test_audit_put(self):
self._put('/foo/test1', {'x': 0})
mgr.cluster_log.assert_called_once()

def test_audit_post(self):
self._post('/foo', {'data': {'id': 'test1', 'x': 0}})
mgr.cluster_log.assert_called_once()

def test_audit_delete(self):
self._delete('/foo/test1')
mgr.cluster_log.assert_called_once()

0 comments on commit c19c35c

Please sign in to comment.