]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: NVMe-oF – Add edit authentication support for subsystem 69850/head
authorpujashahu <pshahu@redhat.com>
Tue, 30 Jun 2026 20:51:53 +0000 (02:21 +0530)
committerpujashahu <pshahu@redhat.com>
Fri, 3 Jul 2026 15:55:08 +0000 (21:25 +0530)
Fixes: https://tracker.ceph.com/issues/77840
Signed-off-by: pujaoshahu <pshahu@redhat.com>
13 files changed:
src/pybind/mgr/dashboard/controllers/nvmeof.py
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-authentication/nvmeof-edit-authentication.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-authentication/nvmeof-edit-authentication.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-authentication/nvmeof-edit-authentication.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-authentication/nvmeof-edit-authentication.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-overview/nvmeof-subsystem-overview.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-overview/nvmeof-subsystem-overview.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-overview/nvmeof-subsystem-overview.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-3/nvmeof-subsystem-step-3.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts
src/pybind/mgr/dashboard/openapi.yaml

index 95065f361bda38b3374ac608a58709918c338dd3..832d3b28c16f15882ce7b25b301ed1ef12144dba 100644 (file)
@@ -616,6 +616,8 @@ else:
                 )
             )
 
+        @Endpoint('PUT', '{nqn}/change_key')
+        @UpdatePermission
         @EndpointDoc(
             "Change subsystem inband authentication key",
             parameters={
index 368a7de95b71de598041a50992f43d9b8d3bf511..722832ae977ab149d2007974461a090284c30b47 100644 (file)
@@ -109,6 +109,7 @@ import { NvmeofTabsComponent } from './nvmeof-tabs/nvmeof-tabs.component';
 import { NvmeofGatewayGroupDeleteGuardModalComponent } from './nvmeof-gateway-group/nvmeof-gateway-group-delete-guard-modal.component';
 import { NvmeofSetupCardsComponent } from './nvmeof-setup-cards/nvmeof-setup-cards.component';
 import { NvmeofGatewayGroupFilterComponent } from './nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component';
+import { NvmeofEditAuthenticationComponent } from './nvmeof-edit-authentication/nvmeof-edit-authentication.component';
 
 @NgModule({
   imports: [
@@ -201,7 +202,8 @@ import { NvmeofGatewayGroupFilterComponent } from './nvmeof-gateway-group-filter
     NvmeofSubsystemOverviewComponent,
     NvmeofSubsystemPerformanceComponent,
     NvmeofTabsComponent,
-    NvmeofGatewayGroupDeleteGuardModalComponent
+    NvmeofGatewayGroupDeleteGuardModalComponent,
+    NvmeofEditAuthenticationComponent
   ],
 
   exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent]
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-authentication/nvmeof-edit-authentication.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-authentication/nvmeof-edit-authentication.component.html
new file mode 100644 (file)
index 0000000..4261e9c
--- /dev/null
@@ -0,0 +1,29 @@
+<cd-tearsheet [steps]="steps"
+              [title]="title"
+              [description]="description"
+              size="md"
+              [isSubmitLoading]="isSubmitLoading"
+              [submitButtonLabel]="'Save changes'"
+              [submitButtonLoadingLabel]="'Saving'"
+              (submitRequested)="onSubmit()"
+              (closeRequested)="closeModal()">
+  <cd-tearsheet-step>
+    @if (showAuthAlert) {
+    <cd-alert-panel type="warning"
+                    title="Subsystem DHCHAP Key will be deleted permanently"
+                    i18n-title
+                    class="cd-nvmeof-edit-auth-downgrade-alert">
+      <span i18n>
+        Switching from bidirectional authentication to unidirectional method will remove the subsystem authentication key,
+        exposing the subsystem to unauthorised access.
+      </span>
+    </cd-alert-panel>
+    }
+    <cd-nvmeof-subsystem-step-three
+      #tearsheetStep
+      [group]="groupName"
+      [initialAuthType]="initialAuthType"
+      [stepTwoValue]="stepTwoValue">
+    </cd-nvmeof-subsystem-step-three>
+  </cd-tearsheet-step>
+</cd-tearsheet>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-authentication/nvmeof-edit-authentication.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-authentication/nvmeof-edit-authentication.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-authentication/nvmeof-edit-authentication.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-authentication/nvmeof-edit-authentication.component.spec.ts
new file mode 100644 (file)
index 0000000..a60fe0d
--- /dev/null
@@ -0,0 +1,270 @@
+import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { of, throwError } from 'rxjs';
+
+import { GridModule, InputModule, RadioModule, TagModule } from 'carbon-components-angular';
+import { SharedModule } from '~/app/shared/shared.module';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
+import { AUTHENTICATION } from '~/app/shared/models/nvmeof';
+import {
+  NvmeofEditAuthenticationComponent,
+  SUBSYSTEM_NQN_TOKEN,
+  GROUP_NAME_TOKEN
+} from './nvmeof-edit-authentication.component';
+import { NvmeofSubsystemsStepThreeComponent } from '../nvmeof-subsystems-form/nvmeof-subsystem-step-3/nvmeof-subsystem-step-3.component';
+
+describe('NvmeofEditAuthenticationComponent', () => {
+  let component: NvmeofEditAuthenticationComponent;
+  let fixture: ComponentFixture<NvmeofEditAuthenticationComponent>;
+  let nvmeofService: jest.Mocked<Pick<
+    NvmeofService,
+    'getInitiators' | 'getSubsystem' | 'updateAuthenticationKey'
+  >>;
+  let notificationService: { show: jest.Mock };
+  let modalService: { dismissAll: jest.Mock };
+
+  const mockSubsystemNQN = 'nqn.2014-08.org.nvmexpress:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6';
+  const mockGroupName = 'default';
+  const mockHosts = [
+    { nqn: 'nqn.2014-08.org.nvmexpress:uuid:host-1', use_dhchap: false },
+    { nqn: 'nqn.2014-08.org.nvmexpress:uuid:host-2', use_dhchap: false }
+  ];
+  const mockHostsWithKey = [{ nqn: 'nqn.2014-08.org.nvmexpress:uuid:host-1', use_dhchap: true }];
+
+  beforeEach(
+    waitForAsync(() => {
+      nvmeofService = {
+        getInitiators: jest.fn().mockReturnValue(of([])),
+        getSubsystem: jest.fn().mockReturnValue(of({ has_dhchap_key: false })),
+        updateAuthenticationKey: jest.fn().mockReturnValue(of(undefined))
+      };
+      notificationService = { show: jest.fn() };
+      modalService = { dismissAll: jest.fn() };
+
+      TestBed.configureTestingModule({
+        declarations: [NvmeofEditAuthenticationComponent, NvmeofSubsystemsStepThreeComponent],
+        imports: [
+          ReactiveFormsModule,
+          HttpClientTestingModule,
+          RouterTestingModule,
+          SharedModule,
+          GridModule,
+          InputModule,
+          RadioModule,
+          TagModule
+        ],
+        providers: [
+          { provide: NvmeofService, useValue: nvmeofService },
+          { provide: NotificationService, useValue: notificationService },
+          { provide: ModalCdsService, useValue: modalService },
+          { provide: SUBSYSTEM_NQN_TOKEN, useValue: mockSubsystemNQN },
+          { provide: GROUP_NAME_TOKEN, useValue: mockGroupName }
+        ]
+      }).compileComponents();
+    })
+  );
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(NvmeofEditAuthenticationComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges(); // runs ngOnInit + ngAfterViewInit
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('should inject subsystemNQN and groupName', () => {
+    expect(component.subsystemNQN).toBe(mockSubsystemNQN);
+    expect(component.groupName).toBe(mockGroupName);
+  });
+
+  it('should fetch initiators on init and build stepTwoValue with host NQNs', () => {
+    nvmeofService.getInitiators.mockReturnValue(of(mockHosts));
+    component.ngOnInit();
+    expect(nvmeofService.getInitiators).toHaveBeenCalledWith(mockSubsystemNQN, mockGroupName);
+    expect(component.stepTwoValue?.addedHosts).toEqual([
+      'nqn.2014-08.org.nvmexpress:uuid:host-1',
+      'nqn.2014-08.org.nvmexpress:uuid:host-2'
+    ]);
+  });
+
+  it('should handle hosts returned as { hosts: [...] } response shape', () => {
+    nvmeofService.getInitiators.mockReturnValue(of({ hosts: mockHosts }));
+    component.ngOnInit();
+    expect(component.stepTwoValue?.addedHosts.length).toBe(2);
+  });
+
+  it('should handle empty host list', () => {
+    nvmeofService.getInitiators.mockReturnValue(of([]));
+    component.ngOnInit();
+    expect(component.stepTwoValue?.addedHosts).toEqual([]);
+  });
+
+  describe('initialAuthType pre-selection', () => {
+    it('should default to Unidirectional when subsystem has no DHCHAP key', () => {
+      expect(component.initialAuthType).toBe(AUTHENTICATION.Unidirectional);
+      expect(component.authStep.formGroup.get('authType')?.value).toBe(
+        AUTHENTICATION.Unidirectional
+      );
+    });
+
+    it('should patch form control to Bidirectional when subsystem and host both have keys', () => {
+      nvmeofService.getSubsystem.mockReturnValue(of({ has_dhchap_key: true }));
+      nvmeofService.getInitiators.mockReturnValue(of(mockHostsWithKey));
+      component.ngOnInit();
+      expect(component.initialAuthType).toBe(AUTHENTICATION.Bidirectional);
+      component.authStep.initialAuthType = component.initialAuthType;
+      expect(component.authStep.formGroup.get('authType')?.value).toBe(
+        AUTHENTICATION.Bidirectional
+      );
+    });
+
+    it('should set Unidirectional when only host has a key', () => {
+      nvmeofService.getSubsystem.mockReturnValue(of({ has_dhchap_key: false }));
+      nvmeofService.getInitiators.mockReturnValue(of(mockHostsWithKey));
+      component.ngOnInit();
+      expect(component.initialAuthType).toBe(AUTHENTICATION.Unidirectional);
+      expect(component.authStep.formGroup.get('authType')?.value).toBe(
+        AUTHENTICATION.Unidirectional
+      );
+    });
+
+    it('should set Unidirectional when no keys are present', () => {
+      nvmeofService.getSubsystem.mockReturnValue(of({ has_dhchap_key: false }));
+      nvmeofService.getInitiators.mockReturnValue(of([]));
+      component.ngOnInit();
+      expect(component.initialAuthType).toBe(AUTHENTICATION.Unidirectional);
+    });
+  });
+
+  describe('showAuthAlert', () => {
+    it('should be false by default', () => {
+      expect(component.showAuthAlert).toBe(false);
+    });
+
+    it('should remain false when subsystem has no DHCHAP key and radio changes to Unidirectional', () => {
+      nvmeofService.getSubsystem.mockReturnValue(of({ has_dhchap_key: false }));
+      component.ngOnInit();
+      component.authStep.formGroup.get('authType')?.setValue(AUTHENTICATION.Unidirectional);
+      expect(component.showAuthAlert).toBe(false);
+    });
+
+    it('should remain false when subsystem has DHCHAP key and radio changes to Bidirectional', () => {
+      nvmeofService.getSubsystem.mockReturnValue(of({ has_dhchap_key: true }));
+      component.ngOnInit();
+      component.authStep.formGroup.get('authType')?.setValue(AUTHENTICATION.Bidirectional);
+      expect(component.showAuthAlert).toBe(false);
+    });
+
+    it('should become true when subsystem has DHCHAP key and radio changes to Unidirectional', () => {
+      nvmeofService.getSubsystem.mockReturnValue(of({ has_dhchap_key: true }));
+      component.ngOnInit();
+      component.authStep.formGroup.get('authType')?.setValue(AUTHENTICATION.Unidirectional);
+      expect(component.showAuthAlert).toBe(true);
+    });
+  });
+
+  describe('onSubmit', () => {
+    it('should not call updateAuthenticationKey when form is invalid', () => {
+      const form = component.authStep?.formGroup;
+      if (form) {
+        form.markAllAsTouched();
+        form.setErrors({ test: true });
+      }
+      component.onSubmit();
+      expect(nvmeofService.updateAuthenticationKey).not.toHaveBeenCalled();
+    });
+
+    it('should not call updateAuthenticationKey when authStep is absent', () => {
+      component.authStep = undefined as any;
+      component.onSubmit();
+      expect(nvmeofService.updateAuthenticationKey).not.toHaveBeenCalled();
+    });
+
+    it('should extract and submit authentication settings on valid Bidirectional form', () => {
+      const validKey = 'Q2VwaE52bWVvRkNoYXBTeW50aGV0aWNLZXkxMjM0NTY=';
+      const form = component.authStep.formGroup;
+      form.get('authType')?.setValue(AUTHENTICATION.Bidirectional);
+      form.get('subsystemDchapKey')?.setValue(validKey);
+      component.onSubmit();
+      expect(nvmeofService.updateAuthenticationKey).toHaveBeenCalledWith(
+        mockSubsystemNQN,
+        mockGroupName,
+        expect.objectContaining({
+          authType: AUTHENTICATION.Bidirectional,
+          subsystemKey: validKey
+        })
+      );
+    });
+
+    it('should submit Unidirectional authentication without subsystem key', () => {
+      const form = component.authStep?.formGroup;
+      form?.get('authType')?.setValue(AUTHENTICATION.Unidirectional);
+      component.onSubmit();
+      expect(nvmeofService.updateAuthenticationKey).toHaveBeenCalledWith(
+        mockSubsystemNQN,
+        mockGroupName,
+        expect.objectContaining({ authType: AUTHENTICATION.Unidirectional })
+      );
+    });
+
+    it('should show notification and emit closeChange on successful submit', (done) => {
+      // closeChange fires synchronously inside onSubmit's complete handler,
+      // but dismissAll is called after — use setTimeout to assert the full chain.
+      component.closeChange.subscribe(() => {
+        setTimeout(() => {
+          expect(notificationService.show).toHaveBeenCalledWith(
+            expect.anything(),
+            expect.stringContaining('updated'),
+            expect.anything()
+          );
+          expect(modalService.dismissAll).toHaveBeenCalled();
+          done();
+        }, 0);
+      });
+
+      const form = component.authStep?.formGroup;
+      form?.get('authType')?.setValue(AUTHENTICATION.Unidirectional);
+      component.onSubmit();
+    });
+
+    it('should mark form with submission error on API failure', (done) => {
+      const form = component.authStep?.formGroup;
+      form?.get('authType')?.setValue(AUTHENTICATION.Unidirectional);
+      nvmeofService.updateAuthenticationKey.mockReturnValue(
+        throwError(() => ({ status: 500, message: 'Server error' }))
+      );
+
+      component.onSubmit();
+
+      setTimeout(() => {
+        expect(component.isSubmitLoading).toBe(false);
+        expect(form?.hasError('cdSubmitButton')).toBe(true);
+        expect(notificationService.show).not.toHaveBeenCalled();
+        expect(modalService.dismissAll).not.toHaveBeenCalled();
+        done();
+      }, 0);
+    });
+
+    it('should clear isSubmitLoading after error', (done) => {
+      const form = component.authStep?.formGroup;
+      form?.get('authType')?.setValue(AUTHENTICATION.Unidirectional);
+      nvmeofService.updateAuthenticationKey.mockReturnValue(
+        throwError(() => new Error('Network error'))
+      );
+
+      component.onSubmit();
+
+      setTimeout(() => {
+        expect(component.isSubmitLoading).toBe(false);
+        done();
+      }, 0);
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-authentication/nvmeof-edit-authentication.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-authentication/nvmeof-edit-authentication.component.ts
new file mode 100644 (file)
index 0000000..0c1b1aa
--- /dev/null
@@ -0,0 +1,179 @@
+import {
+  AfterViewInit,
+  ChangeDetectorRef,
+  Component,
+  EventEmitter,
+  Inject,
+  OnInit,
+  Output,
+  ViewChild
+} from '@angular/core';
+import { FormGroup } from '@angular/forms';
+import { forkJoin } from 'rxjs';
+import { finalize } from 'rxjs/operators';
+
+import { BaseModal } from 'carbon-components-angular';
+import { AuthKeyUpdate, NvmeofService } from '~/app/shared/api/nvmeof.service';
+import {
+  AUTHENTICATION,
+  HostStepType,
+  NvmeofSubsystem,
+  NvmeofSubsystemInitiator,
+  getSubsystemAuthStatus,
+  NvmeofSubsystemAuthType
+} from '~/app/shared/models/nvmeof';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
+import { NvmeofSubsystemsStepThreeComponent } from '../nvmeof-subsystems-form/nvmeof-subsystem-step-3/nvmeof-subsystem-step-3.component';
+
+export const SUBSYSTEM_NQN_TOKEN = 'subsystemNQN';
+export const GROUP_NAME_TOKEN = 'groupName';
+
+@Component({
+  selector: 'cd-nvmeof-edit-authentication',
+  templateUrl: './nvmeof-edit-authentication.component.html',
+  styleUrls: ['./nvmeof-edit-authentication.component.scss'],
+  standalone: false
+})
+export class NvmeofEditAuthenticationComponent extends BaseModal implements OnInit, AfterViewInit {
+  @ViewChild(NvmeofSubsystemsStepThreeComponent)
+  authStep!: NvmeofSubsystemsStepThreeComponent;
+
+  @Output() closeChange = new EventEmitter<void>();
+
+  isSubmitLoading = false;
+  stepTwoValue: HostStepType | null = null;
+  subsystemHasDhchapKey = false;
+  initialAuthType: AUTHENTICATION = AUTHENTICATION.Unidirectional;
+  showAuthAlert = false;
+
+  readonly steps = [{ label: $localize`Authentication`, invalid: false }];
+  readonly title = $localize`Edit authentication`;
+  readonly description = $localize`Configure authentication to verify the identity of connecting hosts and protect the subsystem from unauthorized access.`;
+
+  constructor(
+    @Inject(SUBSYSTEM_NQN_TOKEN) public subsystemNQN: string,
+    @Inject(GROUP_NAME_TOKEN) public groupName: string,
+    private nvmeofService: NvmeofService,
+    private notificationService: NotificationService,
+    private modalCdsService: ModalCdsService,
+    private cdr: ChangeDetectorRef
+  ) {
+    super();
+  }
+
+  ngOnInit() {
+    forkJoin({
+      subsystem: this.nvmeofService.getSubsystem(this.subsystemNQN, this.groupName),
+      initiators: this.nvmeofService.getInitiators(this.subsystemNQN, this.groupName)
+    }).subscribe(({ subsystem, initiators }) => {
+      const sub = subsystem as NvmeofSubsystem;
+      this.subsystemHasDhchapKey = sub.has_dhchap_key;
+      this.initialAuthType = this.deriveAuthType(sub, initiators);
+      this.stepTwoValue = this.buildStepTwoValue(initiators);
+    });
+  }
+
+  /**
+   * @ViewChild is guaranteed to be set by ngAfterViewInit.
+   * Subscribing here ensures the authType form control exists before we attach
+   * the valueChanges listener that drives showAuthAlert.
+   */
+  ngAfterViewInit() {
+    this.authStep.formGroup.get('authType')?.valueChanges.subscribe((authType: AUTHENTICATION) => {
+      this.showAuthAlert = this.subsystemHasDhchapKey && authType === AUTHENTICATION.Unidirectional;
+      this.cdr.markForCheck();
+    });
+  }
+
+  /**
+   * Maps the current subsystem auth status string to the AUTHENTICATION enum
+   * so the form radio pre-selects the correct option on open.
+   */
+  private deriveAuthType(subsystem: NvmeofSubsystem, initiators: unknown): AUTHENTICATION {
+    const status = getSubsystemAuthStatus(
+      subsystem,
+      initiators as NvmeofSubsystemInitiator[] | { hosts?: NvmeofSubsystemInitiator[] }
+    );
+    switch (status) {
+      case NvmeofSubsystemAuthType.BIDIRECTIONAL:
+        return AUTHENTICATION.Bidirectional;
+      case NvmeofSubsystemAuthType.UNIDIRECTIONAL:
+        return AUTHENTICATION.Unidirectional;
+      default:
+        return AUTHENTICATION.Unidirectional;
+    }
+  }
+
+  private buildStepTwoValue(initiators: unknown): HostStepType {
+    const hosts = this.extractHostsFromResponse(initiators);
+    return {
+      addedHosts: hosts.map((h) => h.nqn),
+      hostname: '',
+      hostType: 'specific'
+    };
+  }
+
+  /**
+   * Handles both response shapes from getInitiators:
+   * - direct array: `NvmeofSubsystemInitiator[]`
+   * - object wrapper: `{ hosts: NvmeofSubsystemInitiator[] }`
+   */
+  private extractHostsFromResponse(response: unknown): NvmeofSubsystemInitiator[] {
+    if (!response) {
+      return [];
+    }
+    if (Array.isArray(response)) {
+      return response;
+    }
+    const hostWrapper = response as { hosts?: NvmeofSubsystemInitiator[] };
+    return hostWrapper.hosts ?? [];
+  }
+
+  onSubmit() {
+    const form = this.getValidatedForm();
+    if (!form) {
+      return;
+    }
+
+    this.isSubmitLoading = true;
+
+    const update = this.buildAuthenticationUpdate(form);
+
+    this.nvmeofService
+      .updateAuthenticationKey(this.subsystemNQN, this.groupName, update)
+      .pipe(
+        finalize(() => {
+          this.isSubmitLoading = false;
+        })
+      )
+      .subscribe({
+        error: () => {
+          form.setErrors({ cdSubmitButton: true });
+        },
+        complete: () => {
+          this.notificationService.show(
+            NotificationType.success,
+            $localize`Authentication updated`,
+            $localize`Authentication settings for subsystem ${this.subsystemNQN} have been saved.`
+          );
+          this.closeChange.emit();
+          this.modalCdsService.dismissAll();
+        }
+      });
+  }
+
+  private getValidatedForm(): FormGroup | null {
+    const form = this.authStep?.formGroup;
+    return form && !form.invalid ? form : null;
+  }
+
+  private buildAuthenticationUpdate(form: FormGroup): AuthKeyUpdate {
+    return {
+      authType: form.get('authType')?.value,
+      subsystemKey: form.get('subsystemDchapKey')?.value,
+      hostKeyList: form.get('hostDchapKeyList')?.value
+    };
+  }
+}
index f38f1fe0d4a2ccdd3147aacf31ab0587e23cd502..b14b435e9dfb5287588b05fa39a3793c5fd8fb36 100644 (file)
             i18n>Special characters are not allowed.</span>
         </div>
       </div>
-
       <div cdsRow
            class="form-item">
         <div cdsCol>
           <cds-checkbox id="enableCds"
-                        formControlName="enable_auth"
+                        formControlName="enableEncryption"
                         i18n>Enable encryption
           </cds-checkbox>
         </div>
       </div>
 
-      @if (groupForm.controls.enable_auth.value) {
+      @if (groupForm.controls.enableEncryption.value) {
       <div cdsRow
            class="form-item">
         <div cdsCol>
                       cdValidate
                       #encryptionConfigRef="cdValidate"
                       [invalid]="encryptionConfigRef.isInvalid"
-                      formControlName="encryptionKey"
+                      formControlName="encryptionConfig"
                       cols="100"
                       rows="4"></textarea>
           </cds-textarea-label>
           <span
             class="invalid-feedback"
-            *ngIf="groupForm.showError('encryptionKey', formDir, 'required')"
+            *ngIf="groupForm.showError('encryptionConfig', formDir, 'required')"
             i18n>This field is required.</span>
         </div>
       </div>
index e873691c07795d2eecd85e61c9080093c0a1f444..c975058fe7cc55558fb8aabb81d5f4baa3c8bbf9 100644 (file)
             <span class="cds--type-label-01"
                   i18n>Authentication</span>
             <div>
-              <cd-icon [type]="getStatusIcon(detail)"></cd-icon>
-              <span class="cds--type-label-02">
-                {{ detail.value ? 'Bi-directional' : 'No authentication' }}
-              </span>
+              <cd-icon [type]="getAuthStatusIcon(detail.value.toString())"
+                       class="cds-mr-3"></cd-icon>
+              <span class="cds--type-label-02">{{ detail.value }}</span>
               <a cdsLink
-                 [routerLink]="['../hosts']"
-                 [queryParams]="{ group: groupName }"
+                 class="cds-ml-2"
+                 (click)="openEditAuthModal()"
                  [cdsStack]="'horizontal'"
                  [gap]="1">
                 <span i18n>Edit</span>
index 364ab36502afe0979eb7042b29ea59d75a6b3e68..4b4dc82ecf974d7fc1c2712a2acddbbad3fcb5e2 100644 (file)
@@ -10,13 +10,14 @@ import { GridModule, TilesModule } from 'carbon-components-angular';
 import { NvmeofSubsystemOverviewComponent } from './nvmeof-subsystem-overview.component';
 import { NvmeofService } from '~/app/shared/api/nvmeof.service';
 import { SharedModule } from '~/app/shared/shared.module';
+import { NvmeofSubsystem, NvmeofSubsystemInitiator } from '~/app/shared/models/nvmeof';
 
 describe('NvmeofSubsystemOverviewComponent', () => {
   let component: NvmeofSubsystemOverviewComponent;
   let fixture: ComponentFixture<NvmeofSubsystemOverviewComponent>;
   let nvmeofService: NvmeofService;
 
-  const mockSubsystem = {
+  const mockSubsystem: NvmeofSubsystem = {
     nqn: 'nqn.2016-06.io.spdk:cnode1',
     serial_number: 'Ceph30487186726692',
     model_number: 'Ceph bdev Controller',
@@ -32,8 +33,21 @@ describe('NvmeofSubsystemOverviewComponent', () => {
     network_mask: []
   };
 
-  beforeEach(async () => {
-    await TestBed.configureTestingModule({
+  const defaultActivatedRoute = {
+    parent: { params: of({ subsystem_nqn: 'nqn.2016-06.io.spdk:cnode1' }) },
+    queryParams: of({ group: 'group1' })
+  };
+
+  /**
+   * Creates a TestBed configuration with custom service overrides.
+   * Avoids repeating the full module declaration in tests that need different mock data.
+   */
+  function createTestBed(
+    initiators: NvmeofSubsystemInitiator[],
+    subsystem: NvmeofSubsystem = mockSubsystem,
+    activatedRoute: object = defaultActivatedRoute
+  ): Promise<ComponentFixture<NvmeofSubsystemOverviewComponent>> {
+    return TestBed.configureTestingModule({
       declarations: [NvmeofSubsystemOverviewComponent],
       imports: [
         HttpClientTestingModule,
@@ -44,19 +58,44 @@ describe('NvmeofSubsystemOverviewComponent', () => {
         GridModule
       ],
       providers: [
+        { provide: ActivatedRoute, useValue: activatedRoute },
         {
-          provide: ActivatedRoute,
+          provide: NvmeofService,
           useValue: {
-            parent: {
-              params: of({ subsystem_nqn: 'nqn.2016-06.io.spdk:cnode1' })
-            },
-            queryParams: of({ group: 'group1' })
+            getSubsystem: jest.fn().mockReturnValue(of(subsystem)),
+            getInitiators: jest.fn().mockReturnValue(of(initiators))
           }
-        },
+        }
+      ]
+    })
+      .compileComponents()
+      .then(() => {
+        const f = TestBed.createComponent(NvmeofSubsystemOverviewComponent);
+        f.detectChanges();
+        tick();
+        f.detectChanges();
+        return f;
+      });
+  }
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [NvmeofSubsystemOverviewComponent],
+      imports: [
+        HttpClientTestingModule,
+        RouterTestingModule,
+        SharedModule,
+        NgbTooltipModule,
+        TilesModule,
+        GridModule
+      ],
+      providers: [
+        { provide: ActivatedRoute, useValue: defaultActivatedRoute },
         {
           provide: NvmeofService,
           useValue: {
-            getSubsystem: jest.fn().mockReturnValue(of(mockSubsystem))
+            getSubsystem: jest.fn().mockReturnValue(of(mockSubsystem)),
+            getInitiators: jest.fn().mockReturnValue(of([]))
           }
         }
       ]
@@ -93,44 +132,13 @@ describe('NvmeofSubsystemOverviewComponent', () => {
     expect(component.subsystem.gw_group).toBe('gateway-prod');
   }));
 
-  it('should not fetch when subsystemNQN is missing', fakeAsync(() => {
+  it('should not fetch when subsystemNQN is missing', fakeAsync(async () => {
     TestBed.resetTestingModule();
-    TestBed.configureTestingModule({
-      declarations: [NvmeofSubsystemOverviewComponent],
-      imports: [
-        HttpClientTestingModule,
-        RouterTestingModule,
-        SharedModule,
-        NgbTooltipModule,
-        TilesModule,
-        GridModule
-      ],
-      providers: [
-        {
-          provide: ActivatedRoute,
-          useValue: {
-            parent: {
-              params: of({})
-            },
-            queryParams: of({ group: 'group1' })
-          }
-        },
-        {
-          provide: NvmeofService,
-          useValue: {
-            getSubsystem: jest.fn().mockReturnValue(of(mockSubsystem))
-          }
-        }
-      ]
-    }).compileComponents();
-
-    const newFixture = TestBed.createComponent(NvmeofSubsystemOverviewComponent);
-    const newComponent = newFixture.componentInstance;
+    const noNqnRoute = { parent: { params: of({}) }, queryParams: of({ group: 'group1' }) };
+    const f = await createTestBed([], mockSubsystem, noNqnRoute);
     const newService = TestBed.inject(NvmeofService);
-    newFixture.detectChanges();
-    tick();
     expect(newService.getSubsystem).not.toHaveBeenCalled();
-    expect(newComponent.subsystem).toBeUndefined();
+    expect(f.componentInstance.subsystem).toBeUndefined();
   }));
 
   it('should render detail labels in the template', fakeAsync(() => {
@@ -160,7 +168,23 @@ describe('NvmeofSubsystemOverviewComponent', () => {
 
     const hostAccessText = fixture.nativeElement.textContent;
     expect(hostAccessText).toContain('Allow all hosts');
-    expect(hostAccessText).toContain('Bi-directional');
+    // has_dhchap_key=true but no initiators with use_dhchap → No authentication
+    expect(hostAccessText).toContain('No authentication');
     expect(hostAccessText).toContain('Edit');
   }));
+
+  it('should display Bidirectional when subsystem and host both have keys', fakeAsync(async () => {
+    TestBed.resetTestingModule();
+    const f = await createTestBed([{ nqn: 'nqn.host-1', use_dhchap: true }]);
+    expect(f.nativeElement.textContent).toContain('Bi-directional');
+  }));
+
+  it('should display Unidirectional when only host has a key', fakeAsync(async () => {
+    TestBed.resetTestingModule();
+    const f = await createTestBed([{ nqn: 'nqn.host-1', use_dhchap: true }], {
+      ...mockSubsystem,
+      has_dhchap_key: false
+    });
+    expect(f.nativeElement.textContent).toContain('Unidirectional');
+  }));
 });
index 5b9643065d930d7acca98313dcf1c0a3ff0bc9ec..1abf3c48262598ac1a4f1aedf960765a89d128a8 100644 (file)
@@ -1,8 +1,17 @@
 import { Component, OnInit } from '@angular/core';
 import { ActivatedRoute } from '@angular/router';
+import { forkJoin } from 'rxjs';
+
 import { NvmeofService } from '~/app/shared/api/nvmeof.service';
-import { NvmeofSubsystem } from '~/app/shared/models/nvmeof';
+import {
+  NvmeofSubsystem,
+  NvmeofSubsystemInitiator,
+  NO_AUTH,
+  getSubsystemAuthStatus
+} from '~/app/shared/models/nvmeof';
 import { ICON_TYPE } from '~/app/shared/enum/icons.enum';
+import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
+import { NvmeofEditAuthenticationComponent } from '../nvmeof-edit-authentication/nvmeof-edit-authentication.component';
 
 export interface SubsystemDetail {
   label: string;
@@ -24,7 +33,11 @@ export class NvmeofSubsystemOverviewComponent implements OnInit {
   subsystem!: NvmeofSubsystem;
   details: SubsystemDetail[] = [];
 
-  constructor(private route: ActivatedRoute, private nvmeofService: NvmeofService) {}
+  constructor(
+    private route: ActivatedRoute,
+    private nvmeofService: NvmeofService,
+    private modalService: ModalCdsService
+  ) {}
 
   ngOnInit() {
     this.route.parent?.params.subscribe((params) => {
@@ -44,13 +57,19 @@ export class NvmeofSubsystemOverviewComponent implements OnInit {
   }
 
   fetchSubsystem() {
-    this.nvmeofService.getSubsystem(this.subsystemNQN, this.groupName).subscribe((subsystem) => {
+    forkJoin({
+      subsystem: this.nvmeofService.getSubsystem(this.subsystemNQN, this.groupName),
+      initiators: this.nvmeofService.getInitiators(this.subsystemNQN, this.groupName)
+    }).subscribe(({ subsystem, initiators }) => {
       this.subsystem = subsystem as NvmeofSubsystem;
-      this.buildDetails();
+      const initiatorList = initiators as
+        | NvmeofSubsystemInitiator[]
+        | { hosts?: NvmeofSubsystemInitiator[] };
+      this.buildDetails(getSubsystemAuthStatus(this.subsystem, initiatorList));
     });
   }
 
-  private buildDetails() {
+  private buildDetails(authStatus: string) {
     this.details = [
       {
         label: $localize`Serial number`,
@@ -73,7 +92,7 @@ export class NvmeofSubsystemOverviewComponent implements OnInit {
       },
       {
         label: $localize`Authentication`,
-        value: this.subsystem.has_dhchap_key,
+        value: authStatus,
         type: 'auth',
         row: 2
       },
@@ -124,6 +143,10 @@ export class NvmeofSubsystemOverviewComponent implements OnInit {
     return String(value);
   }
 
+  getAuthStatusIcon(authStatus: string): keyof typeof ICON_TYPE {
+    return authStatus === NO_AUTH ? 'error' : 'success';
+  }
+
   getStatusIcon(detail: SubsystemDetail): keyof typeof ICON_TYPE {
     return detail.value ? 'success' : 'error';
   }
@@ -140,4 +163,14 @@ export class NvmeofSubsystemOverviewComponent implements OnInit {
     const needed = 3 - this.getDetailsForRow(row).length;
     return Array.from({ length: needed });
   }
+
+  openEditAuthModal() {
+    const modalRef = this.modalService.show(NvmeofEditAuthenticationComponent, {
+      subsystemNQN: this.subsystemNQN,
+      groupName: this.groupName
+    });
+    if (modalRef?.closeChange) {
+      modalRef.closeChange.subscribe(() => this.fetchSubsystem());
+    }
+  }
 }
index 9bfc3c4c0cdf4ccb5cf704c87371928daed96361..e66703599528287ce8e8afe484e5bfa99ac815c6 100644 (file)
@@ -15,6 +15,13 @@ import { TearsheetStep } from '~/app/shared/models/tearsheet-step';
 })
 export class NvmeofSubsystemsStepThreeComponent implements OnInit, TearsheetStep {
   @Input() group!: string;
+  @Input() set initialAuthType(value: AUTHENTICATION) {
+    this._initialAuthType = value;
+    if (this.formGroup) {
+      this.formGroup.get('authType')?.setValue(value, { emitEvent: false });
+      this.refreshHostKeyValidation();
+    }
+  }
   @Input() set stepTwoValue(value: HostStepType | null) {
     this._addedHosts = value?.addedHosts ?? [];
     if (this.formGroup) {
@@ -31,6 +38,7 @@ export class NvmeofSubsystemsStepThreeComponent implements OnInit, TearsheetStep
   };
   AUTHENTICATION = AUTHENTICATION;
 
+  _initialAuthType: AUTHENTICATION = AUTHENTICATION.Unidirectional;
   _addedHosts: Array<string> = [];
 
   constructor(public actionLabels: ActionLabelsI18n) {}
@@ -55,7 +63,7 @@ export class NvmeofSubsystemsStepThreeComponent implements OnInit, TearsheetStep
 
   private createForm() {
     this.formGroup = new CdFormGroup({
-      authType: new UntypedFormControl(AUTHENTICATION.Unidirectional),
+      authType: new UntypedFormControl(this._initialAuthType),
       subsystemDchapKey: new UntypedFormControl(null, [
         CdValidators.base64(),
         CdValidators.requiredIf({
index 121299a9d30d22ed2394c772e07931ba97ae7737..fb3bb1c9edefe2f7f0fc42f6247bc649b88a954c 100644 (file)
@@ -5,7 +5,12 @@ import _ from 'lodash';
 import { Observable, forkJoin, of as observableOf } from 'rxjs';
 import { catchError, map, mapTo, mergeMap, switchMap } from 'rxjs/operators';
 import { CephServiceSpec } from '../models/service.interface';
-import { ListenerItem, NvmeofSubsystem, NvmeofSubsystemNamespace } from '../models/nvmeof';
+import {
+  AUTHENTICATION,
+  ListenerItem,
+  NvmeofSubsystem,
+  NvmeofSubsystemNamespace
+} from '../models/nvmeof';
 import { HostService } from './host.service';
 import { OrchestratorService } from './orchestrator.service';
 import { HostStatus } from '../enum/host-status.enum';
@@ -65,6 +70,12 @@ export type NamespaceInitiatorRequest = InitiatorRequest & {
   subsystem_nqn: string;
 };
 
+export type AuthKeyUpdate = {
+  authType: AUTHENTICATION;
+  subsystemKey: string | null;
+  hostKeyList: Array<{ host_nqn: string; dhchap_key: string | null }>;
+};
+
 const API_PATH = 'api/nvmeof';
 const UI_API_PATH = 'ui-api/nvmeof';
 
@@ -300,6 +311,14 @@ export class NvmeofService {
     });
   }
 
+  changeSubsystemKey(subsystemNQN: string, dhchapKey: string, gwGroup: string) {
+    return this.http.put(
+      `${API_PATH}/subsystem/${subsystemNQN}/change_key`,
+      { dhchap_key: dhchapKey, gw_group: gwGroup },
+      { observe: 'response' }
+    );
+  }
+
   updateHostKey(subsystemNQN: string, request: InitiatorRequest) {
     return this.http.put(
       `${API_PATH}/subsystem/${subsystemNQN}/host/${request.host_nqn}/change_key`,
@@ -310,6 +329,31 @@ export class NvmeofService {
     );
   }
 
+  updateAuthenticationKey(
+    subsystemNQN: string,
+    gwGroup: string,
+    update: AuthKeyUpdate
+  ): Observable<void> {
+    const { authType, subsystemKey, hostKeyList } = update;
+
+    const subsystemKeyCall =
+      authType === AUTHENTICATION.Bidirectional && subsystemKey
+        ? this.changeSubsystemKey(subsystemNQN, subsystemKey, gwGroup)
+        : observableOf(null);
+
+    const hostKeyCalls = hostKeyList
+      .filter((item) => !!item.dhchap_key)
+      .map((item) =>
+        this.updateHostKey(subsystemNQN, {
+          host_nqn: item.host_nqn,
+          dhchap_key: item.dhchap_key,
+          gw_group: gwGroup
+        }).pipe(catchError(() => observableOf(null)))
+      );
+
+    return forkJoin([subsystemKeyCall, ...hostKeyCalls]).pipe(map(() => undefined));
+  }
+
   removeInitiators(subsystemNQN: string, request: InitiatorRequest) {
     return this.http.delete(
       `${UI_API_PATH}/subsystem/${subsystemNQN}/host/${request.host_nqn}/${request.gw_group}`,
index b72f8e2893c5948db5bbd1bd76a24ff33363e461..3b3a64eff033a2430268f15a60c8c664232b3c19 100644 (file)
@@ -13545,6 +13545,68 @@ paths:
       summary: Get information from a specific NVMeoF subsystem
       tags:
       - NVMe-oF Subsystem
+  /api/nvmeof/subsystem/{nqn}/change_key:
+    put:
+      parameters:
+      - description: NVMeoF subsystem NQN
+        in: path
+        name: nqn
+        required: true
+        schema:
+          type: string
+      requestBody:
+        content:
+          application/json:
+            schema:
+              properties:
+                dhchap_key:
+                  description: Subsystem DH-HMAC-CHAP key
+                  type: string
+                gw_group:
+                  description: NVMeoF gateway group
+                  type: string
+                server_address:
+                  description: NVMeoF gateway address
+                  type: string
+                traddr:
+                  description: NVMeoF gateway address (deprecated)
+                  type: string
+              required:
+              - dhchap_key
+              type: object
+      responses:
+        '200':
+          content:
+            application/json:
+              schema:
+                type: object
+            application/vnd.ceph.api.v1.0+json:
+              schema:
+                type: object
+          description: Resource updated.
+        '202':
+          content:
+            application/json:
+              schema:
+                type: object
+            application/vnd.ceph.api.v1.0+json:
+              schema:
+                type: object
+          description: Operation is still executing. Please check the task queue.
+        '400':
+          description: Operation exception. Please check the response body for details.
+        '401':
+          description: Unauthenticated access. Please login first.
+        '403':
+          description: Unauthorized access. Please check your permissions.
+        '500':
+          description: Unexpected error. Please check the response body for the stack
+            trace.
+      security:
+      - jwt: []
+      summary: Change subsystem inband authentication key
+      tags:
+      - NVMe-oF Subsystem
   /api/nvmeof/subsystem/{nqn}/connection:
     get:
       parameters: