From 0d4f2e356cadc4bb69a1c55ac45fbe54f501ecbf Mon Sep 17 00:00:00 2001 From: Naman Munet Date: Tue, 14 Jan 2025 13:51:57 +0530 Subject: [PATCH] mgr/dashboard: link user to rgw account & add root account user functionality Fixes: https://tracker.ceph.com/issues/69529 Signed-off-by: Naman Munet --- src/pybind/mgr/dashboard/controllers/rgw.py | 14 ++- .../src/app/ceph/rgw/models/rgw-user.ts | 80 ++++++++++++++++ .../rgw-user-details.component.html | 29 ++++++ .../rgw-user-form.component.html | 59 ++++++++++-- .../rgw-user-form.component.spec.ts | 76 +++++++++++++-- .../rgw-user-form/rgw-user-form.component.ts | 95 +++++++++++++++++-- .../rgw-user-list.component.html | 10 ++ .../rgw-user-list/rgw-user-list.component.ts | 41 +++++++- src/pybind/mgr/dashboard/openapi.yaml | 10 ++ 9 files changed, 386 insertions(+), 28 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user.ts diff --git a/src/pybind/mgr/dashboard/controllers/rgw.py b/src/pybind/mgr/dashboard/controllers/rgw.py index 746bd1c5d1a..e6edd3d5b2f 100755 --- a/src/pybind/mgr/dashboard/controllers/rgw.py +++ b/src/pybind/mgr/dashboard/controllers/rgw.py @@ -851,7 +851,8 @@ class RgwUser(RgwRESTController): @allow_empty_body 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): + 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 @@ -869,13 +870,18 @@ class RgwUser(RgwRESTController): 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 + if account_root_user: + params['account-root'] = account_root_user result = self.proxy(daemon_name, 'PUT', 'user', params) result['uid'] = result['full_user_id'] return result @allow_empty_body def set(self, uid, display_name=None, email=None, max_buckets=None, - system=None, suspended=None, daemon_name=None): + system=None, suspended=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 @@ -887,6 +893,10 @@ class RgwUser(RgwRESTController): params['system'] = system if suspended is not None: params['suspended'] = suspended + if account_id is not None: + params['account-id'] = account_id + if account_root_user: + params['account-root'] = account_root_user result = self.proxy(daemon_name, 'POST', 'user', params) result['uid'] = result['full_user_id'] return result diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user.ts new file mode 100644 index 00000000000..573dd5bb525 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user.ts @@ -0,0 +1,80 @@ +interface Key { + access_key: string; + active: boolean; + secret_key: string; + user: string; +} + +interface SwiftKey { + active: boolean; + secret_key: string; + user: string; +} + +interface Cap { + perm: string; + type: string; +} + +interface Subuser { + id: string; + permissions: string; +} + +interface BucketQuota { + check_on_raw: boolean; + enabled: boolean; + max_objects: number; + max_size: number; + max_size_kb: number; +} + +interface UserQuota { + check_on_raw: boolean; + enabled: boolean; + max_objects: number; + max_size: number; + max_size_kb: number; +} + +interface Stats { + num_objects: number; + size: number; + size_actual: number; + size_utilized: number; + size_kb: number; + size_kb_actual: number; + size_kb_utilized: number; +} + +export interface RgwUser { + account_id: string; + admin: boolean; + bucket_quota: BucketQuota; + caps: Cap[]; + create_date: string; + default_placement: string; + default_storage_class: string; + display_name: string; + email: string; + full_user_id: string; + group_ids: any[]; + keys: Key[]; + max_buckets: number; + mfa_ids: any[]; + op_mask: string; + path: string; + placement_tags: any[]; + stats: Stats; + subusers: Subuser[]; + suspended: number; + swift_keys: SwiftKey[]; + system: boolean; + tags: any[]; + tenant: string; + temp_url_keys: any[]; + type: string; + uid: string; + user_id: string; + user_quota: UserQuota; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.html index 055dd54f13f..e0defa08fb8 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.html @@ -88,6 +88,35 @@ + + Account Details + + + + + + + + + + + + + + + + + + + +
Account ID{{ selection.account?.id }}
Name{{ selection.account?.name }}
Tenant{{ selection.account?.tenant || '-'}}
User type{{ user?.type === 'root' ? 'Account root user' : 'rgw user' }}
+
+
User quota diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.html index 88635763117..053a30f0c58 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.html @@ -7,6 +7,49 @@
{{ action | titlecase }} {{ resource | upperFirst }}
+ @if(accounts.length > 0){ + +
+ + + + + + + Only accounts with the same tenant name can be linked to a tenanted user. + + +
Account membership is permanent. Once added, users cannot be removed from their account.
+
Ownership of all of the user's buckets will be transferred to the account.
+
+
+ + +
+ Account Root user + The account root user has full access to all resources and manages the account. + It's recommended to use this account for management tasks only and create additional users with specific permissions. + + +
+ } +
- -
- Show Tenant - -
+ +
+ Show Tenant + +
{ let component: RgwUserFormComponent; @@ -185,6 +186,7 @@ describe('RgwUserFormComponent', () => { describe('max buckets', () => { beforeEach(() => { + component.loading = LoadingStatus.Ready; fixture.detectChanges(); childComponent = fixture.debugElement.query(By.directive(RgwRateLimitComponent)) .componentInstance; @@ -203,7 +205,9 @@ describe('RgwUserFormComponent', () => { secret_key: '', suspended: false, system: false, - uid: null + uid: null, + account_id: '', + account_root_user: false }); expect(spyRateLimit).toHaveBeenCalled(); }); @@ -219,7 +223,8 @@ describe('RgwUserFormComponent', () => { email: null, max_buckets: -1, suspended: false, - system: false + system: false, + account_root_user: false }); expect(spyRateLimit).toHaveBeenCalled(); }); @@ -238,7 +243,9 @@ describe('RgwUserFormComponent', () => { secret_key: '', suspended: false, system: false, - uid: null + uid: null, + account_id: '', + account_root_user: false }); expect(spyRateLimit).toHaveBeenCalled(); }); @@ -254,7 +261,8 @@ describe('RgwUserFormComponent', () => { email: null, max_buckets: 0, suspended: false, - system: false + system: false, + account_root_user: false }); expect(spyRateLimit).toHaveBeenCalled(); }); @@ -264,6 +272,7 @@ describe('RgwUserFormComponent', () => { formHelper.setValue('max_buckets_mode', 1, true); formHelper.setValue('max_buckets', 100, true); let spyRateLimit = jest.spyOn(childComponent, 'getRateLimitFormValue'); + component.onSubmit(); expect(rgwUserService.create).toHaveBeenCalledWith({ access_key: '', @@ -274,7 +283,9 @@ describe('RgwUserFormComponent', () => { secret_key: '', suspended: false, system: false, - uid: null + uid: null, + account_id: '', + account_root_user: false }); expect(spyRateLimit).toHaveBeenCalled(); }); @@ -291,7 +302,8 @@ describe('RgwUserFormComponent', () => { email: null, max_buckets: 100, suspended: false, - system: false + system: false, + account_root_user: false }); expect(spyRateLimit).toHaveBeenCalled(); }); @@ -301,6 +313,7 @@ describe('RgwUserFormComponent', () => { let notificationService: NotificationService; beforeEach(() => { + component.loading = LoadingStatus.Ready; spyOn(TestBed.inject(Router), 'navigate').and.stub(); notificationService = TestBed.inject(NotificationService); spyOn(notificationService, 'show'); @@ -320,7 +333,8 @@ describe('RgwUserFormComponent', () => { email: '', max_buckets: 1000, suspended: false, - system: false + system: false, + account_root_user: false }); }); @@ -348,6 +362,9 @@ describe('RgwUserFormComponent', () => { }); describe('RgwUserCapabilities', () => { + beforeEach(() => { + component.loading = LoadingStatus.Ready; + }); it('capability button disabled when all capabilities are added', () => { component.editing = true; for (const capabilityType of RgwUserCapabilities.getAll()) { @@ -669,4 +686,49 @@ describe('RgwUserFormComponent', () => { expect(modalRef.submitAction.subscribe).toHaveBeenCalled(); }); }); + + describe('RgwUserAccounts', () => { + beforeEach(() => { + component.loading = LoadingStatus.Ready; + fixture.detectChanges(); + childComponent = fixture.debugElement.query(By.directive(RgwRateLimitComponent)) + .componentInstance; + }); + it('create with account id & account root user', () => { + spyOn(rgwUserService, 'create'); + formHelper.setValue('account_id', 'RGW12312312312312312', true); + formHelper.setValue('account_root_user', true, true); + component.onSubmit(); + expect(rgwUserService.create).toHaveBeenCalledWith({ + access_key: '', + display_name: null, + email: '', + generate_key: true, + max_buckets: 1000, + secret_key: '', + suspended: false, + system: false, + uid: null, + account_id: 'RGW12312312312312312', + account_root_user: true + }); + }); + + it('edit to link account to existing user', () => { + spyOn(rgwUserService, 'update'); + component.editing = true; + formHelper.setValue('account_id', 'RGW12312312312312312', true); + formHelper.setValue('account_root_user', true, true); + component.onSubmit(); + expect(rgwUserService.update).toHaveBeenCalledWith(null, { + display_name: null, + email: null, + max_buckets: 1000, + suspended: false, + system: false, + account_id: 'RGW12312312312312312', + account_root_user: true + }); + }); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.ts index 9cf8741e353..f0783324c24 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.ts @@ -27,6 +27,9 @@ import { RgwUserSwiftKeyModalComponent } from '../rgw-user-swift-key-modal/rgw-u import { RgwRateLimitComponent } from '../rgw-rate-limit/rgw-rate-limit.component'; 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'; @Component({ selector: 'cd-rgw-user-form', @@ -52,6 +55,7 @@ export class RgwUserFormComponent extends CdForm implements OnInit { showTenant = false; previousTenant: string = null; @ViewChild(RgwRateLimitComponent, { static: false }) rateLimitComponent!: RgwRateLimitComponent; + accounts: Account[] = []; constructor( private formBuilder: CdFormBuilder, @@ -60,7 +64,8 @@ export class RgwUserFormComponent extends CdForm implements OnInit { private rgwUserService: RgwUserService, private modalService: ModalCdsService, private notificationService: NotificationService, - public actionLabels: ActionLabelsI18n + public actionLabels: ActionLabelsI18n, + private rgwUserAccountService: RgwUserAccountsService ) { super(); this.resource = $localize`user`; @@ -110,6 +115,8 @@ export class RgwUserFormComponent extends CdForm implements OnInit { [CdValidators.email], [CdValidators.unique(this.rgwUserService.emailExists, this.rgwUserService)] ], + account_id: [null, [this.tenantedAccountValidator.bind(this)]], + account_root_user: [false], max_buckets_mode: [1], max_buckets: [ 1000, @@ -178,7 +185,6 @@ export class RgwUserFormComponent extends CdForm implements OnInit { // Process route parameters. this.route.params.subscribe((params: { uid: string }) => { if (!params.hasOwnProperty('uid')) { - this.loadingReady(); return; } const uid = decodeURIComponent(params.uid); @@ -232,6 +238,13 @@ export class RgwUserFormComponent extends CdForm implements OnInit { // Update the form. this.userForm.setValue(value); + if (this.userForm.getValue('account_id')) { + this.userForm.get('account_id').disable(); + } else { + this.userForm.get('account_id').setValue(null); + } + const isAccountRoot: boolean = resp[0]['type'] !== RGW; + this.userForm.get('account_root_user').setValue(isAccountRoot); // Get the sub users. this.subusers = resp[0].subusers; @@ -248,13 +261,54 @@ export class RgwUserFormComponent extends CdForm implements OnInit { }); this.capabilities = resp[0].caps; this.uid = this.getUID(); - this.loadingReady(); }, () => { this.loadingError(); } ); }); + this.rgwUserAccountService.list(true).subscribe( + (accounts: Account[]) => { + this.accounts = accounts; + if (!this.editing) { + // needed to disable checkbox on create form load + this.userForm.get('account_id').reset(); + } + this.loadingReady(); + }, + () => { + this.loadingError(); + } + ); + this.userForm.get('account_id').valueChanges.subscribe((value) => { + if (!value) { + this.userForm + .get('display_name') + .setValidators([Validators.pattern(/^[a-zA-Z0-9!@#%^&*()._ -]+$/), Validators.required]); + this.userForm.get('display_name').updateValueAndValidity(); + this.userForm.get('account_root_user').disable(); + } else { + this.userForm + .get('display_name') + .setValidators([Validators.pattern(/^[\w+=,.@-]+$/), Validators.required]); + this.userForm.get('display_name').updateValueAndValidity(); + this.userForm.get('account_root_user').enable(); + } + }); + } + + tenantedAccountValidator(control: AbstractControl): ValidationErrors | null { + if (this?.userForm?.getValue('tenant') && this.accounts.length > 0) { + const index: number = this.accounts.findIndex( + (account: Account) => account.id === control.value + ); + if (index !== -1) { + return this.userForm.getValue('tenant') !== this.accounts[index].tenant + ? { tenantedAccount: true } + : null; + } + } + return null; } rateLimitFormInit(rateLimitForm: FormGroup) { @@ -593,11 +647,18 @@ export class RgwUserFormComponent extends CdForm implements OnInit { * @return {Boolean} Returns TRUE if the general user settings have been modified. */ private _isGeneralDirty(): boolean { - return ['display_name', 'email', 'max_buckets_mode', 'max_buckets', 'system', 'suspended'].some( - (path) => { - return this.userForm.get(path).dirty; - } - ); + return [ + 'display_name', + 'email', + 'max_buckets_mode', + 'max_buckets', + 'system', + 'suspended', + 'account_id', + 'account_root_user' + ].some((path) => { + return this.userForm.get(path).dirty; + }); } /** @@ -639,6 +700,8 @@ 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'), @@ -675,10 +738,20 @@ export class RgwUserFormComponent extends CdForm implements OnInit { */ private _getUpdateArgs() { const result: Record = {}; - const keys = ['display_name', 'email', 'max_buckets', 'system', 'suspended']; + const keys = [ + 'display_name', + 'email', + 'max_buckets', + 'system', + 'suspended', + 'account_root_user' + ]; 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'); + } const maxBucketsMode = parseInt(this.userForm.getValue('max_buckets_mode'), 10); if (_.includes([-1, 0], maxBucketsMode)) { // -1 => Disable bucket creation. @@ -770,4 +843,8 @@ export class RgwUserFormComponent extends CdForm implements OnInit { } } } + + goToCreateAccountForm() { + this.router.navigate(['rgw/accounts/create']); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.html index 0bb57267262..44c0017a9f3 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.html @@ -44,3 +44,13 @@ No Limit + + + + {{row.account?.name}} + + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.ts index f44e8f8c2cd..64f5e72bae1 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.ts @@ -1,6 +1,7 @@ import { Component, NgZone, OnInit, TemplateRef, ViewChild } from '@angular/core'; -import { forkJoin as observableForkJoin, Observable, Subscriber } from 'rxjs'; +import { forkJoin as observableForkJoin, Observable, Subscriber, Subject } from 'rxjs'; +import { RgwUserAccountsService } from '~/app/shared/api/rgw-user-accounts.service'; import { RgwUserService } from '~/app/shared/api/rgw-user.service'; import { ListWithDetails } from '~/app/shared/classes/list-with-details.class'; @@ -18,6 +19,9 @@ import { Permission } from '~/app/shared/models/permissions'; import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; import { URLBuilderService } from '~/app/shared/services/url-builder.service'; +import { Account } from '../models/rgw-user-accounts'; +import { switchMap } from 'rxjs/operators'; +import { RgwUser } from '../models/rgw-user'; const BASE_URL = 'rgw/user'; @@ -34,11 +38,15 @@ export class RgwUserListComponent extends ListWithDetails implements OnInit { userSizeTpl: TemplateRef; @ViewChild('userObjectTpl', { static: true }) userObjectTpl: TemplateRef; + @ViewChild('accountTmpl', { static: true }) + public accountTmpl: TemplateRef; permission: Permission; tableActions: CdTableAction[]; columns: CdTableColumn[] = []; users: object[] = []; + userAccounts: Account[]; selection: CdTableSelection = new CdTableSelection(); + userDataSubject = new Subject(); declare staleTimeout: number; constructor( @@ -47,7 +55,8 @@ export class RgwUserListComponent extends ListWithDetails implements OnInit { private modalService: ModalCdsService, private urlBuilder: URLBuilderService, public actionLabels: ActionLabelsI18n, - protected ngZone: NgZone + protected ngZone: NgZone, + private rgwUserAccountService: RgwUserAccountsService ) { super(ngZone); } @@ -65,6 +74,12 @@ export class RgwUserListComponent extends ListWithDetails implements OnInit { prop: 'tenant', flexGrow: 1 }, + { + name: $localize`Account name`, + prop: 'account.name', + flexGrow: 1, + cellTemplate: this.accountTmpl + }, { name: $localize`Full name`, prop: 'display_name', @@ -105,6 +120,17 @@ export class RgwUserListComponent extends ListWithDetails implements OnInit { flexGrow: 0.8 } ]; + this.userDataSubject + .pipe( + switchMap((_: object[]) => { + return this.rgwUserAccountService.list(true); + }) + ) + .subscribe((accounts: Account[]) => { + this.userAccounts = accounts; + this.mapUsersWithAccount(); + }); + const getUserUri = () => this.selection.first() && `${encodeURIComponent(this.selection.first().uid)}`; const addAction: CdTableAction = { @@ -136,6 +162,7 @@ export class RgwUserListComponent extends ListWithDetails implements OnInit { this.rgwUserService.list().subscribe( (resp: object[]) => { this.users = resp; + this.userDataSubject.next(resp); }, () => { context.error(); @@ -143,6 +170,16 @@ export class RgwUserListComponent extends ListWithDetails implements OnInit { ); } + mapUsersWithAccount() { + this.users = this.users.map((user: RgwUser) => { + const account: Account = this.userAccounts.find((acc) => acc.id === user.account_id); + return { + account: account ? account : { name: '' }, // adding {name: ''} for sorting account name in user list to work + ...user + }; + }); + } + updateSelection(selection: CdTableSelection) { this.selection = selection; } diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index e1e5fab12df..29791d7196d 100755 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -13203,6 +13203,11 @@ paths: properties: access_key: type: string + account_id: + type: integer + account_root_user: + default: false + type: integer daemon_name: type: string display_name: @@ -13384,6 +13389,11 @@ paths: application/json: schema: properties: + account_id: + type: integer + account_root_user: + default: false + type: integer daemon_name: type: string display_name: -- 2.39.5