]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: user accounts enhancements 64628/head
authorNaman Munet <naman.munet@ibm.com>
Tue, 22 Jul 2025 17:08:42 +0000 (22:38 +0530)
committerNaman Munet <naman.munet@ibm.com>
Fri, 1 Aug 2025 12:36:04 +0000 (18:06 +0530)
fixes: https://tracker.ceph.com/issues/72072

PR covers:
1) Displaying account name instead of account id in bucket list page & bucket edit form for account owned buckets
2) non-root account user can now be assigned with managed policies with which they can perform operations
3) The root user indication shifted next to username in users list rather than on Account Name with a new icon.

Signed-off-by: Naman Munet <naman.munet@ibm.com>
20 files changed:
src/pybind/mgr/dashboard/controllers/rgw.py
src/pybind/mgr/dashboard/frontend/package-lock.json
src/pybind/mgr/dashboard/frontend/package.json
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.scss
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/utils/constants.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-user.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts
src/pybind/mgr/dashboard/openapi.yaml
src/pybind/mgr/dashboard/services/rgw_iam.py

index 1e2d791fe0b7308d39b65d3dfea282788cd7caf4..bbbf5b971adbae0318f445f47c3db2e0d1420f83 100755 (executable)
@@ -546,6 +546,41 @@ class RgwBucket(RgwRESTController):
             bucket_name = '{}:{}'.format(tenant, bucket_name)
         return bucket_name
 
+    def map_bucket_owners(self, result, daemon_name):
+        """
+        Replace bucket owner IDs with account names for a list of buckets.
+
+        :param result: List of bucket dicts with 'owner' keys.
+        :param daemon_name: RGW daemon identifier.
+        :return: Modified result with owner names instead of IDs.
+        """
+        # Get unique owner IDs from buckets
+        owner_ids = {bucket['owner'] for bucket in result}
+
+        # Get available account IDs
+        valid_accounts = set(RgwAccounts().get_accounts())
+
+        # Determine which owner IDs are valid and need querying
+        query_ids = owner_ids & valid_accounts
+
+        # Fetch account names for valid owner IDs
+        id_to_name = {}
+        for owner_id in query_ids:
+            try:
+                account = self.proxy(daemon_name, 'GET', 'account', {'id': owner_id})
+                if 'name' in account:
+                    id_to_name[owner_id] = account['name']
+            except RequestException:
+                continue
+
+        # Replace owner IDs with names in the bucket list
+        for bucket in result:
+            owner_id = bucket.get('owner')
+            if owner_id in id_to_name:
+                bucket['owner'] = id_to_name[owner_id]
+
+        return result
+
     @RESTController.MethodMap(version=APIVersion(1, 1))  # type: ignore
     def list(self, stats: bool = False, daemon_name: Optional[str] = None,
              uid: Optional[str] = None) -> List[Union[str, Dict[str, Any]]]:
@@ -553,9 +588,9 @@ class RgwBucket(RgwRESTController):
         if uid and uid.strip():
             query_params = f'{query_params}&uid={uid.strip()}'
         result = self.proxy(daemon_name, 'GET', 'bucket{}'.format(query_params))
-
-        if stats:
+        if str_to_bool(stats):
             result = [self._append_bid(bucket) for bucket in result]
+            result = self.map_bucket_owners(result, daemon_name)
 
         return result
 
@@ -900,6 +935,9 @@ class RgwUser(RgwRESTController):
         if not self._keys_allowed():
             del result['keys']
             del result['swift_keys']
+        if result.get('account_id') not in (None, '') and result.get('type') != 'root':
+            rgwAccounts = RgwAccounts()
+            result['managed_user_policies'] = rgwAccounts.list_managed_policy(uid)
         result['uid'] = result['full_user_id']
         return result
 
@@ -918,53 +956,94 @@ class RgwUser(RgwRESTController):
     def create(self, uid, display_name, email=None, max_buckets=None,
                system=None, suspended=None, generate_key=None, access_key=None,
                secret_key=None, daemon_name=None, account_id: Optional[str] = None,
-               account_root_user: Optional[bool] = False):
-        params = {'uid': uid}
-        if display_name is not None:
-            params['display-name'] = display_name
-        if email is not None:
-            params['email'] = email
-        if max_buckets is not None:
-            params['max-buckets'] = max_buckets
-        if system is not None:
-            params['system'] = system
-        if suspended is not None:
-            params['suspended'] = suspended
-        if generate_key is not None:
-            params['generate-key'] = generate_key
-        if access_key is not None:
-            params['access-key'] = access_key
-        if secret_key is not None:
-            params['secret-key'] = secret_key
-        if account_id is not None:
-            params['account-id'] = account_id
+               account_root_user: Optional[bool] = False,
+               account_policies: Optional[str] = None):
+        """Create a new RGW user."""
+
+        params = {'uid': uid, 'display-name': display_name}
+
+        # Add optional parameters
+        optional_params = {
+            'email': email,
+            'max-buckets': max_buckets,
+            'system': system,
+            'suspended': suspended,
+            'generate-key': generate_key,
+            'access-key': access_key,
+            'secret-key': secret_key,
+            'account-id': account_id
+        }
+
+        # Add only non-None parameters
+        for key, value in optional_params.items():
+            if value is not None:
+                params[key] = value
+
+        # Handle boolean parameter separately
         if account_root_user:
             params['account-root'] = account_root_user
+
+        # Make the API request
         result = self.proxy(daemon_name, 'PUT', 'user', params)
         result['uid'] = result['full_user_id']
+
+        # Process account policies
+        if account_policies is not None:
+            self._process_account_policies(uid, account_policies)
+
         return result
 
+    def _process_account_policies(self, uid, account_policies):
+        """Process account policies for a user."""
+        rgw_accounts = RgwAccounts()
+        # Parse the policies JSON if it's a string
+        if isinstance(account_policies, str):
+            account_policies = json.loads(account_policies)
+
+        # Attach policies
+        for policy_arn in account_policies.get('attach', []):
+            rgw_accounts.attach_managed_policy(uid, policy_arn)
+
+        # Detach policies
+        for policy_arn in account_policies.get('detach', []):
+            rgw_accounts.detach_managed_policy(uid, policy_arn)
+
     @allow_empty_body
     def set(self, uid, display_name=None, email=None, max_buckets=None,
             system=None, suspended=None, daemon_name=None, account_id: Optional[str] = None,
-            account_root_user: Optional[bool] = False):
+            account_root_user: Optional[bool] = False,
+            account_policies: Optional[str] = None):
+        """Update an existing RGW user."""
+
         params = {'uid': uid}
-        if display_name is not None:
-            params['display-name'] = display_name
-        if email is not None:
-            params['email'] = email
-        if max_buckets is not None:
-            params['max-buckets'] = max_buckets
-        if system is not None:
-            params['system'] = system
-        if suspended is not None:
-            params['suspended'] = suspended
-        if account_id is not None:
-            params['account-id'] = account_id
+
+        # Add optional parameters
+        optional_params = {
+            'display-name': display_name,
+            'email': email,
+            'max-buckets': max_buckets,
+            'system': system,
+            'suspended': suspended,
+            'account-id': account_id
+        }
+
+        # Add only non-None parameters
+        for key, value in optional_params.items():
+            if value is not None:
+                params[key] = value
+
+        # Handle boolean parameter separately
         if account_root_user:
             params['account-root'] = account_root_user
+
+        # Make the API request
         result = self.proxy(daemon_name, 'POST', 'user', params)
         result['uid'] = result['full_user_id']
+
+        # Process account policies
+        if account_policies is not None:
+            self._process_account_policies(uid, account_policies)
+
         return result
 
     def delete(self, uid, daemon_name=None):
index c4351a783b24405535288df8f3086f7d90eb1e99..6ba9e386e6fd3781800576ec7e832524a9077598 100644 (file)
@@ -19,7 +19,7 @@
         "@angular/platform-browser-dynamic": "18.2.11",
         "@angular/router": "18.2.11",
         "@carbon/charts-angular": "1.23.9",
-        "@carbon/icons": "11.41.0",
+        "@carbon/icons": "11.63.0",
         "@carbon/styles": "1.83.0",
         "@ibm/plex": "6.4.0",
         "@ng-bootstrap/ng-bootstrap": "17.0.1",
       "license": "Apache-2.0"
     },
     "node_modules/@carbon/icons": {
-      "version": "11.41.0",
-      "resolved": "https://registry.npmjs.org/@carbon/icons/-/icons-11.41.0.tgz",
-      "integrity": "sha512-9RGaOnihPQx74yBQ0UnEr9JJ+e2aa/J+tmTG/sZ203q2hfoeMF2PqipwOhNS1fqCnyW1zvsYQNydUsNIDzCqaA==",
+      "version": "11.63.0",
+      "resolved": "https://registry.npmjs.org/@carbon/icons/-/icons-11.63.0.tgz",
+      "integrity": "sha512-J5sGbamMMBbQPcdX9ImzEIoa7l2DyNiZYu9ScKtY3dJ6lKeG6GJUFQYH5/vcpuyj02tizVe6rSgWcH210+OOqw==",
       "hasInstallScript": true,
       "license": "Apache-2.0",
       "dependencies": {
index 5d552a87773b3d9315408cc0c309e328d5c6b257..1372db06196de40e14bf2780295333970f79be65 100644 (file)
@@ -52,7 +52,7 @@
     "@angular/platform-browser-dynamic": "18.2.11",
     "@angular/router": "18.2.11",
     "@carbon/charts-angular": "1.23.9",
-    "@carbon/icons": "11.41.0",
+    "@carbon/icons": "11.63.0",
     "@carbon/styles": "1.83.0",
     "@ibm/plex": "6.4.0",
     "@ng-bootstrap/ng-bootstrap": "17.0.1",
index b3b6f0f35fb1ecd8df1e7bbb952941b30dc5c458..15790fd2f8e4f456f729964c8f6e22486b8dc950 100644 (file)
@@ -231,7 +231,12 @@ export class RgwBucketFormComponent extends CdForm implements OnInit, AfterViewC
     }
 
     // Process route parameters.
-    this.route.params.subscribe((params: { bid: string }) => {
+    this.route.params.subscribe((params: { bid: string; owner: string }) => {
+      let bucketOwner = '';
+      if (params.hasOwnProperty('owner')) {
+        // only used for showing bucket owned by account
+        bucketOwner = decodeURIComponent(params.owner);
+      }
       if (params.hasOwnProperty('bid')) {
         const bid = decodeURIComponent(params.bid);
         promises['getBid'] = this.rgwBucketService.get(bid);
@@ -299,13 +304,13 @@ export class RgwBucketFormComponent extends CdForm implements OnInit, AfterViewC
               // creating dummy user object to show the account owner
               // here value['owner] is the account user id
               const user = Object.assign(
-                { uid: value['owner'] },
+                { uid: bucketOwner },
                 ownersList.find((owner: RgwUser) => owner.uid === AppConstants.defaultUser)
               );
               this.accountUsers.push(user);
               this.bucketForm.get('isAccountOwner').setValue(true);
               this.bucketForm.get('isAccountOwner').disable();
-              this.bucketForm.get('accountUser').setValue(value['owner']);
+              this.bucketForm.get('accountUser').setValue(bucketOwner);
               this.bucketForm.get('accountUser').disable();
             }
             this.isVersioningAlreadyEnabled = this.isVersioningEnabled;
index dae12f99a288962afb91f1d822fbca9343f45621..4248c5ff2a2f0061c202e9319760f16b524be655 100644 (file)
@@ -110,7 +110,10 @@ export class RgwBucketListComponent extends ListWithDetails implements OnInit, O
       }
     ];
     const getBucketUri = () =>
-      this.selection.first() && `${encodeURIComponent(this.selection.first().bid)}`;
+      this.selection.first() &&
+      `${encodeURIComponent(this.selection.first().bid)}/${encodeURIComponent(
+        this.selection.first().owner
+      )}`;
     const addAction: CdTableAction = {
       permission: 'create',
       icon: Icons.add,
index 8d05bb3b314bb495cf99b7266319e975c26b0202..c9d7719d6b6f478cdc3170a3332b71d0651cee28 100644 (file)
               class="bold">Maximum buckets</td>
           <td>{{ user.max_buckets | map: maxBucketsMap }}</td>
         </tr>
+        @if (user.type === 'rgw' && selection.account?.id){
+        <tr>
+          <td i18n
+              class="bold">Managed policies</td>
+          <td i18n>{{ extractPolicyNamesFromArns(user.managed_user_policies) }}</td>
+        </tr>
+        }
         <tr *ngIf="user.subusers && user.subusers.length">
           <td i18n
               class="bold">Subusers</td>
index c308bc6d94b161011dc42be3790c14aef05dc6f6..4267f6c03b67ca6bebb3729fdffdfc0864d406dd 100644 (file)
@@ -56,7 +56,9 @@ describe('RgwUserDetailsComponent', () => {
       system: 'true',
       keys: [],
       swift_keys: [],
-      mfa_ids: ['testMFA1', 'testMFA2']
+      mfa_ids: ['testMFA1', 'testMFA2'],
+      type: 'rgw',
+      account: { id: 'RGW12345678901234567' }
     };
 
     component.ngOnChanges();
@@ -65,8 +67,8 @@ describe('RgwUserDetailsComponent', () => {
     const detailsTab = fixture.debugElement.nativeElement.querySelectorAll(
       '.cds--data-table--sort.cds--data-table--no-border tr td'
     );
-    expect(detailsTab[14].textContent).toEqual('MFAs(Id)');
-    expect(detailsTab[15].textContent).toEqual('testMFA1, testMFA2');
+    expect(detailsTab[16].textContent).toEqual('MFAs(Id)');
+    expect(detailsTab[17].textContent).toEqual('testMFA1, testMFA2');
   });
   it('should test updateKeysSelection', () => {
     component.selection = {
index dd2f6d35e20962d2787e458a7905202eb0b55222..f95134494d9912c2c2c3f0ed72b0086d4cc700e0 100644 (file)
@@ -136,4 +136,14 @@ export class RgwUserDetailsComponent implements OnChanges, OnInit {
         break;
     }
   }
+
+  extractPolicyNamesFromArns(arnList: string[]) {
+    if (!arnList || arnList.length === 0) {
+      return '-';
+    }
+    return arnList
+      .map((arn) => arn.trim().split('/').pop())
+      .filter(Boolean)
+      .join(', ');
+  }
 }
index c38780ee32d5932adf1d5cc9b8dbab1b47e31b5b..172aaa747a784d1d04376d38c260d1359600fd4d 100644 (file)
@@ -23,7 +23,7 @@
                   [ngValue]="null">Loading...</option>
           <option i18n
                   *ngIf="accounts !== null"
-                  [ngValue]="null">-- Select an Account --</option>
+                  [value]="''">-- Select an Account --</option>
           <option *ngFor="let account of accounts"
                   [value]="account.id">{{ account.name }} {{account.tenant ? '- '+account.tenant : ''}}</option>
         </cds-select>
       </cds-checkbox>
     </div>
 
+    @if(userForm.getValue('account_id') && !userForm.getValue('account_root_user')) {
+    <!-- Managed policies -->
+    <fieldset>
+      <div class="form-item">
+        <legend i18n
+                class="cd-header">Managed policies</legend>
+        <cds-combo-box label="Policies"
+                       type="multi"
+                       selectionFeedback="top-after-reopen"
+                       formControlName="account_policies"
+                       id="account_policies"
+                       placeholder="Select managed policies..."
+                       i18n-placeholder
+                       [appendInline]="true"
+                       [items]="managedPolicies"
+                       (selected)="multiSelector($event)"
+                       itemValueKey="name"
+                       i18n-label
+                       i18n>
+          <cds-dropdown-list></cds-dropdown-list>
+        </cds-combo-box>
+      </div>
+    </fieldset>
+    }
+
     <!-- S3 key -->
     <fieldset *ngIf="!editing">
       <legend i18n
index efdf125ad9ecbf62fb45dee60cf2e9d7ec9adfd1..915bd38e9ed95b5a240791173715aae1d8ab3912 100644 (file)
@@ -230,9 +230,7 @@ describe('RgwUserFormComponent', () => {
         secret_key: '',
         suspended: false,
         system: false,
-        uid: null,
-        account_id: '',
-        account_root_user: false
+        uid: null
       });
       expect(spyRateLimit).toHaveBeenCalled();
     });
@@ -248,8 +246,7 @@ describe('RgwUserFormComponent', () => {
         email: null,
         max_buckets: -1,
         suspended: false,
-        system: false,
-        account_root_user: false
+        system: false
       });
       expect(spyRateLimit).toHaveBeenCalled();
     });
@@ -268,9 +265,7 @@ describe('RgwUserFormComponent', () => {
         secret_key: '',
         suspended: false,
         system: false,
-        uid: null,
-        account_id: '',
-        account_root_user: false
+        uid: null
       });
       expect(spyRateLimit).toHaveBeenCalled();
     });
@@ -286,8 +281,7 @@ describe('RgwUserFormComponent', () => {
         email: null,
         max_buckets: 0,
         suspended: false,
-        system: false,
-        account_root_user: false
+        system: false
       });
       expect(spyRateLimit).toHaveBeenCalled();
     });
@@ -308,9 +302,7 @@ describe('RgwUserFormComponent', () => {
         secret_key: '',
         suspended: false,
         system: false,
-        uid: null,
-        account_id: '',
-        account_root_user: false
+        uid: null
       });
       expect(spyRateLimit).toHaveBeenCalled();
     });
@@ -327,8 +319,7 @@ describe('RgwUserFormComponent', () => {
         email: null,
         max_buckets: 100,
         suspended: false,
-        system: false,
-        account_root_user: false
+        system: false
       });
       expect(spyRateLimit).toHaveBeenCalled();
     });
@@ -358,8 +349,7 @@ describe('RgwUserFormComponent', () => {
         email: '',
         max_buckets: 1000,
         suspended: false,
-        system: false,
-        account_root_user: false
+        system: false
       });
     });
 
index fc4f15d2c1e19d6e9820307c308d10bdc935de3c..a46a3c5cc3f00abe4b7366973b7c674f53d5c913 100644 (file)
@@ -29,7 +29,8 @@ import { RgwRateLimitConfig } from '../models/rgw-rate-limit';
 import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
 import { RgwUserAccountsService } from '~/app/shared/api/rgw-user-accounts.service';
 import { Account } from '../models/rgw-user-accounts';
-import { RGW } from '../utils/constants';
+import { ManagedPolicyArnMap, ManagedPolicyName, RGW } from '../utils/constants';
+import { ComboBoxItem } from '~/app/shared/models/combo-box.model';
 
 @Component({
   selector: 'cd-rgw-user-form',
@@ -56,6 +57,19 @@ export class RgwUserFormComponent extends CdForm implements OnInit {
   previousTenant: string = null;
   @ViewChild(RgwRateLimitComponent, { static: false }) rateLimitComponent!: RgwRateLimitComponent;
   accounts: Account[] = [];
+  initialUserPolicies: string[] = [];
+  managedPolicies: ComboBoxItem[] = [
+    {
+      content: ManagedPolicyName.AmazonS3FullAccess,
+      name: ManagedPolicyArnMap[ManagedPolicyName.AmazonS3FullAccess],
+      selected: false
+    },
+    {
+      content: ManagedPolicyName.AmazonS3ReadOnlyAccess,
+      name: ManagedPolicyArnMap[ManagedPolicyName.AmazonS3ReadOnlyAccess],
+      selected: false
+    }
+  ];
 
   constructor(
     private formBuilder: CdFormBuilder,
@@ -117,6 +131,7 @@ export class RgwUserFormComponent extends CdForm implements OnInit {
       ],
       account_id: [null, [this.tenantedAccountValidator.bind(this)]],
       account_root_user: [false],
+      account_policies: [[]],
       max_buckets_mode: [1],
       max_buckets: [
         1000,
@@ -261,6 +276,15 @@ export class RgwUserFormComponent extends CdForm implements OnInit {
           });
           this.capabilities = resp[0].caps;
           this.uid = this.getUID();
+          this.initialUserPolicies = resp[0].managed_user_policies ?? [];
+
+          this.managedPolicies.forEach((policy) => {
+            policy.selected = this.initialUserPolicies.includes(policy.name);
+          });
+
+          // Optionally, update form control with selected items
+          const selectedItems = this.managedPolicies.filter((p) => p.selected).map((p) => p.name);
+          this.userForm.get('account_policies')?.setValue(selectedItems);
         },
         () => {
           this.loadingError();
@@ -297,6 +321,12 @@ export class RgwUserFormComponent extends CdForm implements OnInit {
     });
   }
 
+  multiSelector(event: ComboBoxItem[]) {
+    this.managedPolicies.forEach((policy) => {
+      policy.selected = !!event.find((selected) => selected.name === policy.name);
+    });
+  }
+
   tenantedAccountValidator(control: AbstractControl): ValidationErrors | null {
     if (this?.userForm?.getValue('tenant') && this.accounts.length > 0) {
       const index: number = this.accounts.findIndex(
@@ -655,7 +685,8 @@ export class RgwUserFormComponent extends CdForm implements OnInit {
       'system',
       'suspended',
       'account_id',
-      'account_root_user'
+      'account_root_user',
+      'account_policies'
     ].some((path) => {
       return this.userForm.get(path).dirty;
     });
@@ -700,8 +731,6 @@ export class RgwUserFormComponent extends CdForm implements OnInit {
   private _getCreateArgs() {
     const result = {
       uid: this.getUID(),
-      account_id: this.userForm.getValue('account_id') ? this.userForm.getValue('account_id') : '',
-      account_root_user: this.userForm.getValue('account_root_user'),
       display_name: this.userForm.getValue('display_name'),
       system: this.userForm.getValue('system'),
       suspended: this.userForm.getValue('suspended'),
@@ -729,6 +758,16 @@ export class RgwUserFormComponent extends CdForm implements OnInit {
       //  0 => Unlimited bucket creation.
       _.merge(result, { max_buckets: maxBucketsMode });
     }
+    if (this.userForm.getValue('account_id')) {
+      _.merge(result, {
+        account_id: this.userForm.getValue('account_id'),
+        account_root_user: this.userForm.getValue('account_root_user')
+      });
+    }
+    const accountPolicies = this._getAccountManagedPolicies();
+    if (this.userForm.getValue('account_id') && !this.userForm.getValue('account_root_user')) {
+      _.merge(result, { account_policies: accountPolicies });
+    }
     return result;
   }
 
@@ -738,19 +777,13 @@ export class RgwUserFormComponent extends CdForm implements OnInit {
    */
   private _getUpdateArgs() {
     const result: Record<string, any> = {};
-    const keys = [
-      'display_name',
-      'email',
-      'max_buckets',
-      'system',
-      'suspended',
-      'account_root_user'
-    ];
+    const keys = ['display_name', 'email', 'max_buckets', 'system', 'suspended'];
     for (const key of keys) {
       result[key] = this.userForm.getValue(key);
     }
     if (this.userForm.getValue('account_id')) {
       result['account_id'] = this.userForm.getValue('account_id');
+      result['account_root_user'] = this.userForm.getValue('account_root_user');
     }
     const maxBucketsMode = parseInt(this.userForm.getValue('max_buckets_mode'), 10);
     if (_.includes([-1, 0], maxBucketsMode)) {
@@ -758,6 +791,10 @@ export class RgwUserFormComponent extends CdForm implements OnInit {
       //  0 => Unlimited bucket creation.
       result['max_buckets'] = maxBucketsMode;
     }
+    const accountPolicies = this._getAccountManagedPolicies();
+    if (this.userForm.getValue('account_id') && !this.userForm.getValue('account_root_user')) {
+      result['account_policies'] = accountPolicies;
+    }
     return result;
   }
 
@@ -831,6 +868,25 @@ export class RgwUserFormComponent extends CdForm implements OnInit {
     return result;
   }
 
+  /**
+   * Get the account managed policies to attach/detach.
+   * @returns {Object} Returns an object with attach and detach arrays.
+   */
+  private _getAccountManagedPolicies() {
+    const selectedPolicies = this.managedPolicies.filter((p) => p.selected).map((p) => p.name);
+
+    const initialPolicies = this.initialUserPolicies;
+    const toAttach = selectedPolicies.filter((p) => !initialPolicies.includes(p));
+    const toDetach = initialPolicies.filter((p) => !selectedPolicies.includes(p));
+
+    const payload = {
+      attach: toAttach,
+      detach: toDetach
+    };
+
+    return payload;
+  }
+
   onMaxBucketsModeChange(mode: string) {
     if (mode === '1') {
       // If 'Custom' mode is selected, then ensure that the form field
index 361a1e30b66223f4fb92bf9aacdf3fca77c4e198..daeff590c5ffd8ba7eaeccf97c5aff398489a208 100644 (file)
                i18n>No Limit</ng-template>
 </ng-template>
 
-<ng-template #accountTmpl
+<ng-template #usernameTpl
              let-row="data.row">
-  <cds-tooltip [description]="row.account?.name ? (row.type === 'root' ? 'Account root user' :'') : ''"
-               [align]="'top'"
-               i18n-description
-               i18n>{{row.account?.name}}</cds-tooltip>
+  <div cdsRow>
+    <span i18n>{{ row.uid }}</span>
+    @if (row.type === 'root') {
+    <cds-tooltip [description]="'Account root user'"
+                 [align]="'top'"
+                 i18n-description>
+      <svg [cdsIcon]="icons.userAccessLocked"
+           [size]="icons.size16"
+           class="account-root-icon"></svg>
+    </cds-tooltip>
+    }
+  </div>
 </ng-template>
index 64f5e72bae11722419b79ac16e9dd8214e9a0c91..80e29fb8a318c6cd8630999d438b2624153329bc 100644 (file)
@@ -38,8 +38,8 @@ export class RgwUserListComponent extends ListWithDetails implements OnInit {
   userSizeTpl: TemplateRef<any>;
   @ViewChild('userObjectTpl', { static: true })
   userObjectTpl: TemplateRef<any>;
-  @ViewChild('accountTmpl', { static: true })
-  public accountTmpl: TemplateRef<any>;
+  @ViewChild('usernameTpl', { static: true })
+  usernameTpl: TemplateRef<any>;
   permission: Permission;
   tableActions: CdTableAction[];
   columns: CdTableColumn[] = [];
@@ -48,6 +48,7 @@ export class RgwUserListComponent extends ListWithDetails implements OnInit {
   selection: CdTableSelection = new CdTableSelection();
   userDataSubject = new Subject();
   declare staleTimeout: number;
+  icons = Icons;
 
   constructor(
     private authStorageService: AuthStorageService,
@@ -67,7 +68,8 @@ export class RgwUserListComponent extends ListWithDetails implements OnInit {
       {
         name: $localize`Username`,
         prop: 'uid',
-        flexGrow: 1
+        flexGrow: 1,
+        cellTemplate: this.usernameTpl
       },
       {
         name: $localize`Tenant`,
@@ -77,8 +79,7 @@ export class RgwUserListComponent extends ListWithDetails implements OnInit {
       {
         name: $localize`Account name`,
         prop: 'account.name',
-        flexGrow: 1,
-        cellTemplate: this.accountTmpl
+        flexGrow: 1
       },
       {
         name: $localize`Full name`,
index c7156140034dd1bc426f8db9e11f0a6c0388f8ea..c79ed0dfb694e152a28034b38150e79917209c43 100644 (file)
@@ -97,6 +97,7 @@ import ArrowDownIcon from '@carbon/icons/es/arrow--down/16';
 import ProgressBarRoundIcon from '@carbon/icons/es/progress-bar--round/32';
 import ToolsIcon from '@carbon/icons/es/tools/32';
 import ParentChild from '@carbon/icons/es/parent-child/20';
+import UserAccessLocked from '@carbon/icons/es/user--access-locked/16';
 
 import { CephSharedModule } from '../shared/ceph-shared.module';
 import { RgwUserAccountsComponent } from './rgw-user-accounts/rgw-user-accounts.component';
@@ -234,7 +235,8 @@ export class RgwModule {
       ArrowDownIcon,
       ProgressBarRoundIcon,
       ToolsIcon,
-      ParentChild
+      ParentChild,
+      UserAccessLocked
     ]);
   }
 }
@@ -332,7 +334,7 @@ const routes: Routes = [
         data: { breadcrumbs: ActionLabels.CREATE }
       },
       {
-        path: `${URLVerbs.EDIT}/:bid`,
+        path: `${URLVerbs.EDIT}/:bid/:owner`,
         component: RgwBucketFormComponent,
         data: { breadcrumbs: ActionLabels.EDIT }
       }
index a369498cf70babe843961b020b99af13852f8ca7..f87e79a0e5d240115e38cf2562f4507466e196ba 100644 (file)
@@ -1 +1,11 @@
 export const RGW = 'rgw';
+
+export enum ManagedPolicyName {
+  AmazonS3FullAccess = 'AmazonS3FullAccess',
+  AmazonS3ReadOnlyAccess = 'AmazonS3ReadOnlyAccess'
+}
+
+export const ManagedPolicyArnMap: Record<ManagedPolicyName, string> = {
+  [ManagedPolicyName.AmazonS3FullAccess]: 'arn:aws:iam::aws:policy/AmazonS3FullAccess',
+  [ManagedPolicyName.AmazonS3ReadOnlyAccess]: 'arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess'
+};
index c691085a4aab6cfd52b7af294ccaece23605499c..118467619de09c771553526f8b9962ab41d4c33b 100644 (file)
@@ -69,6 +69,9 @@ export class RgwUserService {
   create(args: Record<string, any>) {
     return this.rgwDaemonService.request((params: HttpParams) => {
       _.keys(args).forEach((key) => {
+        if (typeof args[key] === 'object') {
+          args[key] = JSON.stringify(args[key]);
+        }
         params = params.append(key, args[key]);
       });
       return this.http.post(this.url, null, { params: params });
@@ -78,6 +81,9 @@ export class RgwUserService {
   update(uid: string, args: Record<string, any>) {
     return this.rgwDaemonService.request((params: HttpParams) => {
       _.keys(args).forEach((key) => {
+        if (typeof args[key] === 'object') {
+          args[key] = JSON.stringify(args[key]);
+        }
         params = params.append(key, args[key]);
       });
       return this.http.put(`${this.url}/${uid}`, null, { params: params });
index f1895743c7945869538513c420e985b00bcb1996..73d2acd39524cb98637342d6ec12af357fc639d0 100644 (file)
@@ -84,6 +84,7 @@ export enum Icons {
   parentChild = 'parent-child',
   dataTable = 'data-table',
   idea = 'idea',
+  userAccessLocked = 'user--access-locked', // User access locked
   /* Icons for special effect */
   size16 = '16',
   size20 = '20',
index e74eff6bc5c351c2286322c40d841abb1a984971..4a2715e4bf0207df716086b1a4a76cbbda5f5d93 100755 (executable)
@@ -13999,6 +13999,7 @@ paths:
       tags:
       - RgwUser
     post:
+      description: Create a new RGW user.
       parameters: []
       requestBody:
         content:
@@ -14009,6 +14010,8 @@ paths:
                   type: string
                 account_id:
                   type: integer
+                account_policies:
+                  type: integer
                 account_root_user:
                   default: false
                   type: integer
@@ -14182,6 +14185,7 @@ paths:
       tags:
       - RgwUser
     put:
+      description: Update an existing RGW user.
       parameters:
       - in: path
         name: uid
@@ -14195,6 +14199,8 @@ paths:
               properties:
                 account_id:
                   type: integer
+                account_policies:
+                  type: integer
                 account_root_user:
                   default: false
                   type: integer
index 31cefd3a026cac4e4f719bfed384ac383fb75a15..ed1bfe09581d27003f0054490ec5db6d5812f2da 100644 (file)
@@ -42,3 +42,43 @@ class RgwAccounts:
                                 '--account-id', account_id]
 
         return cls.send_rgw_cmd(set_quota_status_cmd)
+
+    @classmethod
+    def attach_managed_policy(cls, userId, policy_arn):
+        radosgw_attach_managed_policies = ['user', 'policy', 'attach',
+                                           '--uid', userId, '--policy-arn', policy_arn]
+        try:
+            exit_code, _, err = mgr.send_rgwadmin_command(radosgw_attach_managed_policies,
+                                                          stdout_as_json=False)
+            if exit_code > 0:
+                raise DashboardException(e=err, msg='Unable to attach managed policies',
+                                         http_status_code=500, component='rgw')
+        except SubprocessError as error:
+            raise DashboardException(error, http_status_code=500, component='rgw')
+
+    @classmethod
+    def detach_managed_policy(cls, userId, policy_arn):
+        radosgw_detach_managed_policy = ['user', 'policy', 'detach',
+                                         '--uid', userId, '--policy-arn', policy_arn]
+        try:
+            exit_code, _, err = mgr.send_rgwadmin_command(radosgw_detach_managed_policy,
+                                                          stdout_as_json=False)
+            if exit_code > 0:
+                raise DashboardException(e=err, msg='Unable to detach managed policies',
+                                         http_status_code=500, component='rgw')
+
+        except SubprocessError as error:
+            raise DashboardException(error, http_status_code=500, component='rgw')
+
+    @classmethod
+    def list_managed_policy(cls, userId):
+        radosgw_list_managed_policies = ['user', 'policy', 'list', 'attached',
+                                         '--uid', userId]
+        try:
+            exit_code, out, err = mgr.send_rgwadmin_command(radosgw_list_managed_policies)
+            if exit_code > 0:
+                raise DashboardException(e=err, msg='Unable to get managed policies',
+                                         http_status_code=500, component='rgw')
+            return out
+        except SubprocessError as error:
+            raise DashboardException(error, http_status_code=500, component='rgw')