]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: enable/disable MFA Delete on RGW bucket 31922/head
authorAlfonso Martínez <almartin@redhat.com>
Fri, 28 Feb 2020 10:35:57 +0000 (11:35 +0100)
committerAlfonso Martínez <almartin@redhat.com>
Fri, 28 Feb 2020 10:35:57 +0000 (11:35 +0100)
Fixes: https://tracker.ceph.com/issues/42094
Signed-off-by: Alfonso Martínez <almartin@redhat.com>
13 files changed:
qa/tasks/mgr/dashboard/test_rgw.py
src/pybind/mgr/dashboard/controllers/rgw.py
src/pybind/mgr/dashboard/frontend/e2e/rgw/buckets.po.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-bucket-mfa-delete.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-bucket-versioning.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.ts
src/pybind/mgr/dashboard/rest_client.py
src/pybind/mgr/dashboard/services/rgw_client.py

index dca9e3cf45c670a919f4136a634f3741a755dc68..86477bc1dc4eac8c8cbf3a1782efa7a4c85d1d72 100644 (file)
@@ -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)
index 4e3f8f2ac9099501340c652f58a7f5cbd08afd06..fb6764622f483e4997b9039d2de29c4b4411a0a0 100644 (file)
@@ -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)
 
index 430fe850b9719e89739e54da267ddbb5e2a6b86f..1e0cb68244d95968067e1b7f64291339c1a85da1 100644 (file)
@@ -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 (file)
index 0000000..5310940
--- /dev/null
@@ -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 (file)
index 0000000..51048c6
--- /dev/null
@@ -0,0 +1,4 @@
+export enum RgwBucketVersioning {
+  ENABLED = 'Enabled',
+  SUSPENDED = 'Suspended'
+}
index d4ab903dc42076748ba3723f451a348bee77c5f3..9f0c2ddd5dd08681cf0b4c66e80862f907438f6a 100644 (file)
                 class="bold">Versioning</td>
             <td>{{ bucket.versioning }}</td>
           </tr>
+          <tr>
+            <td i18n
+                class="bold">MFA Delete</td>
+            <td>{{ bucket.mfa_delete }}</td>
+          </tr>
         </tbody>
       </table>
 
index e16cbe4f6160484e9b0315a91c0e867a22dd2fa1..6d5dcca01025eca0a09cbcc15c1e4def157edac1 100644 (file)
         </div>
 
         <!-- Versioning -->
-        <div class="form-group row"
-             *ngIf="editing">
-          <legend class="cd-header ml-5 mr-5"
-                  i18n>Versioning</legend>
-          <div class="cd-col-form-offset">
-            <input type="radio"
-                   id="enabled"
-                   name="versioning"
-                   formControlName="versioning"
-                   value="Enabled"
-                   [checked]="bucketForm.get('versioning').value == 'Enabled'"
-                   class="custom-control custom-radio custom-control-inline align-top">
-            <label class="align-text-top"
-                   for="enabled">
-              <span i18n>Enabled</span>
-              <div class="text-muted"
-                   i18n>Enables versioning for the objects in the bucket.</div>
-            </label>
+        <legend class="cd-header"
+                i18n>Versioning</legend>
+
+        <ng-container *ngIf="editing">
+          <div class="form-group row">
+            <div class="cd-col-form-offset">
+              <div class="custom-control custom-checkbox">
+                <input type="checkbox"
+                       class="custom-control-input"
+                       id="versioning"
+                       name="versioning"
+                       formControlName="versioning"
+                       [checked]="isVersioningEnabled"
+                       (change)="updateVersioning()">
+                <label class="custom-control-label"
+                       for="versioning"
+                       i18n>Versioning enabled</label>
+                <cd-helper>
+                  <span i18n>Enables versioning for the objects in the bucket.</span>
+                </cd-helper>
+              </div>
+            </div>
           </div>
-          <div class="cd-col-form-offset">
-            <input type="radio"
-                   id="suspended"
-                   name="versioning"
-                   formControlName="versioning"
-                   value="Suspended"
-                   [checked]="bucketForm.get('versioning').value != 'Enabled'"
-                   class="custom-control custom-radio custom-control-inline align-top">
-            <label class="align-text-top"
-                   for="suspended">
-              <span i18n>Suspended</span>
-              <div class="text-muted"
-                   i18n>Disables versioning for the objects in the bucket.</div>
-            </label>
-          <span class="invalid-feedback"
-                *ngIf="bucketForm.showError('versioning', frm, 'required')"
-                i18n>This field is required.</span>
+
+          <!-- MFA Delete -->
+          <legend class="cd-header"
+                  i18n>Multi-Factor Authentication</legend>
+
+          <div class="form-group row">
+            <div class="cd-col-form-offset">
+              <div class="custom-control custom-checkbox">
+                <input type="checkbox"
+                       class="custom-control-input"
+                       id="mfa-delete"
+                       name="mfa-delete"
+                       formControlName="mfa-delete"
+                       [checked]="isMfaDeleteEnabled"
+                       (change)="updateMfaDelete()">
+                <label class="custom-control-label"
+                       for="mfa-delete"
+                       i18n>Delete enabled</label>
+                <cd-helper>
+                  <span i18n>Enables MFA (multi-factor authentication) Delete, which requires additional authentication for changing the bucket versioning state.</span>
+                </cd-helper>
+              </div>
+            </div>
           </div>
-        </div>
+          <div *ngIf="areMfaCredentialsRequired()"
+               class="form-group row">
+            <label i18n
+                   class="cd-col-form-label"
+                   for="mfa-token-serial">Token Serial Number</label>
+            <div class="cd-col-form-input">
+              <input type="text"
+                     id="mfa-token-serial"
+                     name="mfa-token-serial"
+                     formControlName="mfa-token-serial"
+                     class="form-control">
+              <span class="invalid-feedback"
+                    *ngIf="bucketForm.showError('mfa-token-serial', frm, 'required')"
+                    i18n>This field is required.</span>
+            </div>
+          </div>
+          <div *ngIf="areMfaCredentialsRequired()"
+               class="form-group row">
+            <label i18n
+                   class="cd-col-form-label"
+                   for="mfa-token-pin">Token PIN</label>
+            <div class="cd-col-form-input">
+              <input type="text"
+                     id="mfa-token-pin"
+                     name="mfa-token-pin"
+                     formControlName="mfa-token-pin"
+                     class="form-control">
+              <span class="invalid-feedback"
+                    *ngIf="bucketForm.showError('mfa-token-pin', frm, 'required')"
+                    i18n>This field is required.</span>
+            </div>
+          </div>
+        </ng-container>
+
 
       </div>
       <div class="card-footer">
index 0b6358568ad6bc12b8df74f419b4d7ddb914479f..53880bdb02796f7c9d566fb02487ec1bdeb8bab4 100644 (file)
@@ -14,6 +14,8 @@ import { RgwSiteService } from '../../../shared/api/rgw-site.service';
 import { NotificationType } from '../../../shared/enum/notification-type.enum';
 import { NotificationService } from '../../../shared/services/notification.service';
 import { SharedModule } from '../../../shared/shared.module';
+import { RgwBucketMfaDelete } from '../models/rgw-bucket-mfa-delete';
+import { RgwBucketVersioning } from '../models/rgw-bucket-versioning';
 import { RgwBucketFormComponent } from './rgw-bucket-form.component';
 
 describe('RgwBucketFormComponent', () => {
@@ -21,6 +23,7 @@ describe('RgwBucketFormComponent', () => {
   let fixture: ComponentFixture<RgwBucketFormComponent>;
   let rgwBucketService: RgwBucketService;
   let getPlacementTargetsSpy: jasmine.Spy;
+  let rgwBucketServiceGetSpy: jasmine.Spy;
 
   configureTestBed({
     declarations: [RgwBucketFormComponent],
@@ -38,6 +41,7 @@ describe('RgwBucketFormComponent', () => {
     fixture = TestBed.createComponent(RgwBucketFormComponent);
     component = fixture.componentInstance;
     rgwBucketService = TestBed.get(RgwBucketService);
+    rgwBucketServiceGetSpy = spyOn(rgwBucketService, 'get');
     getPlacementTargetsSpy = spyOn(TestBed.get(RgwSiteService), 'getPlacementTargets');
   });
 
@@ -209,4 +213,106 @@ describe('RgwBucketFormComponent', () => {
       );
     });
   });
+
+  describe('mfa credentials', () => {
+    const checkMfaCredentialsVisibility = (
+      fakeResponse: object,
+      versioningChecked: boolean,
+      mfaDeleteChecked: boolean,
+      expectedVisibility: boolean
+    ) => {
+      component['route'].params = observableOf({ bid: 'bid' });
+      component.editing = true;
+      rgwBucketServiceGetSpy.and.returnValue(observableOf(fakeResponse));
+      component.ngOnInit();
+      component.isVersioningEnabled = versioningChecked;
+      component.isMfaDeleteEnabled = mfaDeleteChecked;
+      fixture.detectChanges();
+
+      const mfaTokenSerial = fixture.debugElement.nativeElement.querySelector('#mfa-token-serial');
+      const mfaTokenPin = fixture.debugElement.nativeElement.querySelector('#mfa-token-pin');
+      if (expectedVisibility) {
+        expect(mfaTokenSerial).toBeTruthy();
+        expect(mfaTokenPin).toBeTruthy();
+      } else {
+        expect(mfaTokenSerial).toBeFalsy();
+        expect(mfaTokenPin).toBeFalsy();
+      }
+    };
+
+    it('inputs should be visible when required', () => {
+      checkMfaCredentialsVisibility(
+        {
+          versioning: RgwBucketVersioning.SUSPENDED,
+          mfa_delete: RgwBucketMfaDelete.DISABLED
+        },
+        false,
+        false,
+        false
+      );
+      checkMfaCredentialsVisibility(
+        {
+          versioning: RgwBucketVersioning.SUSPENDED,
+          mfa_delete: RgwBucketMfaDelete.DISABLED
+        },
+        true,
+        false,
+        false
+      );
+      checkMfaCredentialsVisibility(
+        {
+          versioning: RgwBucketVersioning.ENABLED,
+          mfa_delete: RgwBucketMfaDelete.DISABLED
+        },
+        false,
+        false,
+        false
+      );
+      checkMfaCredentialsVisibility(
+        {
+          versioning: RgwBucketVersioning.ENABLED,
+          mfa_delete: RgwBucketMfaDelete.ENABLED
+        },
+        true,
+        true,
+        false
+      );
+      checkMfaCredentialsVisibility(
+        {
+          versioning: RgwBucketVersioning.SUSPENDED,
+          mfa_delete: RgwBucketMfaDelete.DISABLED
+        },
+        false,
+        true,
+        true
+      );
+      checkMfaCredentialsVisibility(
+        {
+          versioning: RgwBucketVersioning.SUSPENDED,
+          mfa_delete: RgwBucketMfaDelete.ENABLED
+        },
+        false,
+        false,
+        true
+      );
+      checkMfaCredentialsVisibility(
+        {
+          versioning: RgwBucketVersioning.SUSPENDED,
+          mfa_delete: RgwBucketMfaDelete.ENABLED
+        },
+        true,
+        true,
+        true
+      );
+      checkMfaCredentialsVisibility(
+        {
+          versioning: RgwBucketVersioning.ENABLED,
+          mfa_delete: RgwBucketMfaDelete.ENABLED
+        },
+        false,
+        true,
+        true
+      );
+    });
+  });
 });
index 34437e42a65f30c06092af7b522e4e3711c8670b..8eab976a7f4ee444c5418f1a92a7fd1eca923d5f 100644 (file)
@@ -9,11 +9,14 @@ import { RgwBucketService } from '../../../shared/api/rgw-bucket.service';
 import { RgwSiteService } from '../../../shared/api/rgw-site.service';
 import { RgwUserService } from '../../../shared/api/rgw-user.service';
 import { ActionLabelsI18n, URLVerbs } from '../../../shared/constants/app.constants';
+import { Icons } from '../../../shared/enum/icons.enum';
 import { NotificationType } from '../../../shared/enum/notification-type.enum';
 import { CdFormBuilder } from '../../../shared/forms/cd-form-builder';
 import { CdFormGroup } from '../../../shared/forms/cd-form-group';
 import { CdValidators } from '../../../shared/forms/cd-validators';
 import { NotificationService } from '../../../shared/services/notification.service';
+import { RgwBucketMfaDelete } from '../models/rgw-bucket-mfa-delete';
+import { RgwBucketVersioning } from '../models/rgw-bucket-versioning';
 
 @Component({
   selector: 'cd-rgw-bucket-form',
@@ -30,6 +33,11 @@ export class RgwBucketFormComponent implements OnInit {
   resource: string;
   zonegroup: string;
   placementTargets: object[] = [];
+  isVersioningEnabled = false;
+  isVersioningAlreadyEnabled = false;
+  isMfaDeleteEnabled = false;
+  isMfaDeleteAlreadyEnabled = false;
+  icons = Icons;
 
   constructor(
     private route: ActivatedRoute,
@@ -54,7 +62,10 @@ export class RgwBucketFormComponent implements OnInit {
       bid: [null, [Validators.required], this.editing ? [] : [this.bucketNameValidator()]],
       owner: [null, [Validators.required]],
       'placement-target': [null, this.editing ? [] : [Validators.required]],
-      versioning: [null, this.editing ? [Validators.required] : []]
+      versioning: [null],
+      'mfa-delete': [null],
+      'mfa-token-serial': [''],
+      'mfa-token-pin': ['']
     });
   }
 
@@ -101,6 +112,13 @@ export class RgwBucketFormComponent implements OnInit {
         value = _.merge(defaults, value);
         // Update the form.
         this.bucketForm.setValue(value);
+        if (this.editing) {
+          this.setVersioningStatus(resp['versioning']);
+          this.isVersioningAlreadyEnabled = this.isVersioningEnabled;
+          this.setMfaDeleteStatus(resp['mfa_delete']);
+          this.isMfaDeleteAlreadyEnabled = this.isMfaDeleteEnabled;
+          this.setMfaDeleteValidators();
+        }
       });
     });
   }
@@ -121,9 +139,20 @@ export class RgwBucketFormComponent implements OnInit {
     if (this.editing) {
       // Edit
       const idCtl = this.bucketForm.get('id');
-      const versioningCtl = this.bucketForm.get('versioning');
+      const versioning = this.getVersioningStatus();
+      const mfaDelete = this.getMfaDeleteStatus();
+      const mfaTokenSerial = this.bucketForm.getValue('mfa-token-serial');
+      const mfaTokenPin = this.bucketForm.getValue('mfa-token-pin');
       this.rgwBucketService
-        .update(bidCtl.value, idCtl.value, ownerCtl.value, versioningCtl.value)
+        .update(
+          bidCtl.value,
+          idCtl.value,
+          ownerCtl.value,
+          versioning,
+          mfaDelete,
+          mfaTokenSerial,
+          mfaTokenPin
+        )
         .subscribe(
           () => {
             this.notificationService.show(
@@ -226,4 +255,56 @@ export class RgwBucketFormComponent implements OnInit {
       });
     };
   }
+
+  areMfaCredentialsRequired() {
+    return (
+      this.isMfaDeleteEnabled !== this.isMfaDeleteAlreadyEnabled ||
+      (this.isMfaDeleteAlreadyEnabled &&
+        this.isVersioningEnabled !== this.isVersioningAlreadyEnabled)
+    );
+  }
+
+  setMfaDeleteValidators() {
+    const mfaTokenSerialControl = this.bucketForm.get('mfa-token-serial');
+    const mfaTokenPinControl = this.bucketForm.get('mfa-token-pin');
+
+    if (this.areMfaCredentialsRequired()) {
+      mfaTokenSerialControl.setValidators(Validators.required);
+      mfaTokenPinControl.setValidators(Validators.required);
+    } else {
+      mfaTokenSerialControl.setValidators(null);
+      mfaTokenPinControl.setValidators(null);
+    }
+
+    mfaTokenSerialControl.updateValueAndValidity();
+    mfaTokenPinControl.updateValueAndValidity();
+  }
+
+  getVersioningStatus() {
+    return this.isVersioningEnabled ? RgwBucketVersioning.ENABLED : RgwBucketVersioning.SUSPENDED;
+  }
+
+  setVersioningStatus(status: RgwBucketVersioning) {
+    this.isVersioningEnabled = status === RgwBucketVersioning.ENABLED;
+  }
+
+  updateVersioning() {
+    this.isVersioningEnabled = !this.isVersioningEnabled;
+
+    this.setMfaDeleteValidators();
+  }
+
+  getMfaDeleteStatus() {
+    return this.isMfaDeleteEnabled ? RgwBucketMfaDelete.ENABLED : RgwBucketMfaDelete.DISABLED;
+  }
+
+  setMfaDeleteStatus(status: RgwBucketMfaDelete) {
+    this.isMfaDeleteEnabled = status === RgwBucketMfaDelete.ENABLED;
+  }
+
+  updateMfaDelete() {
+    this.isMfaDeleteEnabled = !this.isMfaDeleteEnabled;
+
+    this.setMfaDeleteValidators();
+  }
 }
index ff789e44feed6d91ca7c7d64822300decf05d7e5..53c57cc5e505e2cc5db3961b55a16c3078337b6c 100644 (file)
@@ -70,9 +70,9 @@ describe('RgwBucketService', () => {
   });
 
   it('should call update', () => {
-    service.update('foo', 'bar', 'baz', 'Enabled').subscribe();
+    service.update('foo', 'bar', 'baz', 'Enabled', 'Enabled', '1', '223344').subscribe();
     const req = httpTesting.expectOne(
-      'api/rgw/bucket/foo?bucket_id=bar&uid=baz&versioning_state=Enabled'
+      'api/rgw/bucket/foo?bucket_id=bar&uid=baz&versioning_state=Enabled&mfa_delete=Enabled&mfa_token_serial=1&mfa_token_pin=223344'
     );
     expect(req.request.method).toBe('PUT');
   });
index 5e708c9fcfc7bfbe531cab3a32fa48c4520dc9ff..70a1f3985e67e2d03ffe07159a355b60d51192f9 100644 (file)
@@ -58,11 +58,22 @@ export class RgwBucketService {
     return this.http.post(this.url, null, { params: params });
   }
 
-  update(bucket: string, bucketId: string, uid: string, versioningState: string) {
+  update(
+    bucket: string,
+    bucketId: string,
+    uid: string,
+    versioningState: string,
+    mfaDelete: string,
+    mfaTokenSerial: string,
+    mfaTokenPin: string
+  ) {
     let params = new HttpParams();
     params = params.append('bucket_id', bucketId);
     params = params.append('uid', uid);
     params = params.append('versioning_state', versioningState);
+    params = params.append('mfa_delete', mfaDelete);
+    params = params.append('mfa_token_serial', mfaTokenSerial);
+    params = params.append('mfa_token_pin', mfaTokenPin);
     return this.http.put(`${this.url}/${bucket}`, null, { params: params });
   }
 
index 22a36d0e3072c83d0b1ab49fefa4687ea19669bc..4d58d0dfde17f2b71093f36062b6b93d0427b5a4 100644 (file)
@@ -304,7 +304,8 @@ class _Request(object):
                  method=None,
                  params=None,
                  data=None,
-                 raw_content=False):
+                 raw_content=False,
+                 headers=None):
         method = method if method else self.method
         if not method:
             raise Exception('No HTTP request method specified')
@@ -319,7 +320,7 @@ class _Request(object):
                         method.upper()))
                 data = req_data
         resp = self.rest_client.do_request(method, self._gen_path(), params,
-                                           data, raw_content)
+                                           data, raw_content, headers)
         if raw_content and self.resp_structure:
             raise Exception("Cannot validate response in raw format")
         _ResponseValidator.validate(self.resp_structure, resp)
@@ -377,32 +378,36 @@ class RestClient(object):
                    path,
                    params=None,
                    data=None,
-                   raw_content=False):
+                   raw_content=False,
+                   headers=None):
         url = '{}{}'.format(self.base_url, path)
         logger.debug('%s REST API %s req: %s data: %s', self.client_name,
                      method.upper(), path, data)
+        request_headers = self.headers.copy()
+        if headers:
+            request_headers.update(headers)
         try:
             if method.lower() == 'get':
                 resp = self.session.get(
-                    url, headers=self.headers, params=params, auth=self.auth)
+                    url, headers=request_headers, params=params, auth=self.auth)
             elif method.lower() == 'post':
                 resp = self.session.post(
                     url,
-                    headers=self.headers,
+                    headers=request_headers,
                     params=params,
                     data=data,
                     auth=self.auth)
             elif method.lower() == 'put':
                 resp = self.session.put(
                     url,
-                    headers=self.headers,
+                    headers=request_headers,
                     params=params,
                     data=data,
                     auth=self.auth)
             elif method.lower() == 'delete':
                 resp = self.session.delete(
                     url,
-                    headers=self.headers,
+                    headers=request_headers,
                     params=params,
                     data=data,
                     auth=self.auth)
index daf2bb48ed364cee145dee81eb458bf7326e3584..794340e75777f81145c1faf2c8b0024e1b63e047 100644 (file)
@@ -8,6 +8,7 @@ from distutils.util import strtobool
 import xml.etree.ElementTree as ET  # noqa: N814
 import six
 from ..awsauth import S3Auth
+from ..exceptions import DashboardException
 from ..settings import Settings, Options
 from ..rest_client import RestClient, RequestException
 from ..tools import build_url, dict_contains_path, json_str_to_object, partial_dict
@@ -486,28 +487,52 @@ class RgwClient(RestClient):
         """
         Get bucket versioning.
         :param str bucket_name: the name of the bucket.
-        :return: versioning state
-        :rtype: str
+        :return: versioning info
+        :rtype: Dict
         """
         # pylint: disable=unused-argument
         result = request()
         if 'Status' not in result:
             result['Status'] = 'Suspended'
-        return result['Status']
+        if 'MfaDelete' not in result:
+            result['MfaDelete'] = 'Disabled'
+        return result
 
     @RestClient.api_put('/{bucket_name}?versioning')
-    def set_bucket_versioning(self, bucket_name, versioning_state, request=None):
+    def set_bucket_versioning(self, bucket_name, versioning_state, mfa_delete,
+                              mfa_token_serial, mfa_token_pin, request=None):
         """
         Set bucket versioning.
         :param str bucket_name: the name of the bucket.
         :param str versioning_state:
             https://docs.aws.amazon.com/AmazonS3/latest/API/RESTBucketPUTVersioningStatus.html
+        :param str mfa_delete: MFA Delete state.
+        :param str mfa_token_serial:
+            https://docs.ceph.com/docs/master/radosgw/mfa/
+        :param str mfa_token_pin: value of a TOTP token at a certain time (auth code)
         :return: None
         """
         # pylint: disable=unused-argument
         versioning_configuration = ET.Element('VersioningConfiguration')
-        status = ET.SubElement(versioning_configuration, 'Status')
-        status.text = versioning_state
+        status_element = ET.SubElement(versioning_configuration, 'Status')
+        status_element.text = versioning_state
+
+        headers = {}
+        if mfa_delete and mfa_token_serial and mfa_token_pin:
+            headers['x-amz-mfa'] = '{} {}'.format(mfa_token_serial, mfa_token_pin)
+            mfa_delete_element = ET.SubElement(versioning_configuration, 'MfaDelete')
+            mfa_delete_element.text = mfa_delete
+
         data = ET.tostring(versioning_configuration, encoding='utf-8')
 
-        return request(data=data)
+        try:
+            request(data=data, headers=headers)
+        except RequestException as error:
+            msg = str(error)
+            if error.status_code == 403:
+                msg = 'Bad MFA credentials: {}'.format(msg)
+            # Avoid dashboard GUI redirections caused by status code (403, ...):
+            http_status_code = 400 if 400 <= error.status_code < 500 else error.status_code
+            raise DashboardException(msg=msg,
+                                     http_status_code=http_status_code,
+                                     component='rgw')