From 6ab4cce766fca8e63b7ff959f2b0a41a24d3c2f9 Mon Sep 17 00:00:00 2001 From: ImMin5 Date: Thu, 7 Dec 2023 02:44:10 +0900 Subject: [PATCH 1/2] feat: add App apis except generate_api_key (#73) Signed-off-by: ImMin5 --- .../identity/interface/grpc/__init__.py | 4 + src/spaceone/identity/interface/grpc/app.py | 62 +++++ .../identity/manager/api_key_manager.py | 24 +- src/spaceone/identity/manager/app_manager.py | 79 ++++++ .../identity/model/api_key/database.py | 7 +- .../identity/model/api_key/response.py | 2 + src/spaceone/identity/model/app/__init__.py | 0 src/spaceone/identity/model/app/database.py | 35 +++ src/spaceone/identity/model/app/request.py | 91 +++++++ src/spaceone/identity/model/app/response.py | 37 +++ .../identity/service/api_key_service.py | 14 +- src/spaceone/identity/service/app_service.py | 245 ++++++++++++++++++ 12 files changed, 591 insertions(+), 9 deletions(-) create mode 100644 src/spaceone/identity/interface/grpc/app.py create mode 100644 src/spaceone/identity/manager/app_manager.py create mode 100644 src/spaceone/identity/model/app/__init__.py create mode 100644 src/spaceone/identity/model/app/database.py create mode 100644 src/spaceone/identity/model/app/request.py create mode 100644 src/spaceone/identity/model/app/response.py create mode 100644 src/spaceone/identity/service/app_service.py diff --git a/src/spaceone/identity/interface/grpc/__init__.py b/src/spaceone/identity/interface/grpc/__init__.py index ff531ea9..87cd38de 100644 --- a/src/spaceone/identity/interface/grpc/__init__.py +++ b/src/spaceone/identity/interface/grpc/__init__.py @@ -13,9 +13,12 @@ from spaceone.identity.interface.grpc.role_binding import RoleBinding from spaceone.identity.interface.grpc.user import User from spaceone.identity.interface.grpc.workspace_user import WorkspaceUser + # from spaceone.identity.interface.grpc.user_group import UserGroup +from spaceone.identity.interface.grpc.app import App from spaceone.identity.interface.grpc.api_key import APIKey from spaceone.identity.interface.grpc.token import Token + # from spaceone.identity.interface.grpc.authorization import Authorization _all_ = ["app"] @@ -36,6 +39,7 @@ app.add_service(WorkspaceUser) app.add_service(User) # app.add_service(UserGroup) +app.add_service(App) app.add_service(APIKey) app.add_service(Token) # app.add_service(Authorization) diff --git a/src/spaceone/identity/interface/grpc/app.py b/src/spaceone/identity/interface/grpc/app.py new file mode 100644 index 00000000..4728da42 --- /dev/null +++ b/src/spaceone/identity/interface/grpc/app.py @@ -0,0 +1,62 @@ +from spaceone.core.pygrpc import BaseAPI +from spaceone.api.identity.v2 import app_pb2, app_pb2_grpc +from spaceone.identity.service.app_service import AppService + + +class App(BaseAPI, app_pb2_grpc.AppServicer): + pb2 = app_pb2 + pb2_grpc = app_pb2_grpc + + def create(self, request, context): + params, metadata = self.parse_request(request, context) + app_svc = AppService(metadata) + response: dict = app_svc.create(params) + return self.dict_to_message(response) + + def update(self, request, context): + params, metadata = self.parse_request(request, context) + app_svc = AppService(metadata) + response: dict = app_svc.update(params) + return self.dict_to_message(response) + + def generate_api_key(self, request, context): + params, metadata = self.parse_request(request, context) + app_svc = AppService(metadata) + response: dict = app_svc.generate_api_key(params) + return self.dict_to_message(response) + + def enable(self, request, context): + params, metadata = self.parse_request(request, context) + app_svc = AppService(metadata) + response: dict = app_svc.enable(params) + return self.dict_to_message(response) + + def disable(self, request, context): + params, metadata = self.parse_request(request, context) + app_svc = AppService(metadata) + response: dict = app_svc.disable(params) + return self.dict_to_message(response) + + def delete(self, request, context): + params, metadata = self.parse_request(request, context) + app_svc = AppService(metadata) + app_svc.delete(params) + return self.empty() + + def get(self, request, context): + params, metadata = self.parse_request(request, context) + app_svc = AppService(metadata) + response: dict = app_svc.get(params) + return self.dict_to_message(response) + + def list(self, request, context): + params, metadata = self.parse_request(request, context) + app_svc = AppService(metadata) + response: dict = app_svc.list(params) + return self.dict_to_message(response) + + def stat(self, request, context): + params, metadata = self.parse_request(request, context) + app_svc = AppService(metadata) + response: dict = app_svc.stat(params) + return self.dict_to_message(response) diff --git a/src/spaceone/identity/manager/api_key_manager.py b/src/spaceone/identity/manager/api_key_manager.py index 75b95d75..2d173bd7 100644 --- a/src/spaceone/identity/manager/api_key_manager.py +++ b/src/spaceone/identity/manager/api_key_manager.py @@ -5,6 +5,7 @@ from spaceone.core.manager import BaseManager from spaceone.identity.lib.key_generator import KeyGenerator from spaceone.identity.model.api_key.database import APIKey +from spaceone.identity.model.app.database import App from spaceone.identity.model.domain.database import DomainSecret from spaceone.identity.model.user.database import User @@ -17,7 +18,7 @@ def __init__(self, *args, **kwargs): self.api_key_model = APIKey self.domain_secret_model = DomainSecret - def create_api_key(self, user_vo: User, params: dict) -> (APIKey, str): + def create_api_key_by_user_vo(self, user_vo: User, params: dict) -> (APIKey, str): def _rollback(api_key_vo): _LOGGER.info( f"[create_api_key._rollback] Delete api_key : {api_key_vo.api_key_id}" @@ -37,6 +38,27 @@ def _rollback(api_key_vo): api_key = key_gen.generate_api_key(api_key_vo.api_key_id) return api_key_vo, api_key + def create_api_key_by_app_vo(self, app_vo: App, params: dict) -> (APIKey, str): + def _rollback(api_key_vo, app_vo): + _LOGGER.info( + f"[create_api_key._rollback] Delete app and api_key : {app_vo.app_id} {api_key_vo.api_key_id}" + ) + app_vo.delete() + api_key_vo.delete() + + params["app"] = app_vo + api_key_vo: APIKey = self.api_key_model.create(params) + self.transaction.add_rollback(_rollback, api_key_vo) + + prv_jwk = self._query_domain_secret(params["domain_id"]) + + key_gen = KeyGenerator( + prv_jwk=prv_jwk, domain_id=params["domain_id"], audience=app_vo.app_id + ) + + api_key = key_gen.generate_api_key(api_key_vo.api_key_id) + return api_key_vo, api_key + def update_api_key_by_vo(self, params: dict, api_key_vo: APIKey) -> APIKey: def _rollback(old_data): _LOGGER.info( diff --git a/src/spaceone/identity/manager/app_manager.py b/src/spaceone/identity/manager/app_manager.py new file mode 100644 index 00000000..c8dbb5a9 --- /dev/null +++ b/src/spaceone/identity/manager/app_manager.py @@ -0,0 +1,79 @@ +import logging +from typing import Tuple, Union + +from spaceone.core.cache import cacheable +from spaceone.core.manager import BaseManager +from spaceone.identity.model.app.database import App + +_LOGGER = logging.getLogger(__name__) + + +class AppManager(BaseManager): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.app_model = App + + def create_app(self, params: dict) -> App: + def _rollback(vo: App): + _LOGGER.info(f"[create_app._rollback] Delete app: {vo.name} ({vo.app_id})") + vo.delete() + + app_vo = self.app_model.create(params) + self.transaction.add_rollback(_rollback, app_vo) + + return app_vo + + def update_app_by_vo(self, params: dict, app_vo: App) -> App: + def _rollback(old_data): + _LOGGER.info( + f'[update_app._rollback] Revert Data: {old_data["name"]} ({old_data["app_id"]})' + ) + app_vo.update(old_data) + + self.transaction.add_rollback(_rollback, app_vo.to_dict()) + + return app_vo.update(params) + + def enable_app(self, app_vo: App) -> App: + def _rollback(old_data): + _LOGGER.info(f"[enable_app._rollback] Revert Data: {old_data}") + app_vo.update(old_data) + + if app_vo.state != "ENABLED": + self.transaction.add_rollback(_rollback, app_vo.to_dict()) + app_vo.update({"state": "ENABLED"}) + + return app_vo + + def disable_app(self, app_vo: App) -> App: + def _rollback(old_data): + _LOGGER.info(f"[disable_app._rollback] Revert Data: {old_data}") + app_vo.update(old_data) + + if app_vo.state != "DISABLED": + self.transaction.add_rollback(_rollback, app_vo.to_dict()) + app_vo.update({"state": "DISABLED"}) + + return app_vo + + @staticmethod + def delete_app_by_vo(app_vo: App) -> None: + app_vo.delete() + + def get_app( + self, app_id: str, workspace_id: Union[str, None], domain_id: str + ) -> App: + conditions = { + "app_id": app_id, + "domain_id": domain_id, + } + if workspace_id: + conditions["workspace_id"] = workspace_id + + return self.app_model.get(**conditions) + + def list_apps(self, query: dict) -> Tuple[list, int]: + return self.app_model.query(**query) + + def stat_apps(self, query: dict) -> dict: + return self.app_model.stat(**query) diff --git a/src/spaceone/identity/model/api_key/database.py b/src/spaceone/identity/model/api_key/database.py index f88b35fe..48c6d5b9 100644 --- a/src/spaceone/identity/model/api_key/database.py +++ b/src/spaceone/identity/model/api_key/database.py @@ -9,14 +9,15 @@ class APIKey(MongoModel): max_length=20, default="ENABLED", choices=("ENABLED", "DISABLED", "EXPIRED") ) user = ReferenceField("User", null=True, default=None, reverse_delete_rule=CASCADE) - # app = ReferenceField("App", null=True, default=None, reverse_delete_rule=CASCADE) - user_id = StringField(max_length=40, required=True) + app = ReferenceField("App", null=True, default=None, reverse_delete_rule=CASCADE) + user_id = StringField(max_length=40, default=None, null=True) + app_id = StringField(max_length=40, default=None, null=True) domain_id = StringField(max_length=40, required=True) last_accessed_at = DateTimeField(default=None, null=True) created_at = DateTimeField(auto_now_add=True) meta = { - "updatable_fields": ["state", "nane", "last_accessed_at"], + "updatable_fields": ["state", "name", "last_accessed_at"], "minimal_fields": ["api_key_id", "state", "user_id"], "ordering": ["api_key_id"], "indexes": ["state", "user_id", "domain_id", "last_accessed_at"], diff --git a/src/spaceone/identity/model/api_key/response.py b/src/spaceone/identity/model/api_key/response.py index a47516e6..9c9f8236 100644 --- a/src/spaceone/identity/model/api_key/response.py +++ b/src/spaceone/identity/model/api_key/response.py @@ -16,11 +16,13 @@ class APIKeyResponse(BaseModel): domain_id: Union[str, None] = None created_at: Union[datetime, None] = None last_accessed_at: Union[datetime, None] = None + expired_at: Union[datetime, None] = None def dict(self, *args, **kwargs): data = super().dict(*args, **kwargs) data["created_at"] = utils.datetime_to_iso8601(data["created_at"]) data["last_accessed_at"] = utils.datetime_to_iso8601(data["last_accessed_at"]) + data["expired_at"] = utils.datetime_to_iso8601(data["expired_at"]) return data diff --git a/src/spaceone/identity/model/app/__init__.py b/src/spaceone/identity/model/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/spaceone/identity/model/app/database.py b/src/spaceone/identity/model/app/database.py new file mode 100644 index 00000000..08d86bb1 --- /dev/null +++ b/src/spaceone/identity/model/app/database.py @@ -0,0 +1,35 @@ +from mongoengine import * +from spaceone.core.model.mongo_model import MongoModel + + +class App(MongoModel): + app_id = StringField(max_length=40, generate_id="app", unique=True) + name = StringField(max_length=40, required=True) + state = StringField( + max_length=20, default="ENABLED", choices=("ENABLED", "DISABLED", "EXPIRED") + ) + role_type = StringField( + max_length=20, + default="WORKSPACE_MEMBER", + choices=( + "SYSTEM", + "SYSTEM_ADMIN", + "DOMAIN_ADMIN", + "WORKSPACE_OWNER", + "WORKSPACE_MEMBER", + ), + ) + api_key_id = StringField(max_length=40, default=None, null=True) + role_id = StringField(max_length=40, required=True) + + domain_id = StringField(max_length=40, required=True) + created_at = DateTimeField(auto_now_add=True) + last_accessed_at = DateTimeField(default=None, null=True) + expired_at = DateTimeField(required=True) + + meta = { + "updatable_fields": ["name", "state", "api_key_id", "tags", "last_accessed_at"], + "minimal_fields": ["app_id", "state", "expired_at", "api_key_id"], + "ordering": ["app_id"], + "indexes": ["state", "domain_id", "last_accessed_at", "expired_at"], + } diff --git a/src/spaceone/identity/model/app/request.py b/src/spaceone/identity/model/app/request.py new file mode 100644 index 00000000..78b11fd1 --- /dev/null +++ b/src/spaceone/identity/model/app/request.py @@ -0,0 +1,91 @@ +from datetime import datetime +from typing import Union, Literal +from pydantic import BaseModel + +__all__ = [ + "AppCreateRequest", + "AppUpdateRequest", + "AppGenerateAPIKeyRequest", + "AppEnableRequest", + "AppDisableRequest", + "AppDeleteRequest", + "AppGetRequest", + "AppSearchQueryRequest", + "AppStatQueryRequest", + "State", + "PermissionGroup", + "RoleType", +] + +State = Literal["ENABLED", "DISABLED", "EXPIRED"] +PermissionGroup = Literal["DOMAIN", "WORKSPACE"] +RoleType = Literal[ + "SYSTEM", "SYSTEM_ADMIN", "DOMAIN_ADMIN", "WORKSPACE_OWNER", "WORKSPACE_MEMBER" +] + + +class AppCreateRequest(BaseModel): + name: str + role_id: str + tags: Union[dict, None] = None + expired_at: Union[datetime, None] = None + permission_group: PermissionGroup + workspace_id: Union[str, None] = None + domain_id: str + + +class AppUpdateRequest(BaseModel): + app_id: str + name: Union[str, None] = None + tags: Union[dict, None] = None + workspace_id: Union[str, None] = None + domain_id: str + + +class AppGenerateAPIKeyRequest(BaseModel): + app_id: str + workspace_id: Union[str, None] = None + domain_id: str + + +class AppEnableRequest(BaseModel): + app_id: str + workspace_id: Union[str, None] = None + domain_id: str + + +class AppDisableRequest(BaseModel): + app_id: str + workspace_id: Union[str, None] = None + domain_id: str + + +class AppDeleteRequest(BaseModel): + app_id: str + workspace_id: Union[str, None] = None + domain_id: str + + +class AppGetRequest(BaseModel): + app_id: str + workspace_id: Union[str, None] = None + domain_id: str + + +class AppSearchQueryRequest(BaseModel): + query: Union[dict, None] = None + app_id: Union[str, None] = None + name: Union[str, None] = None + state: Union[State, None] = None + role_type: Union[str, None] = None + role_id: Union[str, None] = None + api_key_id: Union[str, None] = None + permission_group: Union[PermissionGroup, None] = None + workspace_id: Union[str, None] = None + domain_id: str + + +class AppStatQueryRequest(BaseModel): + query: dict + workspace_id: Union[str, None] = None + domain_id: str diff --git a/src/spaceone/identity/model/app/response.py b/src/spaceone/identity/model/app/response.py new file mode 100644 index 00000000..5fe9fd6c --- /dev/null +++ b/src/spaceone/identity/model/app/response.py @@ -0,0 +1,37 @@ +from datetime import datetime +from typing import Union, List +from pydantic import BaseModel + +from spaceone.core import utils + +from spaceone.identity.model.app.request import State, PermissionGroup, RoleType + +__all__ = ["AppResponse", "AppsResponse"] + + +class AppResponse(BaseModel): + app_id: Union[str, None] = None + api_key: Union[str, None] = None + name: Union[str, None] = None + state: Union[State, None] = None + role_type: Union[RoleType, None] = None + api_key_id: Union[str, None] = None + role_id: Union[str, None] = None + permission_group: Union[PermissionGroup, None] = None + workspace_id: Union[str, None] = None + domain_id: Union[str, None] = None + created_at: Union[datetime, None] = None + last_accessed_at: Union[datetime, None] = None + expired_at: Union[datetime, None] = None + + def dict(self, *args, **kwargs): + data = super().dict(*args, **kwargs) + data["created_at"] = utils.datetime_to_iso8601(data["created_at"]) + data["last_accessed_at"] = utils.datetime_to_iso8601(data["last_accessed_at"]) + data["expired_at"] = utils.datetime_to_iso8601(data["expired_at"]) + return data + + +class AppsResponse(BaseModel): + results: List[AppResponse] + total_count: int diff --git a/src/spaceone/identity/service/api_key_service.py b/src/spaceone/identity/service/api_key_service.py index 9ed2e349..0cb91623 100644 --- a/src/spaceone/identity/service/api_key_service.py +++ b/src/spaceone/identity/service/api_key_service.py @@ -38,12 +38,14 @@ def create(self, params: APIKeyCreateRequest) -> Union[APIKeyResponse, dict]: Return: APIKeyResponse: """ - expired_at = self._get_expired_at(params.expired_at) - self._check_expired_at(expired_at) + params.expired_at = self._get_expired_at(params.expired_at) + self._check_expired_at(params.expired_at) user_mgr = UserManager() user_vo = user_mgr.get_user(params.user_id, params.domain_id) - api_key_vo, api_key = self.api_key_mgr.create_api_key(user_vo, params.dict()) + api_key_vo, api_key = self.api_key_mgr.create_api_key_by_user_vo( + user_vo, params.dict() + ) return APIKeyResponse(**api_key_vo.to_dict(), api_key=api_key) @transaction @@ -72,6 +74,8 @@ def enable(self, params: APIKeyEnableRequest) -> Union[APIKeyResponse, dict]: 'api_key_id': 'str', # required 'domain_id': 'str' # required } + Returns: + APIKeyResponse: """ api_key_vo = self.api_key_mgr.get_api_key(params.api_key_id, params.domain_id) api_key_vo = self.api_key_mgr.enable_api_key(api_key_vo) @@ -168,13 +172,13 @@ def _get_expired_at(expired_at: datetime) -> datetime: else: return datetime.now().replace( hour=23, minute=59, second=59, microsecond=0 - ) + timedelta(days=365) + ) + timedelta(days=364) @staticmethod def _check_expired_at(expired_at): one_year_later = datetime.now().replace( hour=23, minute=59, second=59, microsecond=0 - ) + timedelta(days=365) + ) + timedelta(days=364) if one_year_later < expired_at: raise ERROR_API_KEY_EXPIRED_LIMIT( diff --git a/src/spaceone/identity/service/app_service.py b/src/spaceone/identity/service/app_service.py new file mode 100644 index 00000000..94e48bc4 --- /dev/null +++ b/src/spaceone/identity/service/app_service.py @@ -0,0 +1,245 @@ +import logging +from datetime import datetime, timedelta +from typing import Union + +from spaceone.core.service import ( + BaseService, + transaction, + convert_model, + append_query_filter, + append_keyword_filter, +) + +from spaceone.identity.error.error_api_key import * +from spaceone.identity.manager.app_manager import AppManager +from spaceone.identity.manager.api_key_manager import APIKeyManager +from spaceone.identity.manager.user_manager import UserManager +from spaceone.identity.model.app.request import * +from spaceone.identity.model.app.response import * + +_LOGGER = logging.getLogger(__name__) + + +class AppService(BaseService): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.app_mgr = AppManager() + + @transaction + @convert_model + def create(self, params: AppCreateRequest) -> Union[AppResponse, dict]: + """Create API Key + Args: + params (AppCreateRequest): { + 'name': 'str', # required + 'role_id': 'str', # required + 'tags': 'dict', + 'expired_at': 'datetime', + 'permission_group': 'str', # required + 'workspace_id': 'str', + 'domain_id': 'str', # required + } + Return: + AppResponse: + """ + params.expired_at = self._get_expired_at(params.expired_at) + self._check_expired_at(params.expired_at) + + if params.workspace_id: + params.permission_group = "WORKSPACE" + + app_vo = self.app_mgr.create_app(params.dict()) + + api_key_mgr = APIKeyManager() + api_key_vo, api_key = api_key_mgr.create_api_key_by_app_vo( + app_vo, params.dict() + ) + + app_vo = self.app_mgr.update_app_by_vo( + {"api_key_id": api_key_vo.api_key_id}, app_vo + ) + + return AppResponse(**app_vo.to_dict(), api_key=api_key) + + @transaction + @convert_model + def update(self, params: AppUpdateRequest) -> Union[AppResponse, dict]: + """Update App + Args: + params (dict): { + 'app_id': 'str', # required + 'name': 'str', + 'tags': 'dict', + 'workspace_id': 'str', + 'domain_id': 'str' # required + } + Return: + AppResponse: + """ + app_vo = self.app_mgr.get_app( + params.app_id, params.workspace_id, params.domain_id + ) + app_vo = self.app_mgr.update_app_by_vo(params.dict(), app_vo) + return AppResponse(**app_vo.to_dict()) + + @transaction + @convert_model + def generate_api_key( + self, params: AppGenerateAPIKeyRequest + ) -> Union[AppResponse, dict]: + """Generate API Key + Args: + params (dict): { + 'app_id': 'str', # required + 'workspace_id': 'str', + 'domain_id': 'str' # required + } + Return: + AppResponse: + """ + pass + + @transaction + @convert_model + def enable(self, params: AppEnableRequest) -> Union[AppResponse, dict]: + """Enable App Key + Args: + params (dict): { + 'app_id': 'str', # required + 'workspace_id': 'str', + 'domain_id': 'str' # required + } + """ + app_vo = self.app_mgr.get_app( + params.app_id, params.workspace_id, params.domain_id + ) + app_vo = self.app_mgr.enable_app(app_vo) + return AppResponse(**app_vo.to_dict()) + + @transaction + @convert_model + def disable(self, params: AppDisableRequest) -> Union[AppResponse, dict]: + """Disable App Key + Args: + params (dict): { + 'app_id': 'str', # required + 'workspace_id': 'str', + 'domain_id': 'str' # required + } + """ + app_vo = self.app_mgr.get_app( + params.app_id, params.workspace_id, params.domain_id + ) + app_vo = self.app_mgr.disable_app(app_vo) + return AppResponse(**app_vo.to_dict()) + + @transaction + @convert_model + def delete(self, params: AppDeleteRequest) -> None: + """Delete app + Args: + params (dict): { + 'api_key_id': 'str', # required + 'workspace_id': 'str', + 'domain_id': 'str' # required + } + Returns: + None + """ + app_vo = self.app_mgr.get_app( + params.app_id, params.workspace_id, params.domain_id + ) + self.app_mgr.delete_app_by_vo(app_vo) + + @transaction + @convert_model + def get(self, params: AppGetRequest) -> Union[AppResponse, dict]: + """Get API Key + Args: + params (dict): { + 'app_id': 'str', # required + 'workspace_id': 'str', + 'domain_id': 'str' # required + } + Returns: + AppResponse: + """ + app_vo = self.app_mgr.get_app( + params.app_id, params.workspace_id, params.domain_id + ) + return AppResponse(**app_vo.to_dict()) + + @transaction + @append_query_filter( + [ + "app_id", + "name", + "state", + "role_type", + "role_id", + "api_key_id", + "permission_group", + "workspace_id", + "domain_id", + ] + ) + @append_keyword_filter(["app_id", "role_id"]) + @convert_model + def list(self, params: AppSearchQueryRequest) -> Union[AppsResponse, dict]: + """List Apps + Args: + params (dict): { + 'query': 'dict (spaceone.api.core.v1.Query)', + 'app_id': 'str', + 'name': 'str', + 'state': 'str', + 'role_type': 'str', + 'role_id': 'str', + 'api_key_id': 'str', + 'permission_group': 'str', + 'workspace_id': 'str', + 'domain_id': 'str' # required + } + Returns: + AppsResponse: + """ + query = params.query or {} + app_vos, total_count = self.app_mgr.list_apps(query) + apps_info = [app_vo.to_dict() for app_vo in app_vos] + return AppsResponse(results=apps_info, total_count=total_count) + + @transaction + @convert_model + def stat(self, params: AppStatQueryRequest) -> dict: + """Stat API Keys + Args: + params (dict): { + 'query': 'dict', # required + 'workspcae_id': 'str', + 'domain_id': 'str' # required + } + Returns: + dict: + """ + query = params.query or {} + return self.app_mgr.stat_apps(query) + + @staticmethod + def _get_expired_at(expired_at: datetime) -> datetime: + if expired_at: + return expired_at.replace(hour=23, minute=59, second=59, microsecond=0) + else: + return datetime.now().replace( + hour=23, minute=59, second=59, microsecond=0 + ) + timedelta(days=364) + + @staticmethod + def _check_expired_at(expired_at): + one_year_later = datetime.now().replace( + hour=23, minute=59, second=59, microsecond=0 + ) + timedelta(days=364) + + if one_year_later < expired_at: + raise ERROR_API_KEY_EXPIRED_LIMIT( + expired_at=expired_at.strftime("%Y-%m-%dT%H:%M:%S") + ) From 5d5a0fd851f769dbe21ade2db4ba40005a745a60 Mon Sep 17 00:00:00 2001 From: ImMin5 Date: Thu, 7 Dec 2023 17:03:17 +0900 Subject: [PATCH 2/2] feat: add check last admin when disable and delete user (#73) Signed-off-by: ImMin5 --- src/spaceone/identity/error/error_user.py | 4 ++ .../identity/manager/api_key_manager.py | 9 ++- .../identity/service/api_key_service.py | 17 ++++- src/spaceone/identity/service/user_service.py | 68 +++++++++++++------ 4 files changed, 73 insertions(+), 25 deletions(-) diff --git a/src/spaceone/identity/error/error_user.py b/src/spaceone/identity/error/error_user.py index 4f58f3d7..218629ff 100644 --- a/src/spaceone/identity/error/error_user.py +++ b/src/spaceone/identity/error/error_user.py @@ -57,3 +57,7 @@ class ERROR_UNABLE_TO_RESET_PASSWORD_IN_EXTERNAL_AUTH(ERROR_UNABLE_TO_RESET_PASS class ERROR_UNABLE_TO_RESET_PASSWORD_WITHOUT_EMAIL(ERROR_UNABLE_TO_RESET_PASSWORD): _message = "Unable to reset password without email. (user_id = {user_id})" + + +class ERROR_LAST_ADMIN_CANNOT_DISABLE(ERROR_INVALID_ARGUMENT): + _message = "The last admin cannot be disabled. (user_id = {user_id})" diff --git a/src/spaceone/identity/manager/api_key_manager.py b/src/spaceone/identity/manager/api_key_manager.py index 2d173bd7..42d0f338 100644 --- a/src/spaceone/identity/manager/api_key_manager.py +++ b/src/spaceone/identity/manager/api_key_manager.py @@ -1,11 +1,13 @@ import logging from typing import Tuple +from mongoengine import QuerySet from spaceone.core.cache import cacheable from spaceone.core.manager import BaseManager + from spaceone.identity.lib.key_generator import KeyGenerator -from spaceone.identity.model.api_key.database import APIKey from spaceone.identity.model.app.database import App +from spaceone.identity.model.api_key.database import APIKey from spaceone.identity.model.domain.database import DomainSecret from spaceone.identity.model.user.database import User @@ -103,10 +105,13 @@ def _rollback(old_data): def get_api_key(self, api_key_id: str, domain_id: str) -> APIKey: return self.api_key_model.get(api_key_id=api_key_id, domain_id=domain_id) + def filter_api_keys(self, **conditions) -> QuerySet: + return self.api_key_model.filter(**conditions) + def list_api_keys(self, query: dict) -> Tuple[list, int]: return self.api_key_model.query(**query) - def stat_api_keys(self, query): + def stat_api_keys(self, query: dict) -> dict: return self.api_key_model.stat(**query) @cacheable(key="api-key:{domain_id}", expire=60) diff --git a/src/spaceone/identity/service/api_key_service.py b/src/spaceone/identity/service/api_key_service.py index 0cb91623..ee01334c 100644 --- a/src/spaceone/identity/service/api_key_service.py +++ b/src/spaceone/identity/service/api_key_service.py @@ -46,6 +46,11 @@ def create(self, params: APIKeyCreateRequest) -> Union[APIKeyResponse, dict]: api_key_vo, api_key = self.api_key_mgr.create_api_key_by_user_vo( user_vo, params.dict() ) + api_key_counts = self.api_key_mgr.filter_api_keys( + user_id=user_vo.user_id, domain_id=params.domain_id + ) + print(api_key_counts) + user_mgr.update_user_by_vo({"api_key_count": len(api_key_counts)}, user_vo) return APIKeyResponse(**api_key_vo.to_dict(), api_key=api_key) @transaction @@ -62,7 +67,9 @@ def update(self, params: APIKeyUpdateRequest) -> Union[APIKeyResponse, dict]: APIKeyResponse: """ api_key_vo = self.api_key_mgr.get_api_key(params.api_key_id, params.domain_id) - api_key_vo = self.api_key_mgr.update_api_key_by_vo(params.dict(), api_key_vo) + api_key_vo = self.api_key_mgr.update_api_key_by_vo( + params.dict(exclude_unset=True), api_key_vo + ) return APIKeyResponse(**api_key_vo.to_dict()) @transaction @@ -111,6 +118,14 @@ def delete(self, params: APIKeyDeleteRequest) -> None: None """ api_key_vo = self.api_key_mgr.get_api_key(params.api_key_id, params.domain_id) + if api_key_vo.user_id: + user_mgr = UserManager() + user_vo = user_mgr.get_user(api_key_vo.user_id, params.domain_id) + api_key_counts = self.api_key_mgr.filter_api_keys( + user_id=user_vo.user_id, domain_id=params.domain_id + ) + user_mgr.update_user_by_vo({"api_key_count": len(api_key_counts)}, user_vo) + self.api_key_mgr.delete_api_key_by_vo(api_key_vo) @transaction diff --git a/src/spaceone/identity/service/user_service.py b/src/spaceone/identity/service/user_service.py index db4293c3..0251deed 100644 --- a/src/spaceone/identity/service/user_service.py +++ b/src/spaceone/identity/service/user_service.py @@ -21,7 +21,9 @@ from spaceone.identity.manager.domain_secret_manager import DomainSecretManager from spaceone.identity.manager.role_binding_manager import RoleBindingManager from spaceone.identity.manager.mfa_manager import MFAManager -from spaceone.identity.manager.token_manager.local_token_manager import LocalTokenManager +from spaceone.identity.manager.token_manager.local_token_manager import ( + LocalTokenManager, +) from spaceone.identity.manager.user_manager import UserManager from spaceone.identity.manager.workspace_manager import WorkspaceManager from spaceone.identity.model.user.request import * @@ -78,7 +80,9 @@ def create_user(self, params: dict) -> User: temp_password = self._generate_temporary_password() params["password"] = copy.deepcopy(temp_password) - reset_password_type = config.get_global("RESET_PASSWORD_TYPE", "ACCESS_TOKEN") + reset_password_type = config.get_global( + "RESET_PASSWORD_TYPE", "ACCESS_TOKEN" + ) if reset_password_type == "ACCESS_TOKEN": token = self._issue_temporary_token(user_id, domain_id) @@ -217,7 +221,9 @@ def verify_email(self, params: UserVerifyEmailRequest) -> None: @transaction(append_meta={"authorization.scope": "DOMAIN_OR_USER"}) @convert_model - def confirm_email(self, params: UserConfirmEmailRequest) -> Union[UserResponse, dict]: + def confirm_email( + self, params: UserConfirmEmailRequest + ) -> Union[UserResponse, dict]: """Confirm email Args: @@ -434,8 +440,8 @@ def delete(self, params: UserDeleteRequest) -> None: None """ user_vo = self.user_mgr.get_user(params.user_id, params.domain_id) + self._check_last_admin(user_vo) - # TODO: check this user is last admin (DOMAIN_ADMIN, SYSTEM_ADMIN) self.user_mgr.delete_user_by_vo(user_vo) @transaction(append_meta={"authorization.scope": "DOMAIN"}) @@ -475,7 +481,7 @@ def disable(self, params: UserDisableRequest) -> Union[UserResponse, dict]: user_vo = self.user_mgr.get_user(params.user_id, params.domain_id) user_vo = self.user_mgr.update_user_by_vo({"state": "DISABLED"}, user_vo) - # TODO: check this user is last admin (DOMAIN_ADMIN, SYSTEM_ADMIN) + self._check_last_admin(user_vo) return UserResponse(**user_vo.to_dict()) @@ -499,8 +505,10 @@ def get(self, params: UserGetRequest) -> Union[UserResponse, dict]: @transaction(append_meta={"authorization.scope": "DOMAIN_READ"}) @convert_model - def get_workspaces(self, params: UserWorkspacesRequest) -> Union[WorkspacesResponse, dict]: - """ Find user + def get_workspaces( + self, params: UserWorkspacesRequest + ) -> Union[WorkspacesResponse, dict]: + """Find user Args: params (UserWorkspacesRequest): { 'user_id': 'str', # required @@ -516,12 +524,14 @@ def get_workspaces(self, params: UserWorkspacesRequest) -> Union[WorkspacesRespo user_vo = self.user_mgr.get_user(params.user_id, params.domain_id) - if user_vo.role_type == ['SYSTEM_ADMIN', 'DOMAIN_ADMIN']: + if user_vo.role_type == ["SYSTEM_ADMIN", "DOMAIN_ADMIN"]: allow_all = True else: rb_vos = rb_mgr.filter_role_bindings( - user_id=params.user_id, domain_id=params.domain_id, role_type=['WORKSPACE_OWNER', 'WORKSPACE_MEMBER'], - workspace_id='*' + user_id=params.user_id, + domain_id=params.domain_id, + role_type=["WORKSPACE_OWNER", "WORKSPACE_MEMBER"], + workspace_id="*", ) if rb_vos.count() > 0: @@ -531,19 +541,25 @@ def get_workspaces(self, params: UserWorkspacesRequest) -> Union[WorkspacesRespo workspace_vos = workspace_mgr.filter_workspaces(domain_id=params.domain_id) else: rb_vos = rb_mgr.filter_role_bindings( - user_id=params.user_id, domain_id=params.domain_id, role_type=['WORKSPACE_OWNER', 'WORKSPACE_MEMBER'] + user_id=params.user_id, + domain_id=params.domain_id, + role_type=["WORKSPACE_OWNER", "WORKSPACE_MEMBER"], ) workspace_ids = list(set([rb.workspace_id for rb in rb_vos])) - workspace_vos = workspace_mgr.filter_workspaces(workspace_id=workspace_ids, domain_id=params.domain_id) + workspace_vos = workspace_mgr.filter_workspaces( + workspace_id=workspace_ids, domain_id=params.domain_id + ) workspaces_info = [workspace_vo.to_dict() for workspace_vo in workspace_vos] - return WorkspacesResponse(results=workspaces_info, total_count=len(workspaces_info)) + return WorkspacesResponse( + results=workspaces_info, total_count=len(workspaces_info) + ) @transaction(append_meta={"authorization.scope": "DOMAIN_READ"}) @convert_model def find(self, params: UserFindRequest) -> Union[UsersSummaryResponse, dict]: - """ Find user + """Find user Args: params (UserFindRequest): { 'keyword': 'str', # required @@ -557,15 +573,13 @@ def find(self, params: UserFindRequest) -> Union[UsersSummaryResponse, dict]: """ query = { - "filter": [ - {"k": "domain_id", "v": params.domain_id, "o": "eq"} - ], + "filter": [{"k": "domain_id", "v": params.domain_id, "o": "eq"}], "filter_or": [ {"k": "user_id", "v": params.keyword, "o": "contain"}, - {"k": "name", "v": params.keyword, "o": "contain"} + {"k": "name", "v": params.keyword, "o": "contain"}, ], "page": params.page, - "only": ["user_id", "name", "state"] + "only": ["user_id", "name", "state"], } if params.state: @@ -573,7 +587,9 @@ def find(self, params: UserFindRequest) -> Union[UsersSummaryResponse, dict]: if params.exclude_workspace_id: rb_mgr = RoleBindingManager() - rb_vos = rb_mgr.filter_role_bindings(workspace_id=params.exclude_workspace_id, domain_id=params.domain_id) + rb_vos = rb_mgr.filter_role_bindings( + workspace_id=params.exclude_workspace_id, domain_id=params.domain_id + ) user_ids = list(set([rb.user_id for rb in rb_vos])) query["filter"].append({"k": "user_id", "v": user_ids, "o": "not_in"}) @@ -589,7 +605,6 @@ def find(self, params: UserFindRequest) -> Union[UsersSummaryResponse, dict]: "name", "state", "email", - "user_type", "auth_type", "domain_id", ] @@ -623,7 +638,7 @@ def list(self, params: UserSearchQueryRequest) -> Union[UsersResponse, dict]: @append_keyword_filter(["user_id", "name", "email"]) @convert_model def stat(self, params: UserStatQueryRequest) -> dict: - """ stat users + """stat users Args: params (UserStatQueryRequest): { @@ -671,6 +686,15 @@ def _get_console_url(self, domain_id): console_domain = config.get_global("EMAIL_CONSOLE_DOMAIN") return console_domain.format(domain_name=domain_name) + @staticmethod + def _check_last_admin(user_vo): + rb_mgr = RoleBindingManager() + rb_vos = rb_mgr.filter_role_bindings( + domain_id=user_vo.domain_id, role_type=user_vo.role_type + ) + if rb_vos.count() == 1: + raise ERROR_LAST_ADMIN_CANNOT_DISABLE(user_id=user_vo.user_id) + @staticmethod def _check_reset_password_eligibility(user_id, auth_type, email): if auth_type == "EXTERNAL":