@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
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
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
--- /dev/null
+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;
+}
</tbody>
</table>
+ <ng-container *ngIf="selection.account && selection.account?.id">
+ <legend i18n>Account Details</legend>
+ <table class="cds--data-table--sort cds--data-table--no-border cds--data-table cds--data-table--md">
+ <tbody>
+ <tr>
+ <td i18n
+ class="bold w-25">Account ID</td>
+ <td class="w-75">{{ selection.account?.id }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold w-25">Name</td>
+ <td class="w-75">{{ selection.account?.name }}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold w-25">Tenant</td>
+ <td class="w-75">{{ selection.account?.tenant || '-'}}</td>
+ </tr>
+ <tr>
+ <td i18n
+ class="bold w-25">User type</td>
+ <td class="w-75"
+ i18n>{{ user?.type === 'root' ? 'Account root user' : 'rgw user' }}</td>
+ </tr>
+ </tbody>
+ </table>
+ </ng-container>
+
<!-- User quota -->
<div *ngIf="user.user_quota">
<legend i18n>User quota</legend>
<div i18n="form title"
class="form-header">{{ action | titlecase }} {{ resource | upperFirst }}</div>
+ @if(accounts.length > 0){
+ <!-- Link Account -->
+ <div class="form-item">
+ <cds-select label="Link Account"
+ i18n-label
+ for="link_account"
+ formControlName="account_id"
+ [invalid]="userForm.controls.account_id.invalid && userForm.controls.account_id.dirty"
+ [invalidText]="accountError"
+ [helperText]="accountsHelper">
+ <option i18n
+ *ngIf="accounts === null"
+ [ngValue]="null">Loading...</option>
+ <option i18n
+ *ngIf="accounts !== null"
+ [ngValue]="null">-- Select an Account --</option>
+ <option *ngFor="let account of accounts"
+ [value]="account.id">{{ account.name }} {{account.tenant ? '- '+account.tenant : ''}}</option>
+ </cds-select>
+ <ng-template #accountError>
+ <span class="invalid-feedback"
+ *ngIf="userForm.showError('account_id', frm, 'tenantedAccount')"
+ i18n>Only accounts with the same tenant name can be linked to a tenanted user.</span>
+ </ng-template>
+ <ng-template #accountsHelper>
+ <div i18n>Account membership is permanent. Once added, users cannot be removed from their account.</div>
+ <div i18n>Ownership of all of the user's buckets will be transferred to the account.</div>
+ </ng-template>
+ </div>
+
+ <!-- Account Root user -->
+ <div *ngIf="userForm.getValue('account_id')"
+ class="form-item">
+ <cds-checkbox formControlName="account_root_user"
+ id="account_root_user"
+ i18n>Account Root user
+ <cd-help-text>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.
+ </cd-help-text>
+ </cds-checkbox>
+ </div>
+ }
+
<!-- User ID -->
<div class="form-item">
<cds-text-label for="user_id"
</ng-template>
</div>
- <!-- Show Tenant -->
- <div class="form-item">
- <cds-checkbox formControlName="show_tenant"
- id="show_tenant"
- [readonly]="true"
- (checkedChange)="updateFieldsWhenTenanted()">Show Tenant
- </cds-checkbox>
- </div>
+ <!-- Show Tenant -->
+ <div class="form-item">
+ <cds-checkbox formControlName="show_tenant"
+ id="show_tenant"
+ [readonly]="true"
+ (checkedChange)="updateFieldsWhenTenanted()">Show Tenant
+ </cds-checkbox>
+ </div>
<!-- Tenant -->
<div class="form-item"
import { RgwRateLimitComponent } from '../rgw-rate-limit/rgw-rate-limit.component';
import { By } from '@angular/platform-browser';
import { CheckboxModule, NumberModule, SelectModule } from 'carbon-components-angular';
+import { LoadingStatus } from '~/app/shared/forms/cd-form';
describe('RgwUserFormComponent', () => {
let component: RgwUserFormComponent;
describe('max buckets', () => {
beforeEach(() => {
+ component.loading = LoadingStatus.Ready;
fixture.detectChanges();
childComponent = fixture.debugElement.query(By.directive(RgwRateLimitComponent))
.componentInstance;
secret_key: '',
suspended: false,
system: false,
- uid: null
+ uid: null,
+ account_id: '',
+ account_root_user: false
});
expect(spyRateLimit).toHaveBeenCalled();
});
email: null,
max_buckets: -1,
suspended: false,
- system: false
+ system: false,
+ account_root_user: false
});
expect(spyRateLimit).toHaveBeenCalled();
});
secret_key: '',
suspended: false,
system: false,
- uid: null
+ uid: null,
+ account_id: '',
+ account_root_user: false
});
expect(spyRateLimit).toHaveBeenCalled();
});
email: null,
max_buckets: 0,
suspended: false,
- system: false
+ system: false,
+ account_root_user: false
});
expect(spyRateLimit).toHaveBeenCalled();
});
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: '',
secret_key: '',
suspended: false,
system: false,
- uid: null
+ uid: null,
+ account_id: '',
+ account_root_user: false
});
expect(spyRateLimit).toHaveBeenCalled();
});
email: null,
max_buckets: 100,
suspended: false,
- system: false
+ system: false,
+ account_root_user: false
});
expect(spyRateLimit).toHaveBeenCalled();
});
let notificationService: NotificationService;
beforeEach(() => {
+ component.loading = LoadingStatus.Ready;
spyOn(TestBed.inject(Router), 'navigate').and.stub();
notificationService = TestBed.inject(NotificationService);
spyOn(notificationService, 'show');
email: '',
max_buckets: 1000,
suspended: false,
- system: false
+ system: false,
+ account_root_user: false
});
});
});
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()) {
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
+ });
+ });
+ });
});
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',
showTenant = false;
previousTenant: string = null;
@ViewChild(RgwRateLimitComponent, { static: false }) rateLimitComponent!: RgwRateLimitComponent;
+ accounts: Account[] = [];
constructor(
private formBuilder: CdFormBuilder,
private rgwUserService: RgwUserService,
private modalService: ModalCdsService,
private notificationService: NotificationService,
- public actionLabels: ActionLabelsI18n
+ public actionLabels: ActionLabelsI18n,
+ private rgwUserAccountService: RgwUserAccountsService
) {
super();
this.resource = $localize`user`;
[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,
// Process route parameters.
this.route.params.subscribe((params: { uid: string }) => {
if (!params.hasOwnProperty('uid')) {
- this.loadingReady();
return;
}
const uid = decodeURIComponent(params.uid);
// 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;
});
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) {
* @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;
+ });
}
/**
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'),
*/
private _getUpdateArgs() {
const result: Record<string, any> = {};
- 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.
}
}
}
+
+ goToCreateAccountForm() {
+ this.router.navigate(['rgw/accounts/create']);
+ }
}
<ng-template #noObjectQuota
i18n>No Limit</ng-template>
</ng-template>
+
+<ng-template #accountTmpl
+ 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>
+</ng-template>
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';
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';
userSizeTpl: TemplateRef<any>;
@ViewChild('userObjectTpl', { static: true })
userObjectTpl: TemplateRef<any>;
+ @ViewChild('accountTmpl', { static: true })
+ public accountTmpl: TemplateRef<any>;
permission: Permission;
tableActions: CdTableAction[];
columns: CdTableColumn[] = [];
users: object[] = [];
+ userAccounts: Account[];
selection: CdTableSelection = new CdTableSelection();
+ userDataSubject = new Subject();
declare staleTimeout: number;
constructor(
private modalService: ModalCdsService,
private urlBuilder: URLBuilderService,
public actionLabels: ActionLabelsI18n,
- protected ngZone: NgZone
+ protected ngZone: NgZone,
+ private rgwUserAccountService: RgwUserAccountsService
) {
super(ngZone);
}
prop: 'tenant',
flexGrow: 1
},
+ {
+ name: $localize`Account name`,
+ prop: 'account.name',
+ flexGrow: 1,
+ cellTemplate: this.accountTmpl
+ },
{
name: $localize`Full name`,
prop: 'display_name',
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 = {
this.rgwUserService.list().subscribe(
(resp: object[]) => {
this.users = resp;
+ this.userDataSubject.next(resp);
},
() => {
context.error();
);
}
+ 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;
}
properties:
access_key:
type: string
+ account_id:
+ type: integer
+ account_root_user:
+ default: false
+ type: integer
daemon_name:
type: string
display_name:
application/json:
schema:
properties:
+ account_id:
+ type: integer
+ account_root_user:
+ default: false
+ type: integer
daemon_name:
type: string
display_name: