From 5b6cf814b9aba9d274fcbd72311cb86643be3a1d Mon Sep 17 00:00:00 2001 From: =?utf8?q?Alfonso=20Mart=C3=ADnez?= Date: Fri, 28 Feb 2020 11:35:57 +0100 Subject: [PATCH] mgr/dashboard: enable/disable MFA Delete on RGW bucket MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit Fixes: https://tracker.ceph.com/issues/42094 Signed-off-by: Alfonso Martínez --- qa/tasks/mgr/dashboard/test_rgw.py | 69 +++++++++-- src/pybind/mgr/dashboard/controllers/rgw.py | 20 ++- .../dashboard/frontend/e2e/rgw/buckets.po.ts | 20 +-- .../ceph/rgw/models/rgw-bucket-mfa-delete.ts | 4 + .../ceph/rgw/models/rgw-bucket-versioning.ts | 4 + .../rgw-bucket-details.component.html | 5 + .../rgw-bucket-form.component.html | 115 ++++++++++++------ .../rgw-bucket-form.component.spec.ts | 106 ++++++++++++++++ .../rgw-bucket-form.component.ts | 87 ++++++++++++- .../app/shared/api/rgw-bucket.service.spec.ts | 4 +- .../src/app/shared/api/rgw-bucket.service.ts | 13 +- src/pybind/mgr/dashboard/rest_client.py | 19 +-- .../mgr/dashboard/services/rgw_client.py | 39 ++++-- 13 files changed, 422 insertions(+), 83 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-bucket-mfa-delete.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-bucket-versioning.ts diff --git a/qa/tasks/mgr/dashboard/test_rgw.py b/qa/tasks/mgr/dashboard/test_rgw.py index dca9e3cf45c..86477bc1dc4 100644 --- a/qa/tasks/mgr/dashboard/test_rgw.py +++ b/qa/tasks/mgr/dashboard/test_rgw.py @@ -1,9 +1,15 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import +import base64 import logging +import time import urllib +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.twofactor.totp import TOTP +from cryptography.hazmat.primitives.hashes import SHA1 + from .helper import DashboardTestCase, JObj, JList, JLeaf logger = logging.getLogger(__name__) @@ -54,12 +60,9 @@ class RgwTestCase(DashboardTestCase): # Delete administrator account. cls._radosgw_admin_cmd(['user', 'rm', '--uid', 'admin']) if cls.create_test_user: - cls._radosgw_admin_cmd(['user', 'rm', '--uid=teuth-test-user']) + cls._radosgw_admin_cmd(['user', 'rm', '--uid=teuth-test-user', '--purge-data']) super(RgwTestCase, cls).tearDownClass() - def setUp(self): - super(RgwTestCase, self).setUp() - def get_rgw_user(self, uid): return self._get('/api/rgw/user/{}'.format(uid)) @@ -111,12 +114,22 @@ class RgwApiCredentialsTest(RgwTestCase): class RgwBucketTest(RgwTestCase): + _mfa_token_serial = '1' + _mfa_token_seed = '23456723' + _mfa_token_time_step = 1 + AUTH_ROLES = ['rgw-manager'] @classmethod def setUpClass(cls): cls.create_test_user = True super(RgwBucketTest, cls).setUpClass() + # Create MFA TOTP token for test user. + cls._radosgw_admin_cmd([ + 'mfa', 'create', '--uid', 'teuth-test-user', '--totp-serial', cls._mfa_token_serial, + '--totp-seed', cls._mfa_token_seed, '--totp-seed-type', 'base32', + '--totp-seconds', str(cls._mfa_token_time_step), '--totp-window', '2' + ]) # Create tenanted users. cls._radosgw_admin_cmd([ 'user', 'create', '--tenant', 'testx', '--uid', 'teuth-test-user', @@ -130,11 +143,18 @@ class RgwBucketTest(RgwTestCase): @classmethod def tearDownClass(cls): cls._radosgw_admin_cmd( - ['user', 'rm', '--tenant', 'testx', '--uid=teuth-test-user']) + ['user', 'rm', '--tenant', 'testx', '--uid=teuth-test-user', '--purge-data']) cls._radosgw_admin_cmd( - ['user', 'rm', '--tenant', 'testx2', '--uid=teuth-test-user2']) + ['user', 'rm', '--tenant', 'testx2', '--uid=teuth-test-user2', '--purge-data']) super(RgwBucketTest, cls).tearDownClass() + def _get_mfa_token_pin(self): + totp_key = base64.b32decode(self._mfa_token_seed) + totp = TOTP(totp_key, 6, SHA1(), self._mfa_token_time_step, backend=default_backend(), + enforce_key_length=False) + time_value = time.time() + return totp.generate(time_value) + def test_all(self): # Create a new bucket. self._post( @@ -184,7 +204,7 @@ class RgwBucketTest(RgwTestCase): self.assertEqual(data['placement_rule'], 'default-placement') self.assertEqual(data['versioning'], 'Suspended') - # Update the bucket. + # Update bucket: change owner, enable versioning. self._put( '/api/rgw/bucket/teuth-test-bucket', params={ @@ -203,6 +223,41 @@ class RgwBucketTest(RgwTestCase): self.assertEqual(data['owner'], 'teuth-test-user') self.assertEqual(data['versioning'], 'Enabled') + # Update bucket: enable MFA Delete. + self._put( + '/api/rgw/bucket/teuth-test-bucket', + params={ + 'bucket_id': data['id'], + 'uid': 'teuth-test-user', + 'versioning_state': 'Enabled', + 'mfa_delete': 'Enabled', + 'mfa_token_serial': self._mfa_token_serial, + 'mfa_token_pin': self._get_mfa_token_pin() + }) + self.assertStatus(200) + data = self._get('/api/rgw/bucket/teuth-test-bucket') + self.assertStatus(200) + self.assertEqual(data['versioning'], 'Enabled') + self.assertEqual(data['mfa_delete'], 'Enabled') + + # Update bucket: disable versioning & MFA Delete. + time.sleep(self._mfa_token_time_step) # Required to get new TOTP pin. + self._put( + '/api/rgw/bucket/teuth-test-bucket', + params={ + 'bucket_id': data['id'], + 'uid': 'teuth-test-user', + 'versioning_state': 'Suspended', + 'mfa_delete': 'Disabled', + 'mfa_token_serial': self._mfa_token_serial, + 'mfa_token_pin': self._get_mfa_token_pin() + }) + self.assertStatus(200) + data = self._get('/api/rgw/bucket/teuth-test-bucket') + self.assertStatus(200) + self.assertEqual(data['versioning'], 'Suspended') + self.assertEqual(data['mfa_delete'], 'Disabled') + # Delete the bucket. self._delete('/api/rgw/bucket/teuth-test-bucket') self.assertStatus(204) diff --git a/src/pybind/mgr/dashboard/controllers/rgw.py b/src/pybind/mgr/dashboard/controllers/rgw.py index 4e3f8f2ac90..fb6764622f4 100644 --- a/src/pybind/mgr/dashboard/controllers/rgw.py +++ b/src/pybind/mgr/dashboard/controllers/rgw.py @@ -151,9 +151,14 @@ class RgwBucket(RgwRESTController): rgw_client = RgwClient.instance(owner) return rgw_client.get_bucket_versioning(bucket_name) - def _set_versioning(self, owner, bucket_name, versioning_state): - rgw_client = RgwClient.instance(owner) - return rgw_client.set_bucket_versioning(bucket_name, versioning_state) + def _set_versioning(self, owner, bucket_name, versioning_state, mfa_delete, + mfa_token_serial, mfa_token_pin): + bucket_versioning = self._get_versioning(owner, bucket_name) + if versioning_state != bucket_versioning['Status']\ + or (mfa_delete and mfa_delete != bucket_versioning['MfaDelete']): + rgw_client = RgwClient.instance(owner) + rgw_client.set_bucket_versioning(bucket_name, versioning_state, mfa_delete, + mfa_token_serial, mfa_token_pin) @staticmethod def strip_tenant_from_bucket_name(bucket_name): @@ -190,9 +195,11 @@ class RgwBucket(RgwRESTController): # type: (str) -> dict result = self.proxy('GET', 'bucket', {'bucket': bucket}) - result['versioning'] =\ + bucket_versioning =\ self._get_versioning(result['owner'], RgwBucket.get_s3_bucket_name(result['bucket'], result['tenant'])) + result['versioning'] = bucket_versioning['Status'] + result['mfa_delete'] = bucket_versioning['MfaDelete'] return self._append_bid(result) @@ -203,7 +210,8 @@ class RgwBucket(RgwRESTController): except RequestException as e: raise DashboardException(e, http_status_code=500, component='rgw') - def set(self, bucket, bucket_id, uid, versioning_state=None): + def set(self, bucket, bucket_id, uid, versioning_state=None, mfa_delete=None, + mfa_token_serial=None, mfa_token_pin=None): # When linking a non-tenant-user owned bucket to a tenanted user, we # need to prefix bucket name with '/'. e.g. photos -> /photos if '$' in uid and '/' not in bucket: @@ -220,7 +228,7 @@ class RgwBucket(RgwRESTController): uid_tenant = uid[:uid.find('$')] if uid.find('$') >= 0 else None self._set_versioning(uid, RgwBucket.get_s3_bucket_name(bucket, uid_tenant), - versioning_state) + versioning_state, mfa_delete, mfa_token_serial, mfa_token_pin) return self._append_bid(result) diff --git a/src/pybind/mgr/dashboard/frontend/e2e/rgw/buckets.po.ts b/src/pybind/mgr/dashboard/frontend/e2e/rgw/buckets.po.ts index 430fe850b97..1e0cb68244d 100644 --- a/src/pybind/mgr/dashboard/frontend/e2e/rgw/buckets.po.ts +++ b/src/pybind/mgr/dashboard/frontend/e2e/rgw/buckets.po.ts @@ -57,13 +57,9 @@ export class BucketsPageHelper extends PageHelper { await this.selectOwner(new_owner); // Enable versioning - await expect(element(by.css('input[name=versioning]:checked')).getAttribute('value')).toBe( - this.versioningStateSuspended - ); - await element(by.css('input[id=enabled]')).click(); - await expect(element(by.css('input[name=versioning]:checked')).getAttribute('value')).toBe( - this.versioningStateEnabled - ); + await expect(element(by.css('input[id=versioning]')).getAttribute('checked')).toBeFalsy(); + await element(by.css('label[for=versioning]')).click(); + await expect(element(by.css('input[id=versioning]')).getAttribute('checked')).toBeTruthy(); await element(by.cssContainingText('button', 'Edit Bucket')).click(); @@ -96,10 +92,8 @@ export class BucketsPageHelper extends PageHelper { await this.waitClickableAndClick(this.getFirstTableCellWithText(name)); // wait for table to load and click await element(by.cssContainingText('button', 'Edit')).click(); // click button to move to edit page await this.waitTextToBePresent(this.getBreadcrumb(), 'Edit'); - await element(by.css('input[id=suspended]')).click(); - await expect(element(by.css('input[name=versioning]:checked')).getAttribute('value')).toBe( - this.versioningStateSuspended - ); + await element(by.css('label[for=versioning]')).click(); + await expect(element(by.css('input[id=versioning]')).getAttribute('checked')).toBeFalsy(); await element(by.cssContainingText('button', 'Edit Bucket')).click(); // Check versioning suspended: @@ -185,9 +179,7 @@ export class BucketsPageHelper extends PageHelper { await this.waitTextToBePresent(this.getBreadcrumb(), 'Edit'); - await expect(element(by.css('input[name=versioning]:checked')).getAttribute('value')).toBe( - this.versioningStateSuspended - ); + await expect(element(by.css('input[id=versioning]')).getAttribute('checked')).toBeFalsy(); // Chooses 'Select a user' rather than a valid owner on Edit Bucket page // and checks if it's an invalid input diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-bucket-mfa-delete.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-bucket-mfa-delete.ts new file mode 100644 index 00000000000..53109408782 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-bucket-mfa-delete.ts @@ -0,0 +1,4 @@ +export enum RgwBucketMfaDelete { + ENABLED = 'Enabled', + DISABLED = 'Disabled' +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-bucket-versioning.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-bucket-versioning.ts new file mode 100644 index 00000000000..51048c65e7b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-bucket-versioning.ts @@ -0,0 +1,4 @@ +export enum RgwBucketVersioning { + ENABLED = 'Enabled', + SUSPENDED = 'Suspended' +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.html index d4ab903dc42..9f0c2ddd5dd 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.html @@ -64,6 +64,11 @@ class="bold">Versioning {{ bucket.versioning }} + + MFA Delete + {{ bucket.mfa_delete }} + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.html index e16cbe4f616..6d5dcca0102 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.html @@ -119,44 +119,87 @@ -
- Versioning -
- - + Versioning + + +
+
+
+ + + + Enables versioning for the objects in the bucket. + +
+
-
- - - This field is required. + + + Multi-Factor Authentication + +
+
+
+ + + + Enables MFA (multi-factor authentication) Delete, which requires additional authentication for changing the bucket versioning state. + +
+
-
+
+ +
+ + This field is required. +
+
+
+ +
+ + This field is required. +
+
+
+