Skip to content

Commit 6e9db91

Browse files
author
Naman Munet
committed
mgr/dashboard: user accounts enhancements
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 <[email protected]>
1 parent 057888b commit 6e9db91

20 files changed

+345
-89
lines changed

src/pybind/mgr/dashboard/controllers/rgw.py

Lines changed: 114 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -546,16 +546,51 @@ def get_s3_bucket_name(bucket_name, tenant=None):
546546
bucket_name = '{}:{}'.format(tenant, bucket_name)
547547
return bucket_name
548548

549+
def map_bucket_owners(self, result, daemon_name):
550+
"""
551+
Replace bucket owner IDs with account names for a list of buckets.
552+
553+
:param result: List of bucket dicts with 'owner' keys.
554+
:param daemon_name: RGW daemon identifier.
555+
:return: Modified result with owner names instead of IDs.
556+
"""
557+
# Get unique owner IDs from buckets
558+
owner_ids = {bucket['owner'] for bucket in result}
559+
560+
# Get available account IDs
561+
valid_accounts = set(RgwAccounts().get_accounts())
562+
563+
# Determine which owner IDs are valid and need querying
564+
query_ids = owner_ids & valid_accounts
565+
566+
# Fetch account names for valid owner IDs
567+
id_to_name = {}
568+
for owner_id in query_ids:
569+
try:
570+
account = self.proxy(daemon_name, 'GET', 'account', {'id': owner_id})
571+
if 'name' in account:
572+
id_to_name[owner_id] = account['name']
573+
except RequestException:
574+
continue
575+
576+
# Replace owner IDs with names in the bucket list
577+
for bucket in result:
578+
owner_id = bucket.get('owner')
579+
if owner_id in id_to_name:
580+
bucket['owner'] = id_to_name[owner_id]
581+
582+
return result
583+
549584
@RESTController.MethodMap(version=APIVersion(1, 1)) # type: ignore
550585
def list(self, stats: bool = False, daemon_name: Optional[str] = None,
551586
uid: Optional[str] = None) -> List[Union[str, Dict[str, Any]]]:
552587
query_params = f'?stats={str_to_bool(stats)}'
553588
if uid and uid.strip():
554589
query_params = f'{query_params}&uid={uid.strip()}'
555590
result = self.proxy(daemon_name, 'GET', 'bucket{}'.format(query_params))
556-
557-
if stats:
591+
if str_to_bool(stats):
558592
result = [self._append_bid(bucket) for bucket in result]
593+
result = self.map_bucket_owners(result, daemon_name)
559594

560595
return result
561596

@@ -900,6 +935,9 @@ def _get(self, uid, daemon_name=None, stats=True) -> dict:
900935
if not self._keys_allowed():
901936
del result['keys']
902937
del result['swift_keys']
938+
if result.get('account_id') not in (None, '') and result.get('type') != 'root':
939+
rgwAccounts = RgwAccounts()
940+
result['managed_user_policies'] = rgwAccounts.list_managed_policy(uid)
903941
result['uid'] = result['full_user_id']
904942
return result
905943

@@ -918,53 +956,94 @@ def get_emails(self, daemon_name=None):
918956
def create(self, uid, display_name, email=None, max_buckets=None,
919957
system=None, suspended=None, generate_key=None, access_key=None,
920958
secret_key=None, daemon_name=None, account_id: Optional[str] = None,
921-
account_root_user: Optional[bool] = False):
922-
params = {'uid': uid}
923-
if display_name is not None:
924-
params['display-name'] = display_name
925-
if email is not None:
926-
params['email'] = email
927-
if max_buckets is not None:
928-
params['max-buckets'] = max_buckets
929-
if system is not None:
930-
params['system'] = system
931-
if suspended is not None:
932-
params['suspended'] = suspended
933-
if generate_key is not None:
934-
params['generate-key'] = generate_key
935-
if access_key is not None:
936-
params['access-key'] = access_key
937-
if secret_key is not None:
938-
params['secret-key'] = secret_key
939-
if account_id is not None:
940-
params['account-id'] = account_id
959+
account_root_user: Optional[bool] = False,
960+
account_policies: Optional[str] = None):
961+
"""Create a new RGW user."""
962+
963+
params = {'uid': uid, 'display-name': display_name}
964+
965+
# Add optional parameters
966+
optional_params = {
967+
'email': email,
968+
'max-buckets': max_buckets,
969+
'system': system,
970+
'suspended': suspended,
971+
'generate-key': generate_key,
972+
'access-key': access_key,
973+
'secret-key': secret_key,
974+
'account-id': account_id
975+
}
976+
977+
# Add only non-None parameters
978+
for key, value in optional_params.items():
979+
if value is not None:
980+
params[key] = value
981+
982+
# Handle boolean parameter separately
941983
if account_root_user:
942984
params['account-root'] = account_root_user
985+
986+
# Make the API request
943987
result = self.proxy(daemon_name, 'PUT', 'user', params)
944988
result['uid'] = result['full_user_id']
989+
990+
# Process account policies
991+
if account_policies is not None:
992+
self._process_account_policies(uid, account_policies)
993+
945994
return result
946995

996+
def _process_account_policies(self, uid, account_policies):
997+
"""Process account policies for a user."""
998+
rgw_accounts = RgwAccounts()
999+
# Parse the policies JSON if it's a string
1000+
if isinstance(account_policies, str):
1001+
account_policies = json.loads(account_policies)
1002+
1003+
# Attach policies
1004+
for policy_arn in account_policies.get('attach', []):
1005+
rgw_accounts.attach_managed_policy(uid, policy_arn)
1006+
1007+
# Detach policies
1008+
for policy_arn in account_policies.get('detach', []):
1009+
rgw_accounts.detach_managed_policy(uid, policy_arn)
1010+
9471011
@allow_empty_body
9481012
def set(self, uid, display_name=None, email=None, max_buckets=None,
9491013
system=None, suspended=None, daemon_name=None, account_id: Optional[str] = None,
950-
account_root_user: Optional[bool] = False):
1014+
account_root_user: Optional[bool] = False,
1015+
account_policies: Optional[str] = None):
1016+
"""Update an existing RGW user."""
1017+
9511018
params = {'uid': uid}
952-
if display_name is not None:
953-
params['display-name'] = display_name
954-
if email is not None:
955-
params['email'] = email
956-
if max_buckets is not None:
957-
params['max-buckets'] = max_buckets
958-
if system is not None:
959-
params['system'] = system
960-
if suspended is not None:
961-
params['suspended'] = suspended
962-
if account_id is not None:
963-
params['account-id'] = account_id
1019+
1020+
# Add optional parameters
1021+
optional_params = {
1022+
'display-name': display_name,
1023+
'email': email,
1024+
'max-buckets': max_buckets,
1025+
'system': system,
1026+
'suspended': suspended,
1027+
'account-id': account_id
1028+
}
1029+
1030+
# Add only non-None parameters
1031+
for key, value in optional_params.items():
1032+
if value is not None:
1033+
params[key] = value
1034+
1035+
# Handle boolean parameter separately
9641036
if account_root_user:
9651037
params['account-root'] = account_root_user
1038+
1039+
# Make the API request
9661040
result = self.proxy(daemon_name, 'POST', 'user', params)
9671041
result['uid'] = result['full_user_id']
1042+
1043+
# Process account policies
1044+
if account_policies is not None:
1045+
self._process_account_policies(uid, account_policies)
1046+
9681047
return result
9691048

9701049
def delete(self, uid, daemon_name=None):

src/pybind/mgr/dashboard/frontend/package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/pybind/mgr/dashboard/frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
"@angular/platform-browser-dynamic": "18.2.11",
5353
"@angular/router": "18.2.11",
5454
"@carbon/charts-angular": "1.23.9",
55-
"@carbon/icons": "11.41.0",
55+
"@carbon/icons": "11.63.0",
5656
"@carbon/styles": "1.83.0",
5757
"@ibm/plex": "6.4.0",
5858
"@ng-bootstrap/ng-bootstrap": "17.0.1",

src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,12 @@ export class RgwBucketFormComponent extends CdForm implements OnInit, AfterViewC
231231
}
232232

233233
// Process route parameters.
234-
this.route.params.subscribe((params: { bid: string }) => {
234+
this.route.params.subscribe((params: { bid: string; owner: string }) => {
235+
let bucketOwner = '';
236+
if (params.hasOwnProperty('owner')) {
237+
// only used for showing bucket owned by account
238+
bucketOwner = decodeURIComponent(params.owner);
239+
}
235240
if (params.hasOwnProperty('bid')) {
236241
const bid = decodeURIComponent(params.bid);
237242
promises['getBid'] = this.rgwBucketService.get(bid);
@@ -299,13 +304,13 @@ export class RgwBucketFormComponent extends CdForm implements OnInit, AfterViewC
299304
// creating dummy user object to show the account owner
300305
// here value['owner] is the account user id
301306
const user = Object.assign(
302-
{ uid: value['owner'] },
307+
{ uid: bucketOwner },
303308
ownersList.find((owner: RgwUser) => owner.uid === AppConstants.defaultUser)
304309
);
305310
this.accountUsers.push(user);
306311
this.bucketForm.get('isAccountOwner').setValue(true);
307312
this.bucketForm.get('isAccountOwner').disable();
308-
this.bucketForm.get('accountUser').setValue(value['owner']);
313+
this.bucketForm.get('accountUser').setValue(bucketOwner);
309314
this.bucketForm.get('accountUser').disable();
310315
}
311316
this.isVersioningAlreadyEnabled = this.isVersioningEnabled;

src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,10 @@ export class RgwBucketListComponent extends ListWithDetails implements OnInit, O
110110
}
111111
];
112112
const getBucketUri = () =>
113-
this.selection.first() && `${encodeURIComponent(this.selection.first().bid)}`;
113+
this.selection.first() &&
114+
`${encodeURIComponent(this.selection.first().bid)}/${encodeURIComponent(
115+
this.selection.first().owner
116+
)}`;
114117
const addAction: CdTableAction = {
115118
permission: 'create',
116119
icon: Icons.add,

src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.html

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,13 @@
6464
class="bold">Maximum buckets</td>
6565
<td>{{ user.max_buckets | map: maxBucketsMap }}</td>
6666
</tr>
67+
@if (user.type === 'rgw' && selection.account?.id){
68+
<tr>
69+
<td i18n
70+
class="bold">Managed policies</td>
71+
<td i18n>{{ extractPolicyNamesFromArns(user.managed_user_policies) }}</td>
72+
</tr>
73+
}
6774
<tr *ngIf="user.subusers && user.subusers.length">
6875
<td i18n
6976
class="bold">Subusers</td>

src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.spec.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,9 @@ describe('RgwUserDetailsComponent', () => {
5656
system: 'true',
5757
keys: [],
5858
swift_keys: [],
59-
mfa_ids: ['testMFA1', 'testMFA2']
59+
mfa_ids: ['testMFA1', 'testMFA2'],
60+
type: 'rgw',
61+
account: { id: 'RGW12345678901234567' }
6062
};
6163

6264
component.ngOnChanges();
@@ -65,8 +67,8 @@ describe('RgwUserDetailsComponent', () => {
6567
const detailsTab = fixture.debugElement.nativeElement.querySelectorAll(
6668
'.cds--data-table--sort.cds--data-table--no-border tr td'
6769
);
68-
expect(detailsTab[14].textContent).toEqual('MFAs(Id)');
69-
expect(detailsTab[15].textContent).toEqual('testMFA1, testMFA2');
70+
expect(detailsTab[16].textContent).toEqual('MFAs(Id)');
71+
expect(detailsTab[17].textContent).toEqual('testMFA1, testMFA2');
7072
});
7173
it('should test updateKeysSelection', () => {
7274
component.selection = {

src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,4 +136,14 @@ export class RgwUserDetailsComponent implements OnChanges, OnInit {
136136
break;
137137
}
138138
}
139+
140+
extractPolicyNamesFromArns(arnList: string[]) {
141+
if (!arnList || arnList.length === 0) {
142+
return '-';
143+
}
144+
return arnList
145+
.map((arn) => arn.trim().split('/').pop())
146+
.filter(Boolean)
147+
.join(', ');
148+
}
139149
}

src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.html

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
[ngValue]="null">Loading...</option>
2424
<option i18n
2525
*ngIf="accounts !== null"
26-
[ngValue]="null">-- Select an Account --</option>
26+
[value]="''">-- Select an Account --</option>
2727
<option *ngFor="let account of accounts"
2828
[value]="account.id">{{ account.name }} {{account.tenant ? '- '+account.tenant : ''}}</option>
2929
</cds-select>
@@ -208,6 +208,31 @@
208208
</cds-checkbox>
209209
</div>
210210

211+
@if(userForm.getValue('account_id') && !userForm.getValue('account_root_user')) {
212+
<!-- Managed policies -->
213+
<fieldset>
214+
<div class="form-item">
215+
<legend i18n
216+
class="cd-header">Managed policies</legend>
217+
<cds-combo-box label="Policies"
218+
type="multi"
219+
selectionFeedback="top-after-reopen"
220+
formControlName="account_policies"
221+
id="account_policies"
222+
placeholder="Select managed policies..."
223+
i18n-placeholder
224+
[appendInline]="true"
225+
[items]="managedPolicies"
226+
(selected)="multiSelector($event)"
227+
itemValueKey="name"
228+
i18n-label
229+
i18n>
230+
<cds-dropdown-list></cds-dropdown-list>
231+
</cds-combo-box>
232+
</div>
233+
</fieldset>
234+
}
235+
211236
<!-- S3 key -->
212237
<fieldset *ngIf="!editing">
213238
<legend i18n

0 commit comments

Comments
 (0)