]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: create authx users
authorPere Diaz Bou <pdiazbou@redhat.com>
Wed, 24 Aug 2022 17:28:38 +0000 (19:28 +0200)
committerPere Diaz Bou <pere-altea@hotmail.com>
Fri, 5 May 2023 16:43:11 +0000 (18:43 +0200)
Signed-off-by: Pere Diaz Bou <pdiazbou@redhat.com>
Signed-off-by: Nizamudeen A <nia@redhat.com>
Co-authored-by: Nizamudeen A <nia@redhat.com>
(cherry picked from commit 10f17bd9eb379c8a15d0e8b76179e374ed92a87d)

26 files changed:
src/pybind/mgr/dashboard/controllers/_crud.py
src/pybind/mgr/dashboard/controllers/_rest_controller.py
src/pybind/mgr/dashboard/controllers/ceph_users.py
src/pybind/mgr/dashboard/frontend/package-lock.json
src/pybind/mgr/dashboard/frontend/package.json
src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/submit-button/submit-button.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-form/crud-form.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-form/crud-form.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-form/crud-form.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-form/crud-form.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/datatable.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/crud-table-metadata.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/data-gateway.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/shared.module.ts
src/pybind/mgr/dashboard/frontend/src/styles.scss
src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_buttons.scss
src/pybind/mgr/dashboard/openapi.yaml
src/pybind/mgr/dashboard/requirements-lint.txt
src/pybind/mgr/dashboard/requirements-test.txt
src/pybind/mgr/dashboard/requirements.txt
src/pybind/mgr/dashboard/tests/test_ceph_users.py
src/pybind/mgr/dashboard/tests/test_crud.py

index 907759f7c91d1b8522dd756dd7e5ffff823a4aaa..94b1a3146d7a2d482e41ce6331cae25356ca73f4 100644 (file)
@@ -1,5 +1,8 @@
+from enum import Enum
+from functools import wraps
+from inspect import isclass
 from typing import Any, Callable, Dict, Generator, Iterable, Iterator, List, \
-    NamedTuple, Optional, get_type_hints
+    NamedTuple, Optional, Union, get_type_hints
 
 from ._api_router import APIRouter
 from ._docs import APIDoc, EndpointDoc
@@ -17,6 +20,7 @@ def isnamedtuple(o):
 
 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)}
@@ -28,7 +32,7 @@ def serialize(o, expected_type=None):
         return [serialize(i) for i in o]
     elif isinstance(o, (Iterator, Generator)):
         return [serialize(i) for i in o]
-    elif expected_type and issubclass(expected_type, SecretStr):
+    elif expected_type and isclass(expected_type) and issubclass(expected_type, SecretStr):
         return "***********"
     else:
         return o
@@ -41,14 +45,235 @@ class TableColumn(NamedTuple):
     filterable: bool = True
 
 
+class TableAction(NamedTuple):
+    name: str
+    permission: str
+    icon: str
+    routerLink: str  # redirect to...
+
+
 class TableComponent(NamedTuple):
     columns: List[TableColumn] = []
     columnMode: str = 'flex'
     toolHeader: bool = True
 
 
+class Icon(Enum):
+    add = 'fa fa-plus'
+
+
+class FormField(NamedTuple):
+    """
+    The key of a FromField is then used to send the data related to that key into the
+    POST and PUT endpoints. It is imperative for the developer to map keys of fields and containers
+    to the input of the POST and PUT endpoints.
+    """
+    name: str
+    key: str
+    field_type: Any = str
+    default_value: Optional[Any] = None
+    optional: bool = False
+    html_class: str = ''
+    label_html_class: str = 'col-form-label'
+    field_html_class: str = 'col-form-input'
+
+    def get_type(self):
+        _type = ''
+        if self.field_type == str:
+            _type = 'string'
+        elif self.field_type == int:
+            _type = 'integer'
+        elif self.field_type == bool:
+            _type = 'boolean'
+        else:
+            raise NotImplementedError(f'Unimplemented type {self.field_type}')
+        return _type
+
+
+class Container:
+    def __init__(self, name: str, key: str, fields: List[Union[FormField, "Container"]],
+                 optional: bool = False, html_class: str = '', label_html_class: str = '',
+                 field_html_class: str = ''):
+        self.name = name
+        self.key = key
+        self.fields = fields
+        self.optional = optional
+        self.html_class = html_class
+        self.label_html_class = label_html_class
+        self.field_html_class = field_html_class
+
+    def layout_type(self):
+        raise NotImplementedError
+
+    def _property_type(self):
+        raise NotImplementedError
+
+    def to_dict(self, key=''):
+        # intialize the schema of this container
+        ui_schemas = []
+        control_schema = {
+            'type': self._property_type(),
+            'title': self.name
+        }
+        items = None  # layout items alias as it depends on the type of container
+        properties = None  # control schema properties alias
+        required = None
+        if self._property_type() == 'array':
+            control_schema['items'] = {
+                'type': 'object',
+                'properties': {},
+                'required': []
+            }
+            properties = control_schema['items']['properties']
+            required = control_schema['items']['required']
+            ui_schemas.append({
+                'type': 'array',
+                'key': key,
+                'htmlClass': self.html_class,
+                'fieldHtmlClass': self.field_html_class,
+                'labelHtmlClass': self.label_html_class,
+                'items': [{
+                        'type': 'div',
+                        'flex-direction': self.layout_type(),
+                        'displayFlex': True,
+                        'items': []
+                }]
+            })
+            items = ui_schemas[-1]['items'][0]['items']
+        else:
+            control_schema['properties'] = {}
+            control_schema['required'] = []
+            required = control_schema['required']
+            properties = control_schema['properties']
+            ui_schemas.append({
+                'type': 'section',
+                'flex-direction': self.layout_type(),
+                'displayFlex': True,
+                'htmlClass': self.html_class,
+                'fieldHtmlClass': self.field_html_class,
+                'labelHtmlClass': self.label_html_class,
+                'key': key,
+                'items': []
+            })
+            if key:
+                items = ui_schemas[-1]['items']
+            else:
+                items = ui_schemas
+
+        assert items is not None
+        assert properties is not None
+        assert required is not None
+
+        # include fields in this container's schema
+        for field in self.fields:
+            field_ui_schema = {}
+            properties[field.key] = {}
+            field_key = field.key
+            if key:
+                if self._property_type() == 'array':
+                    field_key = key + '[].' + field.key
+                else:
+                    field_key = key + '.' + field.key
+
+            if isinstance(field, FormField):
+                _type = field.get_type()
+                properties[field.key]['type'] = _type
+                properties[field.key]['title'] = field.name
+                field_ui_schema['key'] = field_key
+                field_ui_schema['htmlClass'] = field.html_class
+                field_ui_schema['fieldHtmlClass'] = field.field_html_class
+                field_ui_schema['labelHtmlClass'] = field.label_html_class
+                items.append(field_ui_schema)
+            elif isinstance(field, Container):
+                container_schema = field.to_dict(key+'.'+field.key if key else field.key)
+                control_schema['properties'][field.key] = container_schema['control_schema']
+                ui_schemas.extend(container_schema['ui_schema'])
+            if not field.optional:
+                required.append(field.key)
+        return {
+            'ui_schema': ui_schemas,
+            'control_schema': control_schema,
+        }
+
+
+class VerticalContainer(Container):
+    def layout_type(self):
+        return 'column'
+
+    def _property_type(self):
+        return 'object'
+
+
+class HorizontalContainer(Container):
+    def layout_type(self):
+        return 'row'
+
+    def _property_type(self):
+        return 'object'
+
+
+class ArrayVerticalContainer(Container):
+    def layout_type(self):
+        return 'column'
+
+    def _property_type(self):
+        return 'array'
+
+
+class ArrayHorizontalContainer(Container):
+    def layout_type(self):
+        return 'row'
+
+    def _property_type(self):
+        return 'array'
+
+
+class Form:
+    def __init__(self, path, root_container, action: str = '',
+                 footer_html_class: str = 'card-footer position-absolute pb-0 mt-3',
+                 submit_style: str = 'btn btn-primary', cancel_style: str = ''):
+        self.path = path
+        self.action = action
+        self.root_container = root_container
+        self.footer_html_class = footer_html_class
+        self.submit_style = submit_style
+        self.cancel_style = cancel_style
+
+    def to_dict(self):
+        container_schema = self.root_container.to_dict()
+
+        # root container style
+        container_schema['ui_schema'].append({
+            'type': 'flex',
+            'flex-flow': f'{self.root_container.layout_type()} wrap',
+            'displayFlex': True,
+        })
+
+        footer = {
+            "type": "flex",
+            "htmlClass": self.footer_html_class,
+            "items": [
+                {
+                    'type': 'flex',
+                    'flex-direction': 'row',
+                    'displayFlex': True,
+                    'htmlClass': 'd-flex justify-content-end mb-0',
+                    'items': [
+                        {"type": "cancel", "style": self.cancel_style, 'htmlClass': 'mr-2'},
+                        {"type": "submit", "style": self.submit_style, "title": self.action},
+                    ]
+                }
+            ]
+        }
+        container_schema['ui_schema'].append(footer)
+        return container_schema
+
+
 class CRUDMeta(NamedTuple):
     table: TableComponent = TableComponent()
+    permissions: List[str] = []
+    actions: List[Dict[str, Any]] = []
+    forms: List[Dict[str, Any]] = []
 
 
 class CRUDCollectionMethod(NamedTuple):
@@ -65,8 +290,12 @@ 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
 
     # for testing purposes
     CRUDClass: Optional[RESTController] = None
@@ -89,9 +318,19 @@ class CRUDEndpoint(NamedTuple):
 
             if self.get_all:
                 @self.get_all.doc
-                def list(self):
-                    return serialize(cls(**item)
-                                     for item in outer_self.get_all.func())  # type: ignore
+                @wraps(self.get_all.func)
+                def list(self, *args, **kwargs):
+                    items = []
+                    for item in outer_self.get_all.func(self, *args, **kwargs):  # type: ignore
+                        items.append(serialize(cls(**item)))
+                    return items
+
+            if self.create:
+                @self.create.doc
+                @wraps(self.create.func)
+                def create(self, *args, **kwargs):
+                    return outer_self.create.func(self, *args, **kwargs)  # type: ignore
+
         cls.CRUDClass = CRUDClass
 
     def create_meta_class(self, cls):
@@ -101,6 +340,9 @@ class CRUDEndpoint(NamedTuple):
         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):
@@ -114,4 +356,20 @@ class CRUDEndpoint(NamedTuple):
                                                      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
index 03e124f9e4fb1dbec6f596bf41a5222bb126bb9d..6b4afc27672c1b52173fb01ff5c3de02909dd28c 100644 (file)
@@ -83,7 +83,7 @@ class RESTController(BaseController, skip_registry=True):
         result = super().endpoints()
         res_id_params = cls.infer_resource_id()
 
-        for _, func in inspect.getmembers(cls, predicate=callable):
+        for name, func in inspect.getmembers(cls, predicate=callable):
             endpoint_params = {
                 'no_resource_id_params': False,
                 'status': 200,
@@ -94,10 +94,9 @@ class RESTController(BaseController, skip_registry=True):
                 'sec_permissions': hasattr(func, '_security_permissions'),
                 'permission': None,
             }
-
-            if func.__name__ in cls._method_mapping:
+            if name in cls._method_mapping:
                 cls._update_endpoint_params_method_map(
-                    func, res_id_params, endpoint_params)
+                    func, res_id_params, endpoint_params, name=name)
 
             elif hasattr(func, "__collection_method__"):
                 cls._update_endpoint_params_collection_map(func, endpoint_params)
@@ -166,8 +165,8 @@ class RESTController(BaseController, skip_registry=True):
             endpoint_params['permission'] = cls._permission_map[endpoint_params['method']]
 
     @classmethod
-    def _update_endpoint_params_method_map(cls, func, res_id_params, endpoint_params):
-        meth = cls._method_mapping[func.__name__]  # type: dict
+    def _update_endpoint_params_method_map(cls, func, res_id_params, endpoint_params, name=None):
+        meth = cls._method_mapping[func.__name__ if not name else name]  # type: dict
 
         if meth['resource']:
             if not res_id_params:
index 65b8a0294b4f0accb3f7a6d55a08fa490611a40e..0c65ce1a912d4889964010e3b7fe0eccfc9ca7ac 100644 (file)
@@ -1,8 +1,14 @@
-from typing import NamedTuple
+import logging
+from errno import EINVAL
+from typing import List, NamedTuple
 
+from ..exceptions import DashboardException
 from ..security import Scope
-from ..services.ceph_service import CephService
+from ..services.ceph_service import CephService, SendCommandError
 from . import APIDoc, APIRouter, CRUDCollectionMethod, CRUDEndpoint, EndpointDoc, SecretStr
+from ._crud import ArrayHorizontalContainer, Form, FormField, Icon, TableAction, VerticalContainer
+
+logger = logging.getLogger("controllers.ceph_users")
 
 
 class CephUserCaps(NamedTuple):
@@ -12,16 +18,83 @@ class CephUserCaps(NamedTuple):
     mds: str
 
 
+class Cap(NamedTuple):
+    entity: str
+    cap: str
+
+
+class CephUserEndpoints:
+    @staticmethod
+    def user_list(_):
+        """
+        Get list of ceph users and its respective data
+        """
+        return CephService.send_command('mon', 'auth ls')["auth_dump"]
+
+    @staticmethod
+    def user_create(_, user_entity: str, capabilities: List[Cap]):
+        """
+        Add a ceph user with its defined capabilities.
+        :param user_entity: Entity to change
+        :param capabilities: List of capabilities to add to user_entity
+        """
+        # Caps are represented as a vector in mon auth add commands.
+        # Look at AuthMonitor.cc::valid_caps for reference.
+        caps = []
+        for cap in capabilities:
+            caps.append(cap['entity'])
+            caps.append(cap['cap'])
+
+        logger.debug("Sending command 'auth add' of entity '%s' with caps '%s'",
+                     user_entity, str(caps))
+        try:
+            CephService.send_command('mon', 'auth add', entity=user_entity, caps=caps)
+        except SendCommandError as ex:
+            msg = f'{ex} in command {ex.prefix}'
+            if ex.errno == -EINVAL:
+                raise DashboardException(msg, code=400)
+            raise DashboardException(msg, code=500)
+        return f"Successfully created user '{user_entity}'"
+
+
+create_cap_container = ArrayHorizontalContainer('Capabilities', 'capabilities',
+                                                label_html_class='hidden cd-header mt-1', fields=[
+                                                    FormField('Entity', 'entity',
+                                                              field_type=str, html_class='mr-3'),
+                                                    FormField('Entity Capabilities',
+                                                              'cap', field_type=str)
+                                                ])
+create_container = VerticalContainer('Create User', 'create_user',
+                                     html_class='d-none', fields=[
+                                         FormField('User entity', 'user_entity',
+                                                   field_type=str),
+                                         create_cap_container,
+                                     ])
+
+create_form = Form(path='/cluster/user/create',
+                   root_container=create_container, action='Create User')
+
+
 @CRUDEndpoint(
     router=APIRouter('/cluster/user', Scope.CONFIG_OPT),
     doc=APIDoc("Get Ceph Users", "Cluster"),
     set_column={"caps": {"cellTemplate": "badgeDict"}},
+    actions=[
+        TableAction(name='create', permission='create', icon=Icon.add.value,
+                    routerLink='/cluster/user/create')
+    ],
+    permissions=[Scope.CONFIG_OPT],
+    forms=[create_form],
     get_all=CRUDCollectionMethod(
-        func=lambda **_: CephService.send_command('mon', 'auth ls')["auth_dump"],
+        func=CephUserEndpoints.user_list,
         doc=EndpointDoc("Get Ceph Users")
+    ),
+    create=CRUDCollectionMethod(
+        func=CephUserEndpoints.user_create,
+        doc=EndpointDoc("Create Ceph User")
     )
 )
 class CephUser(NamedTuple):
     entity: str
-    caps: CephUserCaps
+    caps: List[CephUserCaps]
     key: SecretStr
index c60bdbe63b3568fd846ed0e470ebe44a00ce668e..0546043000d7d4a9937e9e6e4e423fc83f721ac1 100644 (file)
       "integrity": "sha512-20Pk2Z98fbPLkECcrZSJszKos/OgtvJJR3NcbVfgCJ6EQjDNzW2P1BKqImOz3tJ952dvO2DWEhcLhQ1Wz1e9ng==",
       "dev": true
     },
+    "@ajsf/bootstrap4": {
+      "version": "0.7.0",
+      "resolved": "https://registry.npmjs.org/@ajsf/bootstrap4/-/bootstrap4-0.7.0.tgz",
+      "integrity": "sha512-wn6wIQeWknmn/t96XZgihfFq/jjr9GkV9P5dHEU+i9wQbxPNL1MS+x4tLWj9LH3Mx5RiC0Dr4gPgbkDd/bzLxg==",
+      "requires": {
+        "@ajsf/core": "~0.7.0",
+        "lodash-es": "~4.17.21",
+        "tslib": "^2.0.0"
+      }
+    },
+    "@ajsf/core": {
+      "version": "0.7.0",
+      "resolved": "https://registry.npmjs.org/@ajsf/core/-/core-0.7.0.tgz",
+      "integrity": "sha512-mysKftZAxT0bHYoia7LzbSinK7Z55wINS63zeK/rqSs9r2dF01Vxtzlx2ITViiok3TQ0UV+1OYce/piozEf4aw==",
+      "requires": {
+        "ajv": "^6.10.0",
+        "lodash-es": "~4.17.21",
+        "tslib": "^2.0.0"
+      },
+      "dependencies": {
+        "ajv": {
+          "version": "6.12.6",
+          "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+          "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+          "requires": {
+            "fast-deep-equal": "^3.1.1",
+            "fast-json-stable-stringify": "^2.0.0",
+            "json-schema-traverse": "^0.4.1",
+            "uri-js": "^4.2.2"
+          }
+        },
+        "json-schema-traverse": {
+          "version": "0.4.1",
+          "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+          "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="
+        }
+      }
+    },
     "@ampproject/remapping": {
       "version": "2.2.0",
       "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz",
     "fast-deep-equal": {
       "version": "3.1.3",
       "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
-      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
-      "dev": true
+      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
     },
     "fast-glob": {
       "version": "3.2.12",
     "fast-json-stable-stringify": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
-      "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
-      "dev": true
+      "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="
     },
     "fast-levenshtein": {
       "version": "2.0.6",
       "version": "4.4.1",
       "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
       "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
-      "dev": true,
       "requires": {
         "punycode": "^2.1.0"
       },
index e4b6145920077c9873833701c8755748a3342d20..6ca0fddfda7e27839ad3bccec91234b484106e41 100644 (file)
@@ -44,6 +44,8 @@
   },
   "private": true,
   "dependencies": {
+    "@ajsf/bootstrap4": "0.7.0",
+    "@ajsf/core": "0.7.0",
     "@angular/animations": "13.3.11",
     "@angular/common": "13.3.11",
     "@angular/compiler": "13.3.11",
index 333a8422ebebed788c7f2d5966177373d5518ff5..6880a1561c1d9d281d264a41dfd286be8344d484 100644 (file)
@@ -37,6 +37,7 @@ import { LoginLayoutComponent } from './core/layouts/login-layout/login-layout.c
 import { WorkbenchLayoutComponent } from './core/layouts/workbench-layout/workbench-layout.component';
 import { ApiDocsComponent } from './core/navigation/api-docs/api-docs.component';
 import { ActionLabels, URLVerbs } from './shared/constants/app.constants';
+import { CrudFormComponent } from './shared/datatable/crud-table/crud-form/crud-form.component';
 import { CRUDTableComponent } from './shared/datatable/crud-table/crud-table.component';
 import { BreadcrumbsResolver, IBreadcrumb } from './shared/models/breadcrumbs';
 import { AuthGuardService } from './shared/services/auth-guard.service';
@@ -124,6 +125,14 @@ const routes: Routes = [
           resource: 'api.cluster.user@1.0'
         }
       },
+      {
+        path: 'cluster/user/create',
+        component: CrudFormComponent,
+        data: {
+          breadcrumbs: 'Cluster/Users',
+          resource: 'api.cluster.user@1.0'
+        }
+      },
       {
         path: 'monitor',
         component: MonitorComponent,
index 3309f47ed18e6c2a02c4df07e786e10d5c368580..22e23d845eb953cfa8addb370694ec8865475f60 100644 (file)
@@ -54,7 +54,7 @@ export class SubmitButtonComponent implements OnInit {
   constructor(private elRef: ElementRef) {}
 
   ngOnInit() {
-    this.form.statusChanges.subscribe(() => {
+    this.form?.statusChanges.subscribe(() => {
       if (_.has(this.form.errors, 'cdSubmitButton')) {
         this.loading = false;
         _.unset(this.form.errors, 'cdSubmitButton');
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-form/crud-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-form/crud-form.component.html
new file mode 100644 (file)
index 0000000..8d7b21b
--- /dev/null
@@ -0,0 +1,20 @@
+<div class="cd-col-form">
+  <div class="card pb-0">
+    <div i18n="form title"
+         class="card-header">{{ title }}</div>
+
+    <div class="card-body position-relative">
+      <json-schema-form
+          *ngIf="controlSchema && uiSchema"
+          [schema]="controlSchema"
+          [layout]="uiSchema"
+          [data]="data"
+          [widgets]="widgets"
+          (onSubmit)="submit($event)"
+          [options]="formOptions"
+          framework="bootstrap-4">
+      </json-schema-form>
+
+    </div>
+  </div>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-form/crud-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-form/crud-form.component.scss
new file mode 100644 (file)
index 0000000..6d21e4c
--- /dev/null
@@ -0,0 +1,22 @@
+@use './src/styles/vendor/variables' as vv;
+
+::ng-deep json-schema-form {
+  label.control-label.hidden {
+    display: none;
+  }
+
+  .form-group.schema-form-submit p {
+    display: none;
+  }
+
+  legend {
+    font-weight: 100 !important;
+  }
+
+  .card-footer {
+    border: 1px solid rgba(0, 0, 0, 0.125);
+    left: -1px;
+    width: -webkit-fill-available;
+    width: -moz-available;
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-form/crud-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-form/crud-form.component.spec.ts
new file mode 100644 (file)
index 0000000..7a6faa7
--- /dev/null
@@ -0,0 +1,42 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ToastrModule, ToastrService } from 'ngx-toastr';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { CdDatePipe } from '~/app/shared/pipes/cd-date.pipe';
+import { CrudFormComponent } from './crud-form.component';
+import { RouterTestingModule } from '@angular/router/testing';
+
+describe('CrudFormComponent', () => {
+  let component: CrudFormComponent;
+  let fixture: ComponentFixture<CrudFormComponent>;
+  const toastFakeService = {
+    error: () => true,
+    info: () => true,
+    success: () => true
+  };
+
+  configureTestBed({
+    imports: [ToastrModule.forRoot(), RouterTestingModule, HttpClientTestingModule],
+    providers: [
+      { provide: ToastrService, useValue: toastFakeService },
+      { provide: CdDatePipe, useValue: { transform: (d: any) => d } }
+    ]
+  });
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [CrudFormComponent]
+    }).compileComponents();
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(CrudFormComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-form/crud-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-form/crud-form.component.ts
new file mode 100644 (file)
index 0000000..4545f2b
--- /dev/null
@@ -0,0 +1,65 @@
+import { Component, OnInit } from '@angular/core';
+import { ActivatedRoute } from '@angular/router';
+import { DataGatewayService } from '~/app/shared/services/data-gateway.service';
+import { BackButtonComponent } from '~/app/shared/components/back-button/back-button.component';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { Location } from '@angular/common';
+
+@Component({
+  selector: 'cd-crud-form',
+  templateUrl: './crud-form.component.html',
+  styleUrls: ['./crud-form.component.scss']
+})
+export class CrudFormComponent implements OnInit {
+  uiSchema: any;
+  controlSchema: any;
+  data: any;
+  widgets: any = {
+    cancel: BackButtonComponent
+  };
+  resource: string;
+  title: string;
+
+  formOptions = {
+    defautWidgetOptions: {
+      validationMessages: {
+        required: 'This field is required'
+      }
+    }
+  };
+  constructor(
+    private dataGatewayService: DataGatewayService,
+    private activatedRoute: ActivatedRoute,
+    private taskWrapper: TaskWrapperService,
+    private location: Location
+  ) {}
+
+  ngOnInit(): void {
+    this.activatedRoute.data.subscribe((data: any) => {
+      this.resource = data.resource;
+      this.dataGatewayService.list(`ui-${this.resource}`).subscribe((response: any) => {
+        this.title = response.forms[0].control_schema.title;
+        this.uiSchema = response.forms[0].ui_schema;
+        this.controlSchema = response.forms[0].control_schema;
+      });
+    });
+  }
+
+  submit(data: any) {
+    if (data) {
+      this.taskWrapper
+        .wrapTaskAroundCall({
+          task: new FinishedTask('ceph-user/create', {
+            user_entity: data.user_entity
+          }),
+          call: this.dataGatewayService.create(this.resource, data)
+        })
+        .subscribe({
+          complete: () => {
+            this.location.back();
+          }
+        });
+    }
+  }
+}
index e8982a92547820eccefc9c89052ed02df55e3822..87a3c586b4a8544ceb9a560f86832db905b3a57b 100644 (file)
@@ -1,10 +1,19 @@
 <ng-container *ngIf="meta">
   <cd-table
-    [data]="data$ | async"
-    [columns]="meta.table.columns"
-    [columnMode]="meta.table.columnMode"
-    [toolHeader]="meta.table.toolHeader"
-  ></cd-table>
+      [data]="data$ | async"
+      [columns]="meta.table.columns"
+      [columnMode]="meta.table.columnMode"
+      [toolHeader]="meta.table.toolHeader">
+    <div class="table-actions btn-toolbar">
+      <cd-table-actions [permission]="permission"
+                        [selection]="selection"
+                        class="btn-group"
+                        id="crud-table-actions"
+                        [tableActions]="meta.actions">
+      </cd-table-actions>
+    </div>
+
+  </cd-table>
 </ng-container>
 
 <ng-template #badgeDictTpl
index 7878a0661e3e3cde3995535f322d1692d1cf1ef5..7e82c523e78d91e3119f2b0f96809c457ad61572 100644 (file)
@@ -7,6 +7,9 @@ import { Observable } from 'rxjs';
 import { CrudMetadata } from '~/app/shared/models/crud-table-metadata';
 import { DataGatewayService } from '~/app/shared/services/data-gateway.service';
 import { TimerService } from '~/app/shared/services/timer.service';
+import { CdTableSelection } from '../../models/cd-table-selection';
+import { Permission, Permissions } from '../../models/permissions';
+import { AuthStorageService } from '../../services/auth-storage.service';
 
 @Component({
   selector: 'cd-crud-table',
@@ -20,28 +23,45 @@ export class CRUDTableComponent implements OnInit {
   data$: Observable<any>;
   meta$: Observable<CrudMetadata>;
   meta: CrudMetadata;
+  permissions: Permissions;
+  permission: Permission;
+  selection = new CdTableSelection();
 
   constructor(
+    private authStorageService: AuthStorageService,
     private timerService: TimerService,
     private dataGatewayService: DataGatewayService,
     private activatedRoute: ActivatedRoute
-  ) {}
+  ) {
+    this.permissions = this.authStorageService.getPermissions();
+  }
 
   ngOnInit() {
     /* The following should be simplified with a wrapper that
     converts .data to @Input args. For example:
     https://medium.com/@andrewcherepovskiy/passing-route-params-into-angular-components-input-properties-fc85c34c9aca
     */
-    this.activatedRoute.data.subscribe((data) => {
+    this.activatedRoute.data.subscribe((data: any) => {
       const resource: string = data.resource;
       this.dataGatewayService
         .list(`ui-${resource}`)
-        .subscribe((response) => this.processMeta(response));
+        .subscribe((response: any) => this.processMeta(response));
       this.data$ = this.timerService.get(() => this.dataGatewayService.list(resource));
     });
   }
 
   processMeta(meta: CrudMetadata) {
+    const toCamelCase = (test: string) =>
+      test
+        .split('-')
+        .reduce(
+          (res: string, word: string, i: number) =>
+            i === 0
+              ? word.toLowerCase()
+              : `${res}${word.charAt(0).toUpperCase()}${word.substr(1).toLowerCase()}`,
+          ''
+        );
+    this.permission = this.permissions[toCamelCase(meta.permissions[0])];
     this.meta = meta;
     const templates = {
       badgeDict: this.badgeDictTpl
index ed92e6b9d7d27d2eea690cc742124cabd2947654..1cef20b31e358e66d567e5733a53f1b4b12e9332 100644 (file)
@@ -14,6 +14,8 @@ import { TableActionsComponent } from './table-actions/table-actions.component';
 import { TableKeyValueComponent } from './table-key-value/table-key-value.component';
 import { TablePaginationComponent } from './table-pagination/table-pagination.component';
 import { TableComponent } from './table/table.component';
+import { Bootstrap4FrameworkModule } from '@ajsf/bootstrap4';
+import { CrudFormComponent } from './crud-table/crud-form/crud-form.component';
 
 @NgModule({
   imports: [
@@ -25,9 +27,17 @@ import { TableComponent } from './table/table.component';
     NgbTooltipModule,
     PipesModule,
     ComponentsModule,
-    RouterModule
+    RouterModule,
+    Bootstrap4FrameworkModule
+  ],
+  declarations: [
+    TableComponent,
+    TableKeyValueComponent,
+    TableActionsComponent,
+    CRUDTableComponent,
+    TablePaginationComponent,
+    CrudFormComponent
   ],
-  declarations: [TableComponent, TableKeyValueComponent, TableActionsComponent, CRUDTableComponent],
   exports: [
     TableComponent,
     NgxDatatableModule,
index ba3e17621a9aa13735203e7b9a4b1904c8dfbb0e..fbd4979ec609ccc96cacff9501f731ee7d004e52 100644 (file)
@@ -1,4 +1,5 @@
 import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdTableAction } from './cd-table-action';
 
 class Table {
   columns: CdTableColumn[];
@@ -8,4 +9,7 @@ class Table {
 
 export class CrudMetadata {
   table: Table;
+  permissions: string[];
+  actions: CdTableAction[];
+  forms: any;
 }
index 283d37bb6f8cb6c9adede865c0144386cbf44ab4..6617fdb314afc7db839664c786ade2f3d505474c 100644 (file)
@@ -12,16 +12,31 @@ export class DataGatewayService {
   constructor(private http: HttpClient) {}
 
   list(dataPath: string): Observable<any> {
-    if (this.cache[dataPath] === undefined) {
+    const cacheable = this.getCacheable(dataPath, 'get');
+    if (this.cache[cacheable] === undefined) {
       const match = dataPath.match(/(?<url>[^@]+)(?:@(?<version>.+))?/);
       const url = match.groups.url.split('.').join('/');
       const version = match.groups.version || '1.0';
 
-      this.cache[dataPath] = this.http.get<any>(url, {
+      this.cache[cacheable] = this.http.get<any>(url, {
         headers: { Accept: `application/vnd.ceph.api.v${version}+json` }
       });
     }
 
-    return this.cache[dataPath];
+    return this.cache[cacheable];
+  }
+
+  create(dataPath: string, data: any): Observable<any> {
+    const match = dataPath.match(/(?<url>[^@]+)(?:@(?<version>.+))?/);
+    const url = match.groups.url.split('.').join('/');
+    const version = match.groups.version || '1.0';
+
+    return this.http.post<any>(url, data, {
+      headers: { Accept: `application/vnd.ceph.api.v${version}+json` }
+    });
+  }
+
+  getCacheable(dataPath: string, method: string) {
+    return dataPath + method;
   }
 }
index 5adabe2115370d9c65ab4232a7325e0c343bc594..595e4fc96f8cabc1a57dea79d9f1d4f9baa776ce 100644 (file)
@@ -339,6 +339,9 @@ export class TaskMessageService {
     ),
     'service/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) =>
       this.service(metadata)
+    ),
+    'ceph-users/create': this.newTaskMessage(this.commonOperations.create, (metadata) =>
+      this.cephUser(metadata)
     )
   };
 
@@ -384,6 +387,10 @@ export class TaskMessageService {
     return $localize`Service '${metadata.service_name}'`;
   }
 
+  cephUser(metadata: any) {
+    return $localize`Ceph User '${metadata.user_entity}'`;
+  }
+
   _getTaskTitle(task: Task) {
     if (task.name && task.name.startsWith('progress/')) {
       // we don't fill the failure string because, at least for now, all
index 905721fa445c487c7656c2b03621c4fe09be17bb..ecc8bd03bfe6f6600f4e1ffc0f2caf1dd4212246 100644 (file)
@@ -1,5 +1,6 @@
 import { CommonModule } from '@angular/common';
 import { NgModule } from '@angular/core';
+import { Bootstrap4FrameworkModule } from '@ajsf/bootstrap4';
 
 import { CssHelper } from '~/app/shared/classes/css-helper';
 import { ComponentsModule } from './components/components.module';
@@ -11,7 +12,14 @@ import { AuthStorageService } from './services/auth-storage.service';
 import { FormatterService } from './services/formatter.service';
 
 @NgModule({
-  imports: [CommonModule, PipesModule, ComponentsModule, DataTableModule, DirectivesModule],
+  imports: [
+    CommonModule,
+    PipesModule,
+    ComponentsModule,
+    DataTableModule,
+    DirectivesModule,
+    Bootstrap4FrameworkModule
+  ],
   declarations: [],
   exports: [ComponentsModule, PipesModule, DataTableModule, DirectivesModule],
   providers: [AuthStorageService, AuthGuardService, FormatterService, CssHelper]
index 6253ec6cf46dc1511519c1a6d2831e73c7b802a2..f2c3052f800abc681b903ad51fb8abada8c05f4a 100644 (file)
@@ -217,3 +217,17 @@ a.btn-light {
 .badge-dark {
   @extend .badge, .bg-dark;
 }
+
+json-schema-form {
+  .help-block {
+    @extend .invalid-feedback;
+  }
+
+  .ng-touched.ng-invalid {
+    @extend .is-invalid;
+  }
+
+  .ng-touched.ng-valid {
+    @extend .is-valid;
+  }
+}
index 5b9789b3162d3bc76aa154e4e7f6379396a5c96b..dd529777a579d514707194a9b1beda769b9ee573 100644 (file)
   }
 }
 
+.btn-default {
+  @extend .btn-light;
+}
+
 .btn-primary .badge {
   background-color: vv.$gray-200;
   color: vv.$primary;
index 99be5131d444eb66e68f7a8bd3c18f121166f7e5..10ef603038c73075d92151f4239cc1c76fd41e3e 100644 (file)
@@ -2145,6 +2145,8 @@ paths:
       - Cluster
   /api/cluster/user:
     get:
+      description: "\n        Get list of ceph users and its respective data\n   \
+        \     "
       parameters: []
       responses:
         '200':
@@ -2166,6 +2168,49 @@ paths:
       summary: Get Ceph Users
       tags:
       - Cluster
+    post:
+      description: "\n        Add a ceph user with its defined capabilities.\n   \
+        \     :param user_entity: Entity to change\n        :param capabilities: List\
+        \ of capabilities to add to user_entity\n        "
+      parameters: []
+      requestBody:
+        content:
+          application/json:
+            schema:
+              properties:
+                capabilities:
+                  type: string
+                user_entity:
+                  type: string
+              required:
+              - user_entity
+              - capabilities
+              type: object
+      responses:
+        '201':
+          content:
+            application/vnd.ceph.api.v1.0+json:
+              type: object
+          description: Resource created.
+        '202':
+          content:
+            application/vnd.ceph.api.v1.0+json:
+              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: Create Ceph User
+      tags:
+      - Cluster
   /api/cluster_conf:
     get:
       parameters: []
index 1b15f46817c2a695517217023ffa2d8d650489cf..d82fa1ace1deb9e958dba9aee15791df09fa7993 100644 (file)
@@ -8,3 +8,4 @@ rstcheck==3.3.1
 autopep8==1.5.7
 pyfakefs==4.5.0
 isort==5.5.3
+jsonschema==4.16.0
index 625f5c358f1cb606e4cd377373c3557975acbbbd..4e925e8616f1ae63c5128278337c71aa7530bb92 100644 (file)
@@ -1,3 +1,4 @@
 pytest-cov
 pytest-instafail
 pyfakefs==4.5.0
+jsonschema==4.16.0
index 607c67426d28f42753782b2b808ac771dc3c1b1d..8003d62a5523f030023fe2d9c7234f21a2def76a 100644 (file)
@@ -11,3 +11,4 @@ pytest
 pyyaml
 natsort
 setuptools
+jsonpatch
index 04a03ca355d87743fe89ceab64282e00a87da0b7..35029b32c653a525582c8ee5eb3f6f69dc7aa42b 100644 (file)
@@ -1,6 +1,8 @@
 import unittest.mock as mock
 
-from ..controllers.ceph_users import CephUser
+from jsonschema import validate
+
+from ..controllers.ceph_users import CephUser, create_form
 from ..tests import ControllerTestCase
 
 auth_dump_mock = {"auth_dump": [
@@ -41,3 +43,8 @@ class CephUsersControllerTestCase(ControllerTestCase):
              "key": "***********"
              }
         ])
+
+    def test_create_form(self):
+        form_dict = create_form.to_dict()
+        schema = {'schema': form_dict['control_schema'], 'layout': form_dict['ui_schema']}
+        validate(instance={'user_entity': 'foo', 'capabilities': []}, schema=schema['schema'])
index db8aa2990190d1f0fec4496f783d9e066c9f88f2..97c5728b388b40fba70e42ad86bab508fe558f82 100644 (file)
@@ -4,8 +4,11 @@ import json
 from typing import NamedTuple
 
 import pytest
+from jsonschema import validate
 
-from ..controllers._crud import SecretStr, serialize
+from ..controllers._crud import ArrayHorizontalContainer, \
+    ArrayVerticalContainer, Form, FormField, HorizontalContainer, SecretStr, \
+    VerticalContainer, serialize
 
 
 def assertObjectEquals(a, b):
@@ -26,10 +29,41 @@ class NamedTupleSecretMock(NamedTuple):
 @pytest.mark.parametrize("inp,out", [
     (["foo", "var"], ["foo", "var"]),
     (NamedTupleMock(1, "test"), {"foo": 1, "var": "test"}),
-    (NamedTupleSecretMock(1, "test", "supposethisisakey"), {"foo": 1, "var": "test",
+    (NamedTupleSecretMock(1, "test", "imaginethisisakey"), {"foo": 1, "var": "test",
                                                             "key": "***********"}),
     ((1, 2, 3), [1, 2, 3]),
     (set((1, 2, 3)), [1, 2, 3]),
 ])
 def test_serialize(inp, out):
     assertObjectEquals(serialize(inp), out)
+
+
+def test_schema():
+    form = Form(path='/cluster/user/create',
+                root_container=VerticalContainer('Create user', key='create_user', fields=[
+                    FormField('User entity', key='user_entity', field_type=str),
+                    ArrayHorizontalContainer('Capabilities', key='caps', fields=[
+                        FormField('left', field_type=str, key='left',
+                                  html_class='cd-col-form-input'),
+                        FormField('right', key='right', field_type=str)
+                    ]),
+                    ArrayVerticalContainer('ah', key='ah', fields=[
+                        FormField('top', key='top', field_type=str, label_html_class='d-none'),
+                        FormField('bottom', key='bottom', field_type=str)
+                    ]),
+                    HorizontalContainer('oh', key='oh', fields=[
+                        FormField('left', key='left', field_type=str, label_html_class='d-none'),
+                        FormField('right', key='right', field_type=str)
+                    ]),
+                    VerticalContainer('ov', key='ov', fields=[
+                        FormField('top', key='top', field_type=str, label_html_class='d-none'),
+                        FormField('bottom', key='bottom', field_type=bool)
+                    ]),
+                ]))
+    form_dict = form.to_dict()
+    schema = {'schema': form_dict['control_schema'], 'layout': form_dict['ui_schema']}
+    validate(instance={'user_entity': 'foo',
+                       'caps': [{'left': 'foo', 'right': 'foo2'}],
+                       'ah': [{'top': 'foo', 'bottom': 'foo2'}],
+                       'oh': {'left': 'foo', 'right': 'foo2'},
+                       'ov': {'top': 'foo', 'bottom': True}}, schema=schema['schema'])