From 0fc5aecd554103a7e132fe9711452c40c4288839 Mon Sep 17 00:00:00 2001 From: Naman Munet Date: Wed, 4 Dec 2024 15:08:42 +0530 Subject: [PATCH] mgr/dashboard: RGW user accounts CRUD api's Fixes: https://tracker.ceph.com/issues/69110 PR Changes: Added CRUD & managing quota endpoints for RGW user accounts management with the unit-tests Signed-off-by: Naman Munet --- .../mgr/dashboard/controllers/rgw_iam.py | 52 ++++ src/pybind/mgr/dashboard/openapi.yaml | 270 ++++++++++++++++ src/pybind/mgr/dashboard/services/rgw_iam.py | 81 ++++- .../mgr/dashboard/tests/test_rgw_iam.py | 292 ++++++++++++++++++ 4 files changed, 691 insertions(+), 4 deletions(-) create mode 100644 src/pybind/mgr/dashboard/controllers/rgw_iam.py create mode 100644 src/pybind/mgr/dashboard/tests/test_rgw_iam.py diff --git a/src/pybind/mgr/dashboard/controllers/rgw_iam.py b/src/pybind/mgr/dashboard/controllers/rgw_iam.py new file mode 100644 index 0000000000000..458bbbb732188 --- /dev/null +++ b/src/pybind/mgr/dashboard/controllers/rgw_iam.py @@ -0,0 +1,52 @@ +from typing import Optional + +from ..security import Scope +from ..services.rgw_iam import RgwAccounts +from ..tools import str_to_bool +from . import APIDoc, APIRouter, EndpointDoc, RESTController, allow_empty_body + + +@APIRouter('rgw/accounts', Scope.RGW) +@APIDoc("RGW User Accounts API", "RgwUserAccounts") +class RgwUserAccountsController(RESTController): + + @allow_empty_body + def create(self, account_name: Optional[str] = None, + account_id: Optional[str] = None, email: Optional[str] = None): + return RgwAccounts.create_account(account_name, account_id, email) + + def list(self, detailed: bool = False): + detailed = str_to_bool(detailed) + return RgwAccounts.get_accounts(detailed) + + @EndpointDoc("Get RGW Account by id", + parameters={'account_id': (str, 'Account id')}) + def get(self, account_id: str): + return RgwAccounts.get_account(account_id) + + @EndpointDoc("Delete RGW Account", + parameters={'account_id': (str, 'Account id')}) + def delete(self, account_id): + return RgwAccounts.delete_account(account_id) + + @EndpointDoc("Update RGW account info", + parameters={'account_id': (str, 'Account id')}) + @allow_empty_body + def set(self, account_id: str, account_name: Optional[str] = None, + email: Optional[str] = None): + return RgwAccounts.modify_account(account_id, account_name, email) + + @EndpointDoc("Set RGW Account/Bucket quota", + parameters={'account_id': (str, 'Account id'), + 'max_size': (str, 'Max size')}) + @RESTController.Resource(method='PUT', path='/quota') + @allow_empty_body + def set_quota(self, quota_type: str, account_id: str, max_size: str, max_objects: str): + return RgwAccounts.set_quota(quota_type, account_id, max_size, max_objects) + + @EndpointDoc("Enable/Disable RGW Account/Bucket quota", + parameters={'account_id': (str, 'Account id')}) + @RESTController.Resource(method='PUT', path='/quota/status') + @allow_empty_body + def set_quota_status(self, quota_type: str, account_id: str, quota_status: str): + return RgwAccounts.set_quota_status(quota_type, account_id, quota_status) diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index b464344e27a2f..2e635c94833f6 100644 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -10790,6 +10790,274 @@ paths: - jwt: [] tags: - Prometheus + /api/rgw/accounts: + get: + parameters: + - default: false + in: query + name: detailed + schema: + type: boolean + 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: [] + tags: + - RgwUserAccounts + post: + parameters: [] + requestBody: + content: + application/json: + schema: + properties: + account_id: + type: integer + account_name: + type: integer + email: + type: string + 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: [] + tags: + - RgwUserAccounts + /api/rgw/accounts/{account_id}: + delete: + parameters: + - description: Account id + in: path + name: account_id + required: true + schema: + type: string + responses: + '202': + content: + application/vnd.ceph.api.v1.0+json: + type: object + description: Operation is still executing. Please check the task queue. + '204': + content: + application/vnd.ceph.api.v1.0+json: + type: object + description: Resource deleted. + '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: Delete RGW Account + tags: + - RgwUserAccounts + get: + parameters: + - description: Account id + in: path + name: account_id + required: true + schema: + type: string + 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: Get RGW Account by id + tags: + - RgwUserAccounts + put: + parameters: + - description: Account id + in: path + name: account_id + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + properties: + account_name: + type: integer + email: + type: string + type: object + responses: + '200': + content: + application/vnd.ceph.api.v1.0+json: + type: object + description: Resource updated. + '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: Update RGW account info + tags: + - RgwUserAccounts + /api/rgw/accounts/{account_id}/quota: + put: + parameters: + - description: Account id + in: path + name: account_id + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + properties: + max_objects: + type: string + max_size: + description: Max size + type: string + quota_type: + type: string + required: + - quota_type + - max_size + - max_objects + type: object + responses: + '200': + content: + application/vnd.ceph.api.v1.0+json: + type: object + description: Resource updated. + '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: Set RGW Account/Bucket quota + tags: + - RgwUserAccounts + /api/rgw/accounts/{account_id}/quota/status: + put: + parameters: + - description: Account id + in: path + name: account_id + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + properties: + quota_status: + type: string + quota_type: + type: string + required: + - quota_type + - quota_status + type: object + responses: + '200': + content: + application/vnd.ceph.api.v1.0+json: + type: object + description: Resource updated. + '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: Enable/Disable RGW Account/Bucket quota + tags: + - RgwUserAccounts /api/rgw/bucket: get: parameters: @@ -15472,6 +15740,8 @@ tags: name: RgwSite - description: RGW User Management API name: RgwUser +- description: RGW User Accounts API + name: RgwUserAccounts - description: '*No description available*' name: RgwZone - description: '*No description available*' diff --git a/src/pybind/mgr/dashboard/services/rgw_iam.py b/src/pybind/mgr/dashboard/services/rgw_iam.py index dbf00df25e0bb..5f490323441ac 100644 --- a/src/pybind/mgr/dashboard/services/rgw_iam.py +++ b/src/pybind/mgr/dashboard/services/rgw_iam.py @@ -1,12 +1,13 @@ from subprocess import SubprocessError -from typing import List +from typing import List, Optional from .. import mgr from ..exceptions import DashboardException class RgwAccounts: - def send_rgw_cmd(self, command: List[str]): + @classmethod + def send_rgw_cmd(cls, command: List[str]): try: exit_code, out, err = mgr.send_rgwadmin_command(command) @@ -19,6 +20,78 @@ class RgwAccounts: except SubprocessError as e: raise DashboardException(e, component='rgw') - def get_accounts(self): + @classmethod + def get_accounts(cls, detailed: bool = False): + """ + Query account Id's, optionally returning full details. + + :param detailed: Boolean to indicate if full account details are required. + """ get_accounts_cmd = ['account', 'list'] - return self.send_rgw_cmd(get_accounts_cmd) + account_list = cls.send_rgw_cmd(get_accounts_cmd) + detailed_account_list = [] + if detailed: + for account in account_list: + detailed_account_list.append(cls.get_account(account)) + return detailed_account_list + return account_list + + @classmethod + def get_account(cls, account_id: str): + get_account_cmd = ['account', 'get', '--account-id', account_id] + return cls.send_rgw_cmd(get_account_cmd) + + @classmethod + def create_account(cls, account_name: Optional[str] = None, + account_id: Optional[str] = None, email: Optional[str] = None): + create_accounts_cmd = ['account', 'create'] + + if account_name: + create_accounts_cmd += ['--account-name', account_name] + + if account_id: + create_accounts_cmd += ['--account_id', account_id] + + if email: + create_accounts_cmd += ['--email', email] + + return cls.send_rgw_cmd(create_accounts_cmd) + + @classmethod + def modify_account(cls, account_id: str, account_name: Optional[str] = None, + email: Optional[str] = None): + modify_accounts_cmd = ['account', 'modify', '--account-id', account_id] + + if account_name: + modify_accounts_cmd += ['--account-name', account_name] + + if email: + modify_accounts_cmd += ['--email', email] + + return cls.send_rgw_cmd(modify_accounts_cmd) + + @classmethod + def delete_account(cls, account_id: str): + modify_accounts_cmd = ['account', 'rm', '--account-id', account_id] + + return cls.send_rgw_cmd(modify_accounts_cmd) + + @classmethod + def get_account_stats(cls, account_id: str): + account_stats_cmd = ['account', 'stats', '--account-id', account_id] + + return cls.send_rgw_cmd(account_stats_cmd) + + @classmethod + def set_quota(cls, quota_type: str, account_id: str, max_size: str, max_objects: str): + set_quota_cmd = ['quota', 'set', '--quota-scope', quota_type, '--account-id', account_id, + '--max-size', max_size, '--max-objects', max_objects] + + return cls.send_rgw_cmd(set_quota_cmd) + + @classmethod + def set_quota_status(cls, quota_type: str, account_id: str, quota_status: str): + set_quota_status_cmd = ['quota', quota_status, '--quota-scope', quota_type, + '--account-id', account_id] + + return cls.send_rgw_cmd(set_quota_status_cmd) diff --git a/src/pybind/mgr/dashboard/tests/test_rgw_iam.py b/src/pybind/mgr/dashboard/tests/test_rgw_iam.py new file mode 100644 index 0000000000000..133b5a0d390c3 --- /dev/null +++ b/src/pybind/mgr/dashboard/tests/test_rgw_iam.py @@ -0,0 +1,292 @@ +from unittest import TestCase +from unittest.mock import patch + +from ..controllers.rgw_iam import RgwUserAccountsController +from ..services.rgw_iam import RgwAccounts + + +class TestRgwUserAccountsController(TestCase): + + @patch.object(RgwAccounts, 'create_account') + def test_create_account(self, mock_create_account): + mockReturnVal = { + "id": "RGW18661471562806836", + "tenant": "", + "name": "", + "email": "", + "quota": { + "enabled": False, + "check_on_raw": False, + "max_size": -1, + "max_size_kb": 0, + "max_objects": -1 + }, + "bucket_quota": { + "enabled": False, + "check_on_raw": False, + "max_size": -1, + "max_size_kb": 0, + "max_objects": -1 + }, + "max_users": 1000, + "max_roles": 1000, + "max_groups": 1000, + "max_buckets": 1000, + "max_access_keys": 4 + } + + # Mock the return value of the create_account method + mock_create_account.return_value = mockReturnVal + + controller = RgwUserAccountsController() + result = controller.create(account_name='test_account', account_id='RGW18661471562806836', + email='test@example.com') + + # Check if the account creation method was called with the correct parameters + mock_create_account.assert_called_with('test_account', 'RGW18661471562806836', + 'test@example.com') + # Check the returned result + self.assertEqual(result, mockReturnVal) + + @patch.object(RgwAccounts, 'get_accounts') + def test_list_accounts(self, mock_get_accounts): + mock_return_value = [ + "RGW22222222222222222", + "RGW59378973811515857", + "RGW11111111111111111" + ] + + mock_get_accounts.return_value = mock_return_value + + controller = RgwUserAccountsController() + result = controller.list(detailed=False) + + mock_get_accounts.assert_called_with(False) + + self.assertEqual(result, mock_return_value) + + @patch.object(RgwAccounts, 'get_accounts') + def test_list_accounts_with_details(self, mock_get_accounts): + mock_return_value = [ + { + "id": "RGW22222222222222222", + "tenant": "", + "name": "Account2", + "email": "account2@ceph.com", + "quota": { + "enabled": False, + "check_on_raw": False, + "max_size": -1, + "max_size_kb": 0, + "max_objects": -1 + }, + "bucket_quota": { + "enabled": False, + "check_on_raw": False, + "max_size": -1, + "max_size_kb": 0, + "max_objects": -1 + }, + "max_users": 1000, + "max_roles": 1000, + "max_groups": 1000, + "max_buckets": 1000, + "max_access_keys": 4 + }, + { + "id": "RGW11111111111111111", + "tenant": "", + "name": "Account1", + "email": "account1@ceph.com", + "quota": { + "enabled": False, + "check_on_raw": False, + "max_size": -1, + "max_size_kb": 0, + "max_objects": -1 + }, + "bucket_quota": { + "enabled": False, + "check_on_raw": False, + "max_size": -1, + "max_size_kb": 0, + "max_objects": -1 + }, + "max_users": 1000, + "max_roles": 1000, + "max_groups": 1000, + "max_buckets": 1000, + "max_access_keys": 4 + } + ] + + mock_get_accounts.return_value = mock_return_value + + controller = RgwUserAccountsController() + result = controller.list(detailed=True) + + mock_get_accounts.assert_called_with(True) + + self.assertEqual(result, mock_return_value) + + @patch.object(RgwAccounts, 'get_account') + def test_get_account(self, mock_get_account): + mock_return_value = { + "id": "RGW22222222222222222", + "tenant": "", + "name": "Account2", + "email": "account2@ceph.com", + "quota": { + "enabled": False, + "check_on_raw": False, + "max_size": -1, + "max_size_kb": 0, + "max_objects": -1 + }, + "bucket_quota": { + "enabled": False, + "check_on_raw": False, + "max_size": -1, + "max_size_kb": 0, + "max_objects": -1 + }, + "max_users": 1000, + "max_roles": 1000, + "max_groups": 1000, + "max_buckets": 1000, + "max_access_keys": 4 + } + mock_get_account.return_value = mock_return_value + + controller = RgwUserAccountsController() + result = controller.get(account_id='RGW22222222222222222') + + mock_get_account.assert_called_with('RGW22222222222222222') + + self.assertEqual(result, mock_return_value) + + @patch.object(RgwAccounts, 'delete_account') + def test_delete_account(self, mock_delete_account): + mock_delete_account.return_value = None + + controller = RgwUserAccountsController() + result = controller.delete(account_id='RGW59378973811515857') + + mock_delete_account.assert_called_with('RGW59378973811515857') + + self.assertEqual(result, None) + + @patch.object(RgwAccounts, 'modify_account') + def test_set_account_name(self, mock_modify_account): + mock_return_value = mock_return_value = { + "id": "RGW59378973811515857", + "tenant": "", + "name": "new_account_name", + "email": "new_email@example.com", + "quota": { + "enabled": False, + "check_on_raw": False, + "max_size": -1, + "max_size_kb": 0, + "max_objects": -1 + }, + "bucket_quota": { + "enabled": False, + "check_on_raw": False, + "max_size": -1, + "max_size_kb": 0, + "max_objects": -1 + }, + "max_users": 1000, + "max_roles": 1000, + "max_groups": 1000, + "max_buckets": 1000, + "max_access_keys": 4 + } + mock_modify_account.return_value = mock_return_value + + controller = RgwUserAccountsController() + result = controller.set(account_id='RGW59378973811515857', account_name='new_account_name', + email='new_email@example.com') + + mock_modify_account.assert_called_with('RGW59378973811515857', 'new_account_name', + 'new_email@example.com') + + self.assertEqual(result, mock_return_value) + + @patch.object(RgwAccounts, 'set_quota') + def test_set_quota(self, mock_set_quota): + mock_return_value = { + "id": "RGW11111111111111111", + "tenant": "", + "name": "Account1", + "email": "account1@ceph.com", + "quota": { + "enabled": False, + "check_on_raw": False, + "max_size": 10737418240, + "max_size_kb": 10485760, + "max_objects": 1000000 + }, + "bucket_quota": { + "enabled": False, + "check_on_raw": False, + "max_size": -1, + "max_size_kb": 0, + "max_objects": 1000000 + }, + "max_users": 1000, + "max_roles": 1000, + "max_groups": 1000, + "max_buckets": 1000, + "max_access_keys": 4 + } + + mock_set_quota.return_value = mock_return_value + + controller = RgwUserAccountsController() + result = controller.set_quota(quota_type='account', account_id='RGW11111111111111111', + max_size='10GB', max_objects='1000') + + mock_set_quota.assert_called_with('account', 'RGW11111111111111111', '10GB', '1000') + + self.assertEqual(result, mock_return_value) + + @patch.object(RgwAccounts, 'set_quota_status') + def test_set_quota_status(self, mock_set_quota_status): + mock_return_value = { + "id": "RGW11111111111111111", + "tenant": "", + "name": "Account1", + "email": "account1@ceph.com", + "quota": { + "enabled": True, + "check_on_raw": False, + "max_size": 10737418240, + "max_size_kb": 10485760, + "max_objects": 1000000 + }, + "bucket_quota": { + "enabled": False, + "check_on_raw": False, + "max_size": -1, + "max_size_kb": 0, + "max_objects": 1000000 + }, + "max_users": 1000, + "max_roles": 1000, + "max_groups": 1000, + "max_buckets": 1000, + "max_access_keys": 4 + } + + mock_set_quota_status.return_value = mock_return_value + + controller = RgwUserAccountsController() + result = controller.set_quota_status(quota_type='account', + account_id='RGW11111111111111111', + quota_status='enabled') + + mock_set_quota_status.assert_called_with('account', 'RGW11111111111111111', 'enabled') + + self.assertEqual(result, mock_return_value) -- 2.39.5