diff --git a/doc/source/misc.rst b/doc/source/misc.rst index 9640c36754..4460d31adb 100644 --- a/doc/source/misc.rst +++ b/doc/source/misc.rst @@ -203,10 +203,16 @@ Static Large Objects :members: :show-inheritance: - List Endpoints ============== .. automodule:: swift.common.middleware.list_endpoints :members: :show-inheritance: + +Account Quotas +================ + +.. automodule:: swift.common.middleware.account_quotas + :members: + :show-inheritance: diff --git a/etc/proxy-server.conf-sample b/etc/proxy-server.conf-sample index 7deddad0b4..c05a345c88 100644 --- a/etc/proxy-server.conf-sample +++ b/etc/proxy-server.conf-sample @@ -34,7 +34,7 @@ # eventlet_debug = false [pipeline:main] -pipeline = catch_errors healthcheck proxy-logging cache slo ratelimit tempauth container-quotas proxy-logging proxy-server +pipeline = catch_errors healthcheck proxy-logging cache slo ratelimit tempauth container-quotas account-quotas proxy-logging proxy-server [app:proxy-server] use = egg:swift#proxy @@ -363,3 +363,6 @@ use = egg:swift#slo # max_manifest_segments = 1000 # max_manifest_size = 2097152 # min_segment_size = 1048576 + +[filter:account-quotas] +use = egg:swift#account_quotas diff --git a/setup.py b/setup.py index 6b4230a18d..0702feb4b4 100644 --- a/setup.py +++ b/setup.py @@ -104,6 +104,8 @@ setup( 'bulk=swift.common.middleware.bulk:filter_factory', 'container_quotas=swift.common.middleware.container_quotas:' 'filter_factory', + 'account_quotas=swift.common.middleware.account_quotas:' + 'filter_factory', 'proxy_logging=swift.common.middleware.proxy_logging:' 'filter_factory', 'slo=swift.common.middleware.slo:filter_factory', diff --git a/swift/common/middleware/account_quotas.py b/swift/common/middleware/account_quotas.py new file mode 100644 index 0000000000..211de0bc5a --- /dev/null +++ b/swift/common/middleware/account_quotas.py @@ -0,0 +1,83 @@ +# Copyright (c) 2013 OpenStack Foundation. +# +# 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. + +""" Account quota middleware for Openstack Swift Proxy """ + +from swift.common.swob import HTTPForbidden, HTTPRequestEntityTooLarge, \ + HTTPBadRequest, wsgify + +from swift.proxy.controllers.base import get_account_info + + +class AccountQuotaMiddleware(object): + """ + account_quotas is a middleware which blocks write requests (PUT, POST) if a + given quota (in bytes) is exceeded while DELETE requests are still allowed. + + account_quotas uses the x-account-meta-quota-bytes metadata to store the + quota. Write requests to this metadata setting are only allowed for + resellers. There is no quota limit if x-account-meta-quota-bytes is not + set. + + The following shows an example proxy-server.conf: + + [pipeline:main] + pipeline = catch_errors cache tempauth account-quotas proxy-server + + [filter:account-quotas] + use = egg:swift#account_quotas + + """ + + def __init__(self, app, *args, **kwargs): + self.app = app + + @wsgify + def __call__(self, request): + + if request.method not in ("POST", "PUT"): + return self.app + + try: + request.split_path(2, 4, rest_with_last=True) + except ValueError: + return self.app + + new_quota = request.headers.get('X-Account-Meta-Quota-Bytes') + + if request.environ.get('reseller_request') is True: + if new_quota and not new_quota.isdigit(): + return HTTPBadRequest() + return self.app + + # deny quota set for non-reseller + if new_quota is not None: + return HTTPForbidden() + + account_info = get_account_info(request.environ, self.app) + new_size = int(account_info['bytes']) + (request.content_length or 0) + quota = int(account_info['meta'].get('quota-bytes', -1)) + + if 0 <= quota < new_size: + return HTTPRequestEntityTooLarge() + + return self.app + + +def filter_factory(global_conf, **local_conf): + """Returns a WSGI filter app for use with paste.deploy.""" + def account_quota_filter(app): + return AccountQuotaMiddleware(app) + return account_quota_filter diff --git a/swift/common/middleware/keystoneauth.py b/swift/common/middleware/keystoneauth.py index cc3c3ef7c7..8c0b8bfb31 100644 --- a/swift/common/middleware/keystoneauth.py +++ b/swift/common/middleware/keystoneauth.py @@ -106,6 +106,8 @@ class KeystoneAuth(object): environ['keystone.identity'] = identity environ['REMOTE_USER'] = identity.get('tenant') environ['swift.authorize'] = self.authorize + if self.reseller_admin_role in identity.get('roles', []): + environ['reseller_request'] = True else: self.logger.debug('Authorizing as anonymous') environ['swift.authorize'] = self.authorize_anonymous diff --git a/swift/common/middleware/tempauth.py b/swift/common/middleware/tempauth.py index 61d7ce9f8e..4f474e3d9e 100644 --- a/swift/common/middleware/tempauth.py +++ b/swift/common/middleware/tempauth.py @@ -150,6 +150,8 @@ class TempAuth(object): '%s,%s' % (user, 's3' if s3 else token) env['swift.authorize'] = self.authorize env['swift.clean_acl'] = clean_acl + if '.reseller_admin' in groups: + env['reseller_request'] = True else: # Unauthorized token if self.reseller_prefix: diff --git a/test/unit/common/middleware/test_account_quotas.py b/test/unit/common/middleware/test_account_quotas.py new file mode 100644 index 0000000000..ffbec27d09 --- /dev/null +++ b/test/unit/common/middleware/test_account_quotas.py @@ -0,0 +1,171 @@ +# 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 unittest + +from swift.common.swob import Request + +from swift.common.middleware import account_quotas + + +class FakeCache(object): + def __init__(self, val): + self.val = val + + def get(self, *args): + return self.val + + def set(self, *args, **kwargs): + pass + + +class FakeApp(object): + def __init__(self, headers=[]): + self.headers = headers + + def __call__(self, env, start_response): + start_response('200 OK', self.headers) + return [] + + +def start_response(*args): + pass + + +class TestAccountQuota(unittest.TestCase): + + def test_unauthorized(self): + headers = [('x-account-bytes-used', '1000'), ] + app = account_quotas.AccountQuotaMiddleware(FakeApp(headers)) + cache = FakeCache(None) + req = Request.blank('/v1/a/c/o', + environ={'REQUEST_METHOD': 'PUT', + 'swift.cache': cache}) + res = req.get_response(app) + #Response code of 200 because authentication itself is not done here + self.assertEquals(res.status_int, 200) + + def test_no_quotas(self): + headers = [('x-account-bytes-used', '1000'), ] + app = account_quotas.AccountQuotaMiddleware(FakeApp(headers)) + cache = FakeCache(None) + req = Request.blank('/v1/a/c/o', + environ={'REQUEST_METHOD': 'PUT', + 'swift.cache': cache}) + res = req.get_response(app) + self.assertEquals(res.status_int, 200) + + def test_exceed_bytes_quota(self): + headers = [('x-account-bytes-used', '1000'), + ('x-account-meta-quota-bytes', '0')] + app = account_quotas.AccountQuotaMiddleware(FakeApp(headers)) + cache = FakeCache(None) + req = Request.blank('/v1/a/c/o', + environ={'REQUEST_METHOD': 'PUT', + 'swift.cache': cache}) + res = req.get_response(app) + self.assertEquals(res.status_int, 413) + + def test_exceed_bytes_quota_reseller(self): + headers = [('x-account-bytes-used', '1000'), + ('x-account-meta-quota-bytes', '0')] + app = account_quotas.AccountQuotaMiddleware(FakeApp(headers)) + cache = FakeCache(None) + req = Request.blank('/v1/a/c/o', + environ={'REQUEST_METHOD': 'PUT', + 'swift.cache': cache, + 'reseller_request': True}) + res = req.get_response(app) + self.assertEquals(res.status_int, 200) + + def test_not_exceed_bytes_quota(self): + headers = [('x-account-bytes-used', '1000'), + ('x-account-meta-quota-bytes', 2000)] + app = account_quotas.AccountQuotaMiddleware(FakeApp(headers)) + cache = FakeCache(None) + req = Request.blank('/v1/a/c/o', + environ={'REQUEST_METHOD': 'PUT', + 'swift.cache': cache}) + res = req.get_response(app) + self.assertEquals(res.status_int, 200) + + def test_invalid_quotas(self): + headers = [('x-account-bytes-used', '0'), ] + app = account_quotas.AccountQuotaMiddleware(FakeApp(headers)) + cache = FakeCache(None) + req = Request.blank('/v1/a/c', + environ={'REQUEST_METHOD': 'POST', + 'swift.cache': cache, + 'HTTP_X_ACCOUNT_META_QUOTA_BYTES': 'abc', + 'reseller_request': True}) + res = req.get_response(app) + self.assertEquals(res.status_int, 400) + + def test_valid_quotas_admin(self): + headers = [('x-account-bytes-used', '0'), ] + app = account_quotas.AccountQuotaMiddleware(FakeApp(headers)) + cache = FakeCache(None) + req = Request.blank('/v1/a/c', + environ={'REQUEST_METHOD': 'POST', + 'swift.cache': cache, + 'HTTP_X_ACCOUNT_META_QUOTA_BYTES': '100'}) + res = req.get_response(app) + self.assertEquals(res.status_int, 403) + + def test_valid_quotas_reseller(self): + headers = [('x-account-bytes-used', '0'), ] + app = account_quotas.AccountQuotaMiddleware(FakeApp(headers)) + cache = FakeCache(None) + req = Request.blank('/v1/a/c', + environ={'REQUEST_METHOD': 'POST', + 'swift.cache': cache, + 'HTTP_X_ACCOUNT_META_QUOTA_BYTES': '100', + 'reseller_request': True}) + res = req.get_response(app) + self.assertEquals(res.status_int, 200) + + def test_delete_quotas(self): + headers = [('x-account-bytes-used', '0'), ] + app = account_quotas.AccountQuotaMiddleware(FakeApp(headers)) + cache = FakeCache(None) + req = Request.blank('/v1/a/c', + environ={'REQUEST_METHOD': 'POST', + 'swift.cache': cache, + 'HTTP_X_ACCOUNT_META_QUOTA_BYTES': ''}) + res = req.get_response(app) + self.assertEquals(res.status_int, 403) + + def test_delete_quotas_reseller(self): + headers = [('x-account-bytes-used', '0'), ] + app = account_quotas.AccountQuotaMiddleware(FakeApp(headers)) + req = Request.blank('/v1/a/c', + environ={'REQUEST_METHOD': 'POST', + 'HTTP_X_ACCOUNT_META_QUOTA_BYTES': '', + 'reseller_request': True}) + res = req.get_response(app) + self.assertEquals(res.status_int, 200) + + def test_invalid_request_exception(self): + headers = [('x-account-bytes-used', '1000'), ] + app = account_quotas.AccountQuotaMiddleware(FakeApp(headers)) + cache = FakeCache(None) + req = Request.blank('/v1', + environ={'REQUEST_METHOD': 'PUT', + 'swift.cache': cache}) + res = req.get_response(app) + #Response code of 200 because authentication itself is not done here + self.assertEquals(res.status_int, 200) + + +if __name__ == '__main__': + unittest.main()