]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: set bucket policies
authorPedro Gonzalez Gomez <pegonzal@redhat.com>
Thu, 4 Jan 2024 22:16:10 +0000 (23:16 +0100)
committerPedro Gonzalez Gomez <pegonzal@redhat.com>
Thu, 14 Mar 2024 11:50:36 +0000 (12:50 +0100)
conflict: /home/pegonzal/ceph/ceph/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts

- Adds support to set bucket policies through the Dashboard.
- Rename rgw bucket policy from 'policy' to 'bucket policy' and tab 'Permissions' to 'Policies'
- Fix: hide Tags when none are present on bucket list details and sets bucket form dirty after deleting a tag
- Added service to manage the formatting of a textArea that works with json

Signed-off-by: Pedro Gonzalez Gomez <pegonzal@redhat.com>
Fixes: https://tracker.ceph.com/issues/63942
(cherry picked from commit 2817d8e25d84bba47951bd68cb3e8651cdb51b56)

13 files changed:
src/pybind/mgr/dashboard/controllers/rgw.py
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-details/rgw-bucket-details.component.ts
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.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/frontend/src/app/shared/forms/cd-validators.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/forms/crud-form/formly-textarea-type/formly-textarea-type.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/text-area-json-formatter.service.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/services/text-area-json-formatter.service.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/openapi.yaml
src/pybind/mgr/dashboard/services/rgw_client.py

index f3653c5bd27b8994d91146561fb05f0c7323742b..f8c37cede699c2574ec9ea05953f02130c5dda1e 100644 (file)
@@ -290,6 +290,10 @@ class RgwBucket(RgwRESTController):
         rgw_client = RgwClient.admin_instance()
         return rgw_client.get_bucket_policy(bucket)
 
+    def _set_policy(self, bucket_name: str, policy: str, daemon_name, owner):
+        rgw_client = RgwClient.instance(owner, daemon_name)
+        return rgw_client.set_bucket_policy(bucket_name, policy)
+
     def _set_tags(self, bucket_name, tags, daemon_name, owner):
         rgw_client = RgwClient.instance(owner, daemon_name)
         return rgw_client.set_tags(bucket_name, tags)
@@ -346,7 +350,7 @@ class RgwBucket(RgwRESTController):
         result['encryption'] = encryption['Status']
         result['versioning'] = versioning['Status']
         result['mfa_delete'] = versioning['MfaDelete']
-        result['policy'] = self._get_policy(bucket_name)
+        result['bucket_policy'] = self._get_policy(bucket_name)
 
         # Append the locking configuration.
         locking = self._get_locking(result['owner'], daemon_name, bucket_name)
@@ -359,7 +363,8 @@ class RgwBucket(RgwRESTController):
                lock_enabled='false', lock_mode=None,
                lock_retention_period_days=None,
                lock_retention_period_years=None, encryption_state='false',
-               encryption_type=None, key_id=None, tags=None, daemon_name=None):
+               encryption_type=None, key_id=None, tags=None,
+               bucket_policy=None, daemon_name=None):
         lock_enabled = str_to_bool(lock_enabled)
         encryption_state = str_to_bool(encryption_state)
         try:
@@ -378,6 +383,9 @@ class RgwBucket(RgwRESTController):
             if tags:
                 self._set_tags(bucket, tags, daemon_name, uid)
 
+            if bucket_policy:
+                self._set_policy(bucket, bucket_policy, daemon_name, uid)
+
             return result
         except RequestException as e:  # pragma: no cover - handling is too obvious
             raise DashboardException(e, http_status_code=500, component='rgw')
@@ -387,7 +395,7 @@ class RgwBucket(RgwRESTController):
             encryption_state='false', encryption_type=None, key_id=None,
             mfa_delete=None, mfa_token_serial=None, mfa_token_pin=None,
             lock_mode=None, lock_retention_period_days=None,
-            lock_retention_period_years=None, tags=None, daemon_name=None):
+            lock_retention_period_years=None, tags=None, bucket_policy=None, daemon_name=None):
         encryption_state = str_to_bool(encryption_state)
         # When linking a non-tenant-user owned bucket to a tenanted user, we
         # need to prefix bucket name with '/'. e.g. photos -> /photos
@@ -429,6 +437,8 @@ class RgwBucket(RgwRESTController):
             self._delete_encryption(bucket_name, daemon_name, uid)
         if tags:
             self._set_tags(bucket_name, tags, daemon_name, uid)
+        if bucket_policy:
+            self._set_policy(bucket, bucket_policy, daemon_name, uid)
         return self._append_bid(result)
 
     def delete(self, bucket, purge_objects='true', daemon_name=None):
index e96a89b234f9ad642827d3ac9674f397fc1251cf..1732a7fb3b4bd468c9be0f26549a807d8e6c24d1 100644 (file)
         </table>
 
       <!-- Tags -->
-      <ng-container *ngIf="selection.tagset">
+      <ng-container *ngIf="(selection.tagset | keyvalue)?.length">
         <legend i18n>Tags</legend>
         <table class="table table-striped table-bordered">
           <tbody>
 
     <ng-container ngbNavItem="permissions">
       <a ngbNavLink
-         i18n>Permissions</a>
+         i18n>Policies</a>
       <ng-template ngbNavContent>
 
         <table class="table table-striped table-bordered">
           <tbody>
             <tr>
               <td i18n
-                  class="bold w-25">Policy</td>
-              <td><pre>{{ selection.policy | json}}</pre></td>
+                  class="bold w-25">Bucket policy</td>
+              <td><pre>{{ selection.bucket_policy | json}}</pre></td>
             </tr>
           </tbody>
         </table>
index 0ecbe0536dffec32bb717c769007f172eafb5ef0..d731a323818eefac3cae758d9b30ef7810440d66 100644 (file)
@@ -18,7 +18,7 @@ export class RgwBucketDetailsComponent implements OnChanges {
       this.rgwBucketService.get(this.selection.bid).subscribe((bucket: object) => {
         bucket['lock_retention_period_days'] = this.rgwBucketService.getLockDays(bucket);
         this.selection = bucket;
-        this.selection.policy = JSON.parse(this.selection.policy) || {};
+        this.selection.bucket_policy = JSON.parse(this.selection.bucket_policy) || {};
       });
     }
   }
index a9704c0bdc89f9305c4a08ab4643b4c251a33b52..eef66a6da60b429e66288c1872234ec6b080fdaf 100644 (file)
           </div>
         </div>
 
+        <!-- Policies -->
+        <legend class="cd-header"
+                i18n>Policies
+        </legend>
+        <div class="row">
+          <div class="col-12">
+            <div class="form-group row">
+              <label i18n
+                     class="cd-col-form-label"
+                     for="id">Bucket policy</label>
+              <div class="cd-col-form-input">
+                <textarea #bucketPolicyTextArea
+                          class="form-control resize-vertical"
+                          id="bucket_policy"
+                          formControlName="bucket_policy"
+                          (change)="bucketPolicyOnChange()">
+                </textarea>
+                <span class="invalid-feedback"
+                      *ngIf="bucketForm.showError('bucket_policy', frm, 'invalidJson')"
+                      i18n>Invalid json text</span>
+                <div class="btn-group float-end"
+                     role="group"
+                     aria-label="bucket-policy-helpers">
+                  <button type="button"
+                          id="example-generator-button"
+                          class="btn btn-light my-3"
+                          (click)="openUrl('https://docs.aws.amazon.com/AmazonS3/latest/userguide/example-bucket-policies.html?icmpid=docs_amazons3_console')"
+                          i18n>
+                    <i [ngClass]="[icons.externalUrl]"></i>
+                    Policy examples
+                  </button>
+                  <button type="button"
+                          id="example-generator-button"
+                          class="btn btn-light my-3"
+                          (click)="openUrl('https://awspolicygen.s3.amazonaws.com/policygen.html')"
+                          i18n>
+                    <i [ngClass]="[icons.externalUrl]"></i>
+                    Policy generator
+                  </button>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
 
       </div>
       <div class="card-footer">
index 6b90b45e16a6498bcaa12a7a70fb61e224c95b9c..f7015f609f68b38e5c293f0d6350f3f4ff91b373 100644 (file)
@@ -1,4 +1,11 @@
-import { AfterViewChecked, ChangeDetectorRef, Component, OnInit } from '@angular/core';
+import {
+  AfterViewChecked,
+  ChangeDetectorRef,
+  Component,
+  OnInit,
+  ViewChild,
+  ElementRef
+} from '@angular/core';
 import { AbstractControl, Validators } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
 
@@ -22,6 +29,7 @@ import { RgwBucketMfaDelete } from '../models/rgw-bucket-mfa-delete';
 import { RgwBucketVersioning } from '../models/rgw-bucket-versioning';
 import { RgwConfigModalComponent } from '../rgw-config-modal/rgw-config-modal.component';
 import { BucketTagModalComponent } from '../bucket-tag-modal/bucket-tag-modal.component';
+import { TextAreaJsonFormatterService } from '~/app/shared/services/text-area-json-formatter.service';
 
 @Component({
   selector: 'cd-rgw-bucket-form',
@@ -30,6 +38,9 @@ import { BucketTagModalComponent } from '../bucket-tag-modal/bucket-tag-modal.co
   providers: [RgwBucketEncryptionModel]
 })
 export class RgwBucketFormComponent extends CdForm implements OnInit, AfterViewChecked {
+  @ViewChild('bucketPolicyTextArea')
+  public bucketPolicyTextArea: ElementRef<any>;
+
   bucketForm: CdFormGroup;
   editing = false;
   owners: string[] = null;
@@ -70,6 +81,7 @@ export class RgwBucketFormComponent extends CdForm implements OnInit, AfterViewC
     private rgwUserService: RgwUserService,
     private notificationService: NotificationService,
     private rgwEncryptionModal: RgwBucketEncryptionModel,
+    private textAreaJsonFormatterService: TextAreaJsonFormatterService,
     public actionLabels: ActionLabelsI18n,
     private readonly changeDetectorRef: ChangeDetectorRef
   ) {
@@ -82,6 +94,7 @@ export class RgwBucketFormComponent extends CdForm implements OnInit, AfterViewC
 
   ngAfterViewChecked(): void {
     this.changeDetectorRef.detectChanges();
+    this.bucketPolicyOnChange();
   }
 
   createForm() {
@@ -129,7 +142,8 @@ export class RgwBucketFormComponent extends CdForm implements OnInit, AfterViewC
         ]
       ],
       lock_mode: ['COMPLIANCE'],
-      lock_retention_period_days: [0, [CdValidators.number(false), lockDaysValidator]]
+      lock_retention_period_days: [0, [CdValidators.number(false), lockDaysValidator]],
+      bucket_policy: ['{}', CdValidators.json()]
     });
   }
 
@@ -217,6 +231,11 @@ export class RgwBucketFormComponent extends CdForm implements OnInit, AfterViewC
             if (value['lock_enabled']) {
               this.bucketForm.controls['versioning'].disable();
             }
+            if (value['bucket_policy']) {
+              this.bucketForm
+                .get('bucket_policy')
+                .setValue(JSON.stringify(value['bucket_policy'], null, 2));
+            }
           }
         }
         this.loadingReady();
@@ -240,6 +259,7 @@ export class RgwBucketFormComponent extends CdForm implements OnInit, AfterViewC
     }
     const values = this.bucketForm.value;
     const xmlStrTags = this.tagsToXML(this.tags);
+    const bucketPolicy = this.getBucketPolicy();
     if (this.editing) {
       // Edit
       const versioning = this.getVersioningStatus();
@@ -258,7 +278,8 @@ export class RgwBucketFormComponent extends CdForm implements OnInit, AfterViewC
           values['mfa-token-pin'],
           values['lock_mode'],
           values['lock_retention_period_days'],
-          xmlStrTags
+          xmlStrTags,
+          bucketPolicy
         )
         .subscribe(
           () => {
@@ -287,7 +308,8 @@ export class RgwBucketFormComponent extends CdForm implements OnInit, AfterViewC
           values['encryption_enabled'],
           values['encryption_type'],
           values['keyId'],
-          xmlStrTags
+          xmlStrTags,
+          bucketPolicy
         )
         .subscribe(
           () => {
@@ -337,6 +359,10 @@ export class RgwBucketFormComponent extends CdForm implements OnInit, AfterViewC
     return this.isMfaDeleteEnabled ? RgwBucketMfaDelete.ENABLED : RgwBucketMfaDelete.DISABLED;
   }
 
+  getBucketPolicy() {
+    return this.bucketForm.getValue('bucket_policy') || '{}';
+  }
+
   fileUpload(files: FileList, controlName: string) {
     const file: File = files[0];
     const reader = new FileReader();
@@ -349,6 +375,16 @@ export class RgwBucketFormComponent extends CdForm implements OnInit, AfterViewC
     });
   }
 
+  bucketPolicyOnChange() {
+    if (this.bucketPolicyTextArea) {
+      this.textAreaJsonFormatterService.format(this.bucketPolicyTextArea);
+    }
+  }
+
+  openUrl(url: string) {
+    window.open(url, '_blank');
+  }
+
   openConfigModal() {
     const modalRef = this.modalService.show(RgwConfigModalComponent, null, { size: 'lg' });
     modalRef.componentInstance.configForm
@@ -374,6 +410,8 @@ export class RgwBucketFormComponent extends CdForm implements OnInit, AfterViewC
 
   deleteTag(index: number) {
     this.tags.splice(index, 1);
+    this.bucketForm.markAsDirty();
+    this.bucketForm.updateValueAndValidity();
   }
 
   private setTag(tag: Record<string, string>, index?: number) {
index 15821c3b6265be53afe38fcf5937bef892bdbcb3..e1002373806c1f9cbc60f70818ceba16705b9751 100644 (file)
@@ -60,11 +60,12 @@ describe('RgwBucketService', () => {
         true,
         'aws:kms',
         'qwerty1',
+        null,
         null
       )
       .subscribe();
     const req = httpTesting.expectOne(
-      `api/rgw/bucket?bucket=foo&uid=bar&zonegroup=default&placement_target=default-placement&lock_enabled=false&lock_mode=COMPLIANCE&lock_retention_period_days=5&encryption_state=true&encryption_type=aws%253Akms&key_id=qwerty1&tags=null&${RgwHelper.DAEMON_QUERY_PARAM}`
+      `api/rgw/bucket?bucket=foo&uid=bar&zonegroup=default&placement_target=default-placement&lock_enabled=false&lock_mode=COMPLIANCE&lock_retention_period_days=5&encryption_state=true&encryption_type=aws%253Akms&key_id=qwerty1&tags=null&bucket_policy=null&${RgwHelper.DAEMON_QUERY_PARAM}`
     );
     expect(req.request.method).toBe('POST');
   });
@@ -84,11 +85,12 @@ describe('RgwBucketService', () => {
         '223344',
         'GOVERNANCE',
         '10',
+        null,
         null
       )
       .subscribe();
     const req = httpTesting.expectOne(
-      `api/rgw/bucket/foo?${RgwHelper.DAEMON_QUERY_PARAM}&bucket_id=bar&uid=baz&versioning_state=Enabled&encryption_state=true&encryption_type=aws%253Akms&key_id=qwerty1&mfa_delete=Enabled&mfa_token_serial=1&mfa_token_pin=223344&lock_mode=GOVERNANCE&lock_retention_period_days=10&tags=null`
+      `api/rgw/bucket/foo?${RgwHelper.DAEMON_QUERY_PARAM}&bucket_id=bar&uid=baz&versioning_state=Enabled&encryption_state=true&encryption_type=aws%253Akms&key_id=qwerty1&mfa_delete=Enabled&mfa_token_serial=1&mfa_token_pin=223344&lock_mode=GOVERNANCE&lock_retention_period_days=10&tags=null&bucket_policy=null`
     );
     expect(req.request.method).toBe('PUT');
   });
index 87561d92d8996318773c5067a2bc74a4ade8e7bd..a43b2616f9334573aeb500d6b314c72205c8eeca 100644 (file)
@@ -60,7 +60,8 @@ export class RgwBucketService extends ApiClient {
     encryption_state: boolean,
     encryption_type: string,
     key_id: string,
-    tags: string
+    tags: string,
+    bucketPolicy: string
   ) {
     return this.rgwDaemonService.request((params: HttpParams) => {
       return this.http.post(this.url, null, {
@@ -77,6 +78,7 @@ export class RgwBucketService extends ApiClient {
             encryption_type,
             key_id,
             tags: tags,
+            bucket_policy: bucketPolicy,
             daemon_name: params.get('daemon_name')
           }
         })
@@ -97,7 +99,8 @@ export class RgwBucketService extends ApiClient {
     mfaTokenPin: string,
     lockMode: 'GOVERNANCE' | 'COMPLIANCE',
     lockRetentionPeriodDays: string,
-    tags: string
+    tags: string,
+    bucketPolicy: string
   ) {
     return this.rgwDaemonService.request((params: HttpParams) => {
       params = params.appendAll({
@@ -112,7 +115,8 @@ export class RgwBucketService extends ApiClient {
         mfa_token_pin: mfaTokenPin,
         lock_mode: lockMode,
         lock_retention_period_days: lockRetentionPeriodDays,
-        tags: tags
+        tags: tags,
+        bucket_policy: bucketPolicy
       });
       return this.http.put(`${this.url}/${bucket}`, null, { params: params });
     });
index 602a11e7343cd636903b00a8c7377198bcbc2cca..78171f650f5f29ddd277f8fbe4a2baae9cf74305 100644 (file)
@@ -610,4 +610,16 @@ export class CdValidators {
         );
     };
   }
+
+  static json(): ValidatorFn {
+    return (control: AbstractControl): Record<string, any> | null => {
+      if (!control.value) return null;
+      try {
+        JSON.parse(control.value);
+        return null;
+      } catch (e) {
+        return { invalidJson: true };
+      }
+    };
+  }
 }
index a3139f0e26494d3623749b830922b586d0cc3ff8..654220596ad82f98e009cb70087918a0108a66f1 100644 (file)
@@ -1,5 +1,6 @@
 import { Component, ViewChild, ElementRef } from '@angular/core';
 import { FieldType, FieldTypeConfig } from '@ngx-formly/core';
+import { TextAreaJsonFormatterService } from '~/app/shared/services/text-area-json-formatter.service';
 
 @Component({
   selector: 'cd-formly-textarea-type',
@@ -10,16 +11,11 @@ export class FormlyTextareaTypeComponent extends FieldType<FieldTypeConfig> {
   @ViewChild('textArea')
   public textArea: ElementRef<any>;
 
+  constructor(private textAreaJsonFormatterService: TextAreaJsonFormatterService) {
+    super();
+  }
+
   onChange() {
-    const value = this.textArea.nativeElement.value;
-    try {
-      const formatted = JSON.stringify(JSON.parse(value), null, 2);
-      this.textArea.nativeElement.value = formatted;
-      this.textArea.nativeElement.style.height = 'auto';
-      const lineNumber = formatted.split('\n').length;
-      const pixelPerLine = 25;
-      const pixels = lineNumber * pixelPerLine;
-      this.textArea.nativeElement.style.height = pixels + 'px';
-    } catch (e) {}
+    this.textAreaJsonFormatterService.format(this.textArea);
   }
 }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/text-area-json-formatter.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/text-area-json-formatter.service.spec.ts
new file mode 100644 (file)
index 0000000..fc428f9
--- /dev/null
@@ -0,0 +1,16 @@
+import { TestBed } from '@angular/core/testing';
+
+import { TextAreaJsonFormatterService } from './text-area-json-formatter.service';
+
+describe('TextAreaJsonFormatterService', () => {
+  let service: TextAreaJsonFormatterService;
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({});
+    service = TestBed.inject(TextAreaJsonFormatterService);
+  });
+
+  it('should be created', () => {
+    expect(service).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/text-area-json-formatter.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/text-area-json-formatter.service.ts
new file mode 100644 (file)
index 0000000..0e69602
--- /dev/null
@@ -0,0 +1,21 @@
+import { ElementRef, Injectable } from '@angular/core';
+
+@Injectable({
+  providedIn: 'root'
+})
+export class TextAreaJsonFormatterService {
+  constructor() {}
+
+  format(textArea: ElementRef<any>): void {
+    const value = textArea.nativeElement.value;
+    try {
+      const formatted = JSON.stringify(JSON.parse(value), null, 2);
+      textArea.nativeElement.value = formatted;
+      textArea.nativeElement.style.height = 'auto';
+      const lineNumber = formatted.split('\n').length;
+      const pixelPerLine = 20;
+      const pixels = lineNumber * pixelPerLine;
+      textArea.nativeElement.style.height = pixels + 'px';
+    } catch (e) {}
+  }
+}
index 602ee67ee26652bc8f165afde811c9b43542fad4..681d5e54cb9c3233c620714a0362e34002e7b471 100644 (file)
@@ -9355,6 +9355,8 @@ paths:
               properties:
                 bucket:
                   type: string
+                bucket_policy:
+                  type: string
                 daemon_name:
                   type: string
                 encryption_state:
@@ -9668,6 +9670,8 @@ paths:
               properties:
                 bucket_id:
                   type: string
+                bucket_policy:
+                  type: string
                 daemon_name:
                   type: string
                 encryption_state:
index a258d992012041f8b68c512c173ec41ad35752c4..f90045b033f548346a006be445b4dbf0fcf7b42d 100644 (file)
@@ -889,6 +889,28 @@ class RgwClient(RestClient):
                     return None
             raise e
 
+    @RestClient.api_put('/{bucket_name}?policy')
+    def set_bucket_policy(self, bucket_name: str, policy: str, request=None):
+        """
+        Sets the bucket policy for a bucket.
+        :param bucket_name: The name of the bucket.
+        :type bucket_name: str
+        :param policy: The bucket policy.
+        :type policy: JSON Structured Document
+        :return: The bucket policy.
+        :rtype: Dict
+        """
+        # pylint: disable=unused-argument
+        try:
+            request = request(data=policy)
+        except RequestException as e:
+            if e.content:
+                content = json_str_to_object(e.content)
+                if content.get("Code") == "InvalidArgument":
+                    msg = "Invalid JSON document"
+                    raise DashboardException(msg=msg, component='rgw')
+            raise DashboardException(e)
+
     def perform_validations(self, retention_period_days, retention_period_years, mode):
         try:
             retention_period_days = int(retention_period_days) if retention_period_days else 0