mgr/dashboard : Access denied issue for settings icon

fixes : https://tracker.ceph.com/issues/78407

Signed-off-by: Abhishek Desai <abhishek.desai1@ibm.com>
This commit is contained in:
Abhishek Desai 2026-07-20 17:15:53 +05:30
parent 21e7419557
commit 481d6e4699
8 changed files with 186 additions and 60 deletions

View File

@ -29,19 +29,21 @@
size="md"
(click)="updateSiteName()"
[title]="editing ? 'Save' : 'Edit'"
data-testid="site-name-edit-btn"
>
<svg
[cdsIcon]="icons.edit"
[size]="icons.size32"
class="cds--btn__icon"
*ngIf="!editing"
></svg>
<svg
[cdsIcon]="icons.check"
[size]="icons.size32"
class="cds--btn__icon"
*ngIf="editing"
></svg>
@if (!editing) {
<svg
[cdsIcon]="icons.edit"
[size]="icons.size32"
class="cds--btn__icon"
></svg>
} @else {
<svg
[cdsIcon]="icons.check"
[size]="icons.size32"
class="cds--btn__icon"
></svg>
}
</cds-icon-button>
}
<cd-copy-2-clipboard-button

View File

@ -82,7 +82,8 @@ describe('OverviewComponent', () => {
});
describe('site name edit button visibility', () => {
const editButton = () => fixture.debugElement.query(By.css('cds-icon-button'));
const editButton = () =>
fixture.debugElement.query(By.css('[data-testid="site-name-edit-btn"]'));
it('should show the edit button when user has update permission', () => {
component.permission = new Permission(['read', 'update']);

View File

@ -3,16 +3,18 @@
[flip]="true"
description=""
>
<li class="cds--overflow-menu-options__option mb-2">
<button
routerLink="/user-management"
class="cds--overflow-menu-options__btn"
type="button"
i18n
>
User management
</button>
</li>
@if (userPermission.create || userPermission.update || userPermission.delete) {
<li class="cds--overflow-menu-options__option mb-2">
<button
routerLink="/user-management"
class="cds--overflow-menu-options__btn"
type="button"
i18n
>
User management
</button>
</li>
}
@if (configOptPermission.read) {
<li class="cds--overflow-menu-options__option mb-2">
<button
@ -28,7 +30,12 @@
</cds-overflow-menu>
<ng-template #customTrigger>
@if (userPermission.read || configOptPermission.read) {
@if (
userPermission.create ||
userPermission.update ||
userPermission.delete ||
configOptPermission.read
) {
<svg
cdsIcon="settings"
size="20"

View File

@ -1,7 +1,9 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { RouterTestingModule } from '@angular/router/testing';
import { Permission } from '~/app/shared/models/permissions';
import { Permission, Permissions } from '~/app/shared/models/permissions';
import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
import { SharedModule } from '~/app/shared/shared.module';
import { configureTestBed } from '~/testing/unit-test-helper';
import { AdministrationComponent } from './administration.component';
@ -9,49 +11,113 @@ import { AdministrationComponent } from './administration.component';
describe('AdministrationComponent', () => {
let component: AdministrationComponent;
let fixture: ComponentFixture<AdministrationComponent>;
let permissions: Permissions;
configureTestBed({
imports: [SharedModule],
declarations: [AdministrationComponent]
});
beforeEach(() => {
const createComponent = () => {
spyOn(TestBed.inject(AuthStorageService), 'getPermissions').and.returnValue(permissions);
fixture = TestBed.createComponent(AdministrationComponent);
component = fixture.componentInstance;
fixture.detectChanges();
};
configureTestBed({
imports: [SharedModule, RouterTestingModule],
declarations: [AdministrationComponent],
providers: [AuthStorageService]
});
beforeEach(() => {
permissions = new Permissions({});
});
it('should create', () => {
createComponent();
expect(component).toBeTruthy();
});
describe('settings icon visibility', () => {
const setPermissions = (user: string[], configOpt: string[]) => {
component.userPermission = new Permission(user);
component.configOptPermission = new Permission(configOpt);
fixture.detectChanges();
};
describe('Settings icon visibility', () => {
/**
* The settings icon is inside <ng-template #customTrigger> which is not
* projected into the DOM by the CDS component in JSDOM. We therefore
* verify the guard condition through the component's own Permission objects,
* mirroring the *ngIf expression in the template:
* userPermission.create || userPermission.update || userPermission.delete || configOptPermission.read
*/
const iconVisible = (c: AdministrationComponent) =>
c['userPermission'].create ||
c['userPermission'].update ||
c['userPermission'].delete ||
c['configOptPermission'].read;
const settingsIcon = () => fixture.debugElement.query(By.css('[cdsIcon="settings"]'));
it('should show the settings icon when user has user.read', () => {
setPermissions(['read'], []);
expect(settingsIcon()).toBeTruthy();
it('should hide the settings icon for a read-only user', () => {
permissions.user = new Permission(['read']);
permissions.configOpt = new Permission([]);
createComponent();
expect(iconVisible(component)).toBeFalsy();
});
it('should show the settings icon when user has configOpt.read', () => {
setPermissions([], ['read']);
expect(settingsIcon()).toBeTruthy();
it('should show the settings icon when user has create permission', () => {
permissions.user = new Permission(['read', 'create']);
permissions.configOpt = new Permission([]);
createComponent();
expect(iconVisible(component)).toBeTruthy();
});
it('should show the settings icon when user has both user.read and configOpt.read', () => {
setPermissions(['read'], ['read']);
expect(settingsIcon()).toBeTruthy();
it('should show the settings icon when user has update permission', () => {
permissions.user = new Permission(['read', 'update']);
permissions.configOpt = new Permission([]);
createComponent();
expect(iconVisible(component)).toBeTruthy();
});
it('should hide the settings icon when user has neither user.read nor configOpt.read', () => {
setPermissions([], []);
expect(settingsIcon()).toBeFalsy();
it('should show the settings icon when user has delete permission', () => {
permissions.user = new Permission(['read', 'delete']);
permissions.configOpt = new Permission([]);
createComponent();
expect(iconVisible(component)).toBeTruthy();
});
it('should show the settings icon when configOpt is readable (even with read-only user)', () => {
permissions.user = new Permission(['read']);
permissions.configOpt = new Permission(['read']);
createComponent();
expect(iconVisible(component)).toBeTruthy();
});
});
describe('User Management menu item visibility', () => {
it('should hide User Management for a read-only user', () => {
permissions.user = new Permission(['read']);
createComponent();
const buttons = fixture.debugElement.queryAll(By.css('.cds--overflow-menu-options__btn'));
const labels = buttons.map((b) => b.nativeElement.textContent.trim());
expect(labels).not.toContain('User management');
});
it('should show User Management when user has create permission', () => {
permissions.user = new Permission(['read', 'create']);
createComponent();
const buttons = fixture.debugElement.queryAll(By.css('.cds--overflow-menu-options__btn'));
const labels = buttons.map((b) => b.nativeElement.textContent.trim());
expect(labels).toContain('User management');
});
});
describe('Telemetry menu item visibility', () => {
it('should hide Telemetry configuration when configOpt is not readable', () => {
permissions.configOpt = new Permission([]);
createComponent();
const buttons = fixture.debugElement.queryAll(By.css('.cds--overflow-menu-options__btn'));
const labels = buttons.map((b) => b.nativeElement.textContent.trim());
expect(labels).not.toContain('Telemetry configuration');
});
it('should show Telemetry configuration when configOpt is readable', () => {
permissions.configOpt = new Permission(['read']);
createComponent();
const buttons = fixture.debugElement.queryAll(By.css('.cds--overflow-menu-options__btn'));
const labels = buttons.map((b) => b.nativeElement.textContent.trim());
expect(labels).toContain('Telemetry configuration');
});
});
});

View File

@ -57,12 +57,16 @@
<div class="cds--btn cds--btn--icon-only cds--header__action">
<cd-dashboard-help></cd-dashboard-help>
</div>
<div
class="cds--btn cds--btn--icon-only cds--header__action"
*ngIf="permissions.user.read"
>
<cd-administration></cd-administration>
</div>
@if (
permissions.user.create ||
permissions.user.update ||
permissions.user.delete ||
permissions.configOpt.read
) {
<div class="cds--btn cds--btn--icon-only cds--header__action tc_administration">
<cd-administration></cd-administration>
</div>
}
<div class="cds--btn cds--btn--icon-only cds--header__action">
<cd-identity></cd-identity>
</div>

View File

@ -264,4 +264,31 @@ describe('NavigationComponent', () => {
}
});
});
describe('Administration settings icon wrapper', () => {
it('should hide the wrapper for a read-only user (no write user perms, no configOpt.read)', () => {
component.permissions = everythingPermittedExcept(['user', 'configOpt']);
fixture.detectChanges();
expect(fixture.debugElement.query(By.css('.tc_administration'))).toBeFalsy();
});
it('should show the wrapper when user has write permission', () => {
const permissions: Permissions = new Permissions({});
Object.keys(permissions).forEach((key) => (permissions[key] = new Permission(['read'])));
permissions.user = new Permission(['read', 'create']);
component.permissions = permissions;
fixture.detectChanges();
expect(fixture.debugElement.query(By.css('.tc_administration'))).toBeTruthy();
});
it('should show the wrapper when configOpt is readable', () => {
const permissions: Permissions = new Permissions({});
Object.keys(permissions).forEach((key) => (permissions[key] = new Permission(['read'])));
permissions.user = new Permission(['read']);
permissions.configOpt = new Permission(['read']);
component.permissions = permissions;
fixture.detectChanges();
expect(fixture.debugElement.query(By.css('.tc_administration'))).toBeTruthy();
});
});
});

View File

@ -118,7 +118,7 @@ describe('ApiInterceptorService', () => {
);
});
it('should not redirect 403 for ui-api/ requests', () => {
it('should not redirect 403 for unscoped ui-api/ background check requests', () => {
const uiApiUrl = 'ui-api/prometheus/prometheus-api-host';
httpClient.get(uiApiUrl).subscribe(
() => true,
@ -129,6 +129,20 @@ describe('ApiInterceptorService', () => {
expect(router.navigate).not.toHaveBeenCalled();
});
it('should redirect 403 for scoped ui-api/ requests', () => {
const scopedUiApiUrl = 'ui-api/osd/deployment_options';
httpClient.get(scopedUiApiUrl).subscribe(
() => true,
(_resp) => undefined
);
httpTesting.expectOne(scopedUiApiUrl).error(new ErrorEvent('abc'), { status: 403 });
httpTesting.verify();
expect(router.navigate).toHaveBeenCalledWith(
['error'],
{ state: { header: 'Access Denied', icon: 'locked', message: "Sorry, you don't have permission to view this page or resource.", source: 'forbidden' } } // prettier-ignore
);
});
it('should show notification (error string)', () => {
runNotificationTest(
'foobar',

View File

@ -108,8 +108,12 @@ export class ApiInterceptorService implements HttpInterceptor {
this.authStorageService.remove();
this.router.navigate(['/login']);
break;
case 403:
if (!request.url.startsWith('ui-api/')) {
case 403: {
const UNSCOPED_UI_APIS = [
'ui-api/prometheus/prometheus-api-host',
'ui-api/prometheus/alertmanager-api-host'
];
if (!UNSCOPED_UI_APIS.some((path) => request.url.startsWith(path))) {
this.router.navigate(['error'], {
state: {
message: $localize`Sorry, you don't have permission to view this page or resource.`,
@ -120,6 +124,7 @@ export class ApiInterceptorService implements HttpInterceptor {
});
}
break;
}
default:
timeoutId = this.prepareNotification(resp);
}