From: Pere Diaz Bou Date: Mon, 23 Jan 2023 11:34:12 +0000 (+0100) Subject: mgr/dashboard: rgw role listing X-Git-Tag: v17.2.7~420^2~5 X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=cc8fee4e439fd7f5b9433d00b3653a3de086d181;p=ceph.git mgr/dashboard: rgw role listing Listing is performed using the radosgw-admin command api we have with the mgr for now until the S3 api is fixed: https://tracker.ceph.com/issues/58547. This commit fixes and issue with regards to the _crud.py controller where redefining `CRUDClassMetadata` caused the users table and the roles table to share columns. We fixed this by creating CRUDClassMetadata dynamically for each endpoint. The issue described above is linked to an issue with NamedTuple were default nested lists are not a great move because it can cause unexpected issues when 2 or more classes are created. Moreover, NamedTuples are read-only making initialization even harder without factory methods as with dataclasses. Therefore, let's move to the good old __init__ :). Signed-off-by: Pere Diaz Bou Fixes: https://tracker.ceph.com/issues/58699 (cherry picked from commit 07e07b8d8a39e8cec3cce30e9cdd5439cc9b2906) --- diff --git a/src/pybind/mgr/dashboard/controllers/_crud.py b/src/pybind/mgr/dashboard/controllers/_crud.py index 94b1a3146d7a..0ac299db6e46 100644 --- a/src/pybind/mgr/dashboard/controllers/_crud.py +++ b/src/pybind/mgr/dashboard/controllers/_crud.py @@ -18,9 +18,21 @@ def isnamedtuple(o): return isinstance(o, tuple) and hasattr(o, '_asdict') and hasattr(o, '_fields') +class SerializableClass: + def __iter__(self): + for attr in self.__dict__: + if not attr.startswith("__"): + yield attr, getattr(self, attr) + + def __contains__(self, value): + return value in self.__dict__ + + def __len__(self): + return len(self.__dict__) + + def serialize(o, expected_type=None): # pylint: disable=R1705,W1116 - print(o, expected_type) if isnamedtuple(o): hints = get_type_hints(o) return {k: serialize(v, hints[k]) for k, v in zip(o._fields, o)} @@ -30,6 +42,8 @@ def serialize(o, expected_type=None): # NOTE: we could add a metadata value in a list to indentify tuples and, # sets if we wanted but for now let's go for lists. return [serialize(i) for i in o] + elif isinstance(o, SerializableClass): + return {serialize(k): serialize(v) for k, v in o} elif isinstance(o, (Iterator, Generator)): return [serialize(i) for i in o] elif expected_type and isclass(expected_type) and issubclass(expected_type, SecretStr): @@ -43,6 +57,7 @@ class TableColumn(NamedTuple): cellTemplate: str = '' isHidden: bool = False filterable: bool = True + flexGrow: int = 1 class TableAction(NamedTuple): @@ -52,10 +67,11 @@ class TableAction(NamedTuple): routerLink: str # redirect to... -class TableComponent(NamedTuple): - columns: List[TableColumn] = [] - columnMode: str = 'flex' - toolHeader: bool = True +class TableComponent(SerializableClass): + def __init__(self) -> None: + self.columns: List[TableColumn] = [] + self.columnMode: str = 'flex' + self.toolHeader: bool = True class Icon(Enum): @@ -269,11 +285,12 @@ class Form: return container_schema -class CRUDMeta(NamedTuple): - table: TableComponent = TableComponent() - permissions: List[str] = [] - actions: List[Dict[str, Any]] = [] - forms: List[Dict[str, Any]] = [] +class CRUDMeta(SerializableClass): + def __init__(self): + self.table = TableComponent() + self.permissions = [] + self.actions = [] + self.forms = [] class CRUDCollectionMethod(NamedTuple): @@ -286,23 +303,39 @@ class CRUDResourceMethod(NamedTuple): doc: EndpointDoc -class CRUDEndpoint(NamedTuple): - router: APIRouter - doc: APIDoc - set_column: Optional[Dict[str, Dict[str, str]]] = None - actions: List[TableAction] = [] - permissions: List[str] = [] - forms: List[Form] = [] - meta: CRUDMeta = CRUDMeta() - get_all: Optional[CRUDCollectionMethod] = None - create: Optional[CRUDCollectionMethod] = None - +class CRUDEndpoint: # for testing purposes CRUDClass: Optional[RESTController] = None CRUDClassMetadata: Optional[RESTController] = None - # --------------------- - def __call__(self, cls: NamedTuple): + # pylint: disable=R0902 + def __init__(self, router: APIRouter, doc: APIDoc, + set_column: Optional[Dict[str, Dict[str, str]]] = None, + actions: Optional[List[TableAction]] = None, + permissions: Optional[List[str]] = None, forms: Optional[List[Form]] = None, + meta: CRUDMeta = CRUDMeta(), get_all: Optional[CRUDCollectionMethod] = None, + create: Optional[CRUDCollectionMethod] = None): + self.router = router + self.doc = doc + self.set_column = set_column + if actions: + self.actions = actions + else: + self.actions = [] + + if forms: + self.forms = forms + else: + self.forms = [] + self.meta = meta + self.get_all = get_all + self.create = create + if permissions: + self.permissions = permissions + else: + self.permissions = [] + + def __call__(self, cls: Any): self.create_crud_class(cls) self.meta.table.columns.extend(TableColumn(prop=field) for field in cls._fields) @@ -334,42 +367,55 @@ class CRUDEndpoint(NamedTuple): cls.CRUDClass = CRUDClass def create_meta_class(self, cls): - outer_self: CRUDEndpoint = self - - @UIRouter(self.router.path, self.router.security_scope) - class CRUDClassMetadata(RESTController): - def list(self): - self.update_columns() - self.generate_actions() - self.generate_forms() - self.set_permissions() - return serialize(outer_self.meta) - - def update_columns(self): - if outer_self.set_column: - for i, column in enumerate(outer_self.meta.table.columns): - if column.prop in dict(outer_self.set_column): - new_template = outer_self.set_column[column.prop]["cellTemplate"] - new_column = TableColumn(column.prop, - new_template, - column.isHidden, - column.filterable) - outer_self.meta.table.columns[i] = new_column - - def generate_actions(self): - outer_self.meta.actions.clear() - - for action in outer_self.actions: - outer_self.meta.actions.append(action._asdict()) - - def generate_forms(self): - outer_self.meta.forms.clear() - - for form in outer_self.forms: - outer_self.meta.forms.append(form.to_dict()) - - def set_permissions(self): - if outer_self.permissions: - outer_self.meta.permissions.extend(outer_self.permissions) - - cls.CRUDClassMetadata = CRUDClassMetadata + def _list(self): + self.update_columns() + self.generate_actions() + self.generate_forms() + self.set_permissions() + return serialize(self.__class__.outer_self.meta) + + def update_columns(self): + if self.__class__.outer_self.set_column: + for i, column in enumerate(self.__class__.outer_self.meta.table.columns): + if column.prop in dict(self.__class__.outer_self.set_column): + prop = self.__class__.outer_self.set_column[column.prop] + new_template = "" + if "cellTemplate" in prop: + new_template = prop["cellTemplate"] + hidden = prop['isHidden'] if 'isHidden' in prop else False + flex_grow = prop['flexGrow'] if 'flexGrow' in prop else column.flexGrow + new_column = TableColumn(column.prop, + new_template, + hidden, + column.filterable, + flex_grow) + self.__class__.outer_self.meta.table.columns[i] = new_column + + def generate_actions(self): + self.__class__.outer_self.meta.actions.clear() + + for action in self.__class__.outer_self.actions: + self.__class__.outer_self.meta.actions.append(action._asdict()) + + def generate_forms(self): + self.__class__.outer_self.meta.forms.clear() + + for form in self.__class__.outer_self.forms: + self.__class__.outer_self.meta.forms.append(form.to_dict()) + + def set_permissions(self): + if self.__class__.outer_self.permissions: + self.outer_self.meta.permissions.extend(self.__class__.outer_self.permissions) + class_name = self.router.path.replace('/', '') + meta_class = type(f'{class_name}_CRUDClassMetadata', + (RESTController,), + { + 'list': _list, + 'update_columns': update_columns, + 'generate_actions': generate_actions, + 'generate_forms': generate_forms, + 'set_permissions': set_permissions, + 'outer_self': self, + }) + UIRouter(self.router.path, self.router.security_scope)(meta_class) + cls.CRUDClassMetadata = meta_class diff --git a/src/pybind/mgr/dashboard/controllers/ceph_users.py b/src/pybind/mgr/dashboard/controllers/ceph_users.py index 0c65ce1a912d..3562f33a2271 100644 --- a/src/pybind/mgr/dashboard/controllers/ceph_users.py +++ b/src/pybind/mgr/dashboard/controllers/ceph_users.py @@ -6,7 +6,8 @@ from ..exceptions import DashboardException from ..security import Scope from ..services.ceph_service import CephService, SendCommandError from . import APIDoc, APIRouter, CRUDCollectionMethod, CRUDEndpoint, EndpointDoc, SecretStr -from ._crud import ArrayHorizontalContainer, Form, FormField, Icon, TableAction, VerticalContainer +from ._crud import ArrayHorizontalContainer, CRUDMeta, Form, FormField, Icon, \ + TableAction, VerticalContainer logger = logging.getLogger("controllers.ceph_users") @@ -92,7 +93,8 @@ create_form = Form(path='/cluster/user/create', create=CRUDCollectionMethod( func=CephUserEndpoints.user_create, doc=EndpointDoc("Create Ceph User") - ) + ), + meta=CRUDMeta() ) class CephUser(NamedTuple): entity: str diff --git a/src/pybind/mgr/dashboard/controllers/rgw.py b/src/pybind/mgr/dashboard/controllers/rgw.py index f120b4a5ee3e..710a4980f7cd 100644 --- a/src/pybind/mgr/dashboard/controllers/rgw.py +++ b/src/pybind/mgr/dashboard/controllers/rgw.py @@ -2,6 +2,7 @@ import json import logging +from typing import Any, Dict, List, NamedTuple, Optional, Union import cherrypy @@ -12,15 +13,12 @@ from ..services.auth import AuthManager, JwtManager from ..services.ceph_service import CephService from ..services.rgw_client import NoRgwDaemonsException, RgwClient from ..tools import json_str_to_object, str_to_bool -from . import APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc, \ - ReadPermission, RESTController, UIRouter, allow_empty_body +from . import APIDoc, APIRouter, BaseController, CRUDCollectionMethod, \ + CRUDEndpoint, Endpoint, EndpointDoc, ReadPermission, RESTController, \ + UIRouter, allow_empty_body +from ._crud import CRUDMeta from ._version import APIVersion -try: - from typing import Any, Dict, List, Optional, Union -except ImportError: # pragma: no cover - pass # Just for type checking - logger = logging.getLogger("controllers.rgw") RGW_SCHEMA = { @@ -607,3 +605,37 @@ class RgwUser(RgwRESTController): 'subuser': subuser, 'purge-keys': purge_keys }, json_response=False) + + +class RGWRoleEndpoints: + @staticmethod + def role_list(_): + rgw_client = RgwClient.admin_instance() + roles = rgw_client.list_roles() + return roles + + +@CRUDEndpoint( + router=APIRouter('/rgw/user/roles', Scope.RGW), + doc=APIDoc("List of RGW roles", "RGW"), + actions=[], + permissions=[Scope.CONFIG_OPT], + get_all=CRUDCollectionMethod( + func=RGWRoleEndpoints.role_list, + doc=EndpointDoc("List RGW roles") + ), + set_column={ + "CreateDate": {'cellTemplate': 'date'}, + "MaxSessionDuration": {'cellTemplate': 'duration'}, + "AssumeRolePolicyDocument": {'isHidden': True} + }, + meta=CRUDMeta() +) +class RgwUserRole(NamedTuple): + RoleId: int + RoleName: str + Path: str + Arn: str + CreateDate: str + MaxSessionDuration: int + AssumeRolePolicyDocument: str 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 6c6d7677ec70..8f50e4abcb24 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 @@ -1,3 +1,5 @@ + + + + + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-tabs/rgw-user-tabs.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-tabs/rgw-user-tabs.component.scss new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-tabs/rgw-user-tabs.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-tabs/rgw-user-tabs.component.spec.ts new file mode 100644 index 000000000000..3b81e718dc43 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-tabs/rgw-user-tabs.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { RgwUserTabsComponent } from './rgw-user-tabs.component'; + +describe('RgwUserTabsComponent', () => { + let component: RgwUserTabsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [RgwUserTabsComponent] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(RgwUserTabsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-tabs/rgw-user-tabs.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-tabs/rgw-user-tabs.component.ts new file mode 100644 index 000000000000..95625ca62181 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-tabs/rgw-user-tabs.component.ts @@ -0,0 +1,8 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'cd-rgw-user-tabs', + templateUrl: './rgw-user-tabs.component.html', + styleUrls: ['./rgw-user-tabs.component.scss'] +}) +export class RgwUserTabsComponent {} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts index 7ecaddddfef0..edffa8e856b7 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts @@ -7,6 +7,7 @@ import { NgbNavModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; import { NgxPipeFunctionModule } from 'ngx-pipe-function'; import { ActionLabels, URLVerbs } from '~/app/shared/constants/app.constants'; +import { CRUDTableComponent } from '~/app/shared/datatable/crud-table/crud-table.component'; import { SharedModule } from '~/app/shared/shared.module'; import { PerformanceCounterModule } from '../performance-counter/performance-counter.module'; import { RgwBucketDetailsComponent } from './rgw-bucket-details/rgw-bucket-details.component'; @@ -22,6 +23,7 @@ import { RgwUserListComponent } from './rgw-user-list/rgw-user-list.component'; import { RgwUserS3KeyModalComponent } from './rgw-user-s3-key-modal/rgw-user-s3-key-modal.component'; import { RgwUserSubuserModalComponent } from './rgw-user-subuser-modal/rgw-user-subuser-modal.component'; import { RgwUserSwiftKeyModalComponent } from './rgw-user-swift-key-modal/rgw-user-swift-key-modal.component'; +import { RgwUserTabsComponent } from './rgw-user-tabs/rgw-user-tabs.component'; @NgModule({ imports: [ @@ -58,7 +60,8 @@ import { RgwUserSwiftKeyModalComponent } from './rgw-user-swift-key-modal/rgw-us RgwUserS3KeyModalComponent, RgwUserCapabilityModalComponent, RgwUserSubuserModalComponent, - RgwConfigModalComponent + RgwConfigModalComponent, + RgwUserTabsComponent ] }) export class RgwModule {} @@ -82,6 +85,24 @@ const routes: Routes = [ path: `${URLVerbs.EDIT}/:uid`, component: RgwUserFormComponent, data: { breadcrumbs: ActionLabels.EDIT } + }, + { + path: 'roles', + component: CRUDTableComponent, + data: { + breadcrumbs: 'Roles', + resource: 'api.rgw.user.roles@1.0', + tabs: [ + { + name: 'Users', + url: '/rgw/user' + }, + { + name: 'Roles', + url: '/rgw/user/roles' + } + ] + } } ] }, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.html index 87a3c586b4a8..b9b4ae62b34d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.html @@ -1,3 +1,16 @@ + +   + + + {{ value | cdDate }} + + + + {{ value | duration }} + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.ts index 7e82c523e78d..96346ea7a1b7 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.ts @@ -19,6 +19,10 @@ import { AuthStorageService } from '../../services/auth-storage.service'; export class CRUDTableComponent implements OnInit { @ViewChild('badgeDictTpl') public badgeDictTpl: TemplateRef; + @ViewChild('dateTpl') + public dateTpl: TemplateRef; + @ViewChild('durationTpl') + public durationTpl: TemplateRef; data$: Observable; meta$: Observable; @@ -26,6 +30,7 @@ export class CRUDTableComponent implements OnInit { permissions: Permissions; permission: Permission; selection = new CdTableSelection(); + tabs = {}; constructor( private authStorageService: AuthStorageService, @@ -43,9 +48,10 @@ export class CRUDTableComponent implements OnInit { */ this.activatedRoute.data.subscribe((data: any) => { const resource: string = data.resource; + this.tabs = data.tabs; this.dataGatewayService .list(`ui-${resource}`) - .subscribe((response: any) => this.processMeta(response)); + .subscribe((response: CrudMetadata) => this.processMeta(response)); this.data$ = this.timerService.get(() => this.dataGatewayService.list(resource)); }); } @@ -62,15 +68,22 @@ export class CRUDTableComponent implements OnInit { '' ); this.permission = this.permissions[toCamelCase(meta.permissions[0])]; - this.meta = meta; const templates = { - badgeDict: this.badgeDictTpl + badgeDict: this.badgeDictTpl, + date: this.dateTpl, + duration: this.durationTpl }; - this.meta.table.columns.forEach((element, index) => { + meta.table.columns.forEach((element, index) => { if (element['cellTemplate'] !== undefined) { - this.meta.table.columns[index]['cellTemplate'] = - templates[element['cellTemplate'] as string]; + meta.table.columns[index]['cellTemplate'] = templates[element['cellTemplate'] as string]; } }); + // isHidden flag does not work as expected somehow so the best ways to enforce isHidden is + // to filter the columns manually instead of letting isHidden flag inside table.component to + // work. + meta.table.columns = meta.table.columns.filter((col: any) => { + return !col['isHidden']; + }); + this.meta = meta; } } diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index 10ef603038c7..a102fffb814b 100644 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -8309,6 +8309,29 @@ paths: - jwt: [] tags: - RgwUser + /api/rgw/user/roles: + get: + parameters: [] + responses: + '200': + content: + application/vnd.ceph.api.v1.0+json: + type: object + description: OK + '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: List RGW roles + tags: + - RGW /api/rgw/user/{uid}: delete: parameters: @@ -10827,6 +10850,8 @@ tags: name: Prometheus - description: Prometheus Notifications Management API name: PrometheusNotifications +- description: List of RGW roles + name: RGW - description: RBD Management API name: Rbd - description: RBD Mirroring Management API diff --git a/src/pybind/mgr/dashboard/services/rgw_client.py b/src/pybind/mgr/dashboard/services/rgw_client.py index 242600c2265b..2aa933f4656a 100644 --- a/src/pybind/mgr/dashboard/services/rgw_client.py +++ b/src/pybind/mgr/dashboard/services/rgw_client.py @@ -806,3 +806,35 @@ class RgwClient(RestClient): _ = request(data=data) # type: ignore except RequestException as e: raise DashboardException(msg=str(e), component='rgw') + + def list_roles(self) -> List[Dict[str, Any]]: + rgw_list_roles_command = ['role', 'list'] + code, roles, err = mgr.send_rgwadmin_command(rgw_list_roles_command) + if code < 0: + logger.warning('Error listing roles with code %d: %s', code, err) + return [] + + return roles + + def perform_validations(self, retention_period_days, retention_period_years, mode): + try: + retention_period_days = int(retention_period_days) if retention_period_days else 0 + retention_period_years = int(retention_period_years) if retention_period_years else 0 + if retention_period_days < 0 or retention_period_years < 0: + raise ValueError + except (TypeError, ValueError): + msg = "Retention period must be a positive integer." + raise DashboardException(msg=msg, component='rgw') + if retention_period_days and retention_period_years: + # https://docs.aws.amazon.com/AmazonS3/latest/API/archive-RESTBucketPUTObjectLockConfiguration.html + msg = "Retention period requires either Days or Years. "\ + "You can't specify both at the same time." + raise DashboardException(msg=msg, component='rgw') + if not retention_period_days and not retention_period_years: + msg = "Retention period requires either Days or Years. "\ + "You must specify at least one." + raise DashboardException(msg=msg, component='rgw') + if not isinstance(mode, str) or mode.upper() not in ['COMPLIANCE', 'GOVERNANCE']: + msg = "Retention mode must be either COMPLIANCE or GOVERNANCE." + raise DashboardException(msg=msg, component='rgw') + return retention_period_days, retention_period_years