diff --git a/ocs_ci/ocs/bucket_utils.py b/ocs_ci/ocs/bucket_utils.py index a0cd4762e8e..e7dd1349a62 100644 --- a/ocs_ci/ocs/bucket_utils.py +++ b/ocs_ci/ocs/bucket_utils.py @@ -88,6 +88,61 @@ def craft_s3_command(cmd, mcg_obj=None, api=False, signed_request_creds=None): return f"{base_command}{cmd}{string_wrapper}" +def craft_sts_command(cmd, mcg_obj=None, signed_request_creds=None): + """ + Crafts the AWS CLI STS command including the + login credentials and command to be ran + + Args: + cmd: The AWSCLI STS command to run + mcg_obj: An MCG class instance + signed_request_creds: a dictionary containing AWS S3 creds for a signed request + + Returns: + str: The crafted command, ready to be executed on the pod + + """ + + no_ssl = ( + "--no-verify-ssl" + if signed_request_creds and signed_request_creds.get("ssl") is False + else "" + ) + if mcg_obj: + if mcg_obj.region: + region = f"AWS_DEFAULT_REGION={mcg_obj.region} " + else: + region = "" + base_command = ( + f'sh -c "AWS_CA_BUNDLE={constants.SERVICE_CA_CRT_AWSCLI_PATH} ' + f"AWS_ACCESS_KEY_ID={mcg_obj.access_key_id} " + f"AWS_SECRET_ACCESS_KEY={mcg_obj.access_key} " + f"{region}" + f"aws sts " + f"--endpoint={mcg_obj.sts_internal_endpoint} " + ) + string_wrapper = '"' + elif signed_request_creds: + if signed_request_creds.get("region"): + region = f'AWS_DEFAULT_REGION={signed_request_creds.get("region")} ' + else: + region = "" + base_command = ( + f'sh -c "AWS_ACCESS_KEY_ID={signed_request_creds.get("access_key_id")} ' + f'AWS_SECRET_ACCESS_KEY={signed_request_creds.get("access_key")} ' + f"{region}" + f"aws sts " + f'--endpoint={signed_request_creds.get("endpoint")} ' + f"{no_ssl} " + ) + string_wrapper = '"' + else: + base_command = "aws sts --no-sign-request " + string_wrapper = "" + + return f"{base_command}{cmd}{string_wrapper}" + + def craft_s3cmd_command(cmd, mcg_obj=None, signed_request_creds=None): """ Crafts the S3cmd CLI command including the @@ -2739,3 +2794,118 @@ def map_objects_to_owners(mcg_obj, bucket_name, prefix=""): """ response = s3_list_objects_v2(mcg_obj, bucket_name, prefix=prefix, fetch_owner=True) return {item["Key"]: item["Owner"] for item in response.get("Contents", [])} + + +def sts_assume_role( + pod_obj, + role_name, + access_key_id_assumed_user, + role_session_name=None, + mcg_obj=None, + signed_request_creds=None, +): + """ + Aws s3 assume role of an User + + Args: + role_name (str): Role name of a role attached to the assumed user + access_key_id_assumed_user (str): Access key id of the assumed user + mcg_obj (MCG): MCG object + signed_request_creds (dict): a dictionary containing AWS S3 creds for a signed request + + Returns: + Dict: Representing the output of the command which on successful execution + consists of new credentials + + """ + if not role_session_name: + role_session_name = f"role-session-{uuid4().hex}" + cmd = ( + f"assume-role --role-arn arn:aws:sts::{access_key_id_assumed_user}:role/{role_name} " + f"--role-session-name {role_session_name}" + ) + cmd = craft_sts_command( + cmd, mcg_obj=mcg_obj, signed_request_creds=signed_request_creds + ) + return pod_obj.exec_cmd_on_pod(command=cmd) + + +def s3_create_bucket(s3_obj, bucket_name, s3_client=None): + """ + AWS s3 create bucket + + Args: + s3_obj (MCG): MCG object + bucket_name (str): Name of the bucket + s3_client (S3.Client): Any S3 client resource + + """ + if s3_client: + s3_client.create_bucket(Bucket=bucket_name) + else: + s3_obj.s3_resource.create_bucket(Bucket=bucket_name) + + +def s3_delete_bucket(s3_obj, bucket_name, s3_client=None): + """ + AWS s3 delete bucket + + Args: + s3_obj (MCG): MCG object + bucket_name (str): Name of the bucket + s3_client (S3.Client): Any s3 client resource + + """ + if s3_client: + s3_client.delete_bucket(Bucket=bucket_name) + else: + s3_obj.s3_client.delete_bucket(Bucket=bucket_name) + + +def s3_list_buckets(s3_obj, s3_client=None): + """ + AWS S3 list buckets + + Args: + s3_obj (MCG): MCG object + s3_client (S3.Client): Any s3 client resource + + Returns: + List: List of buckets + + """ + + if s3_client: + response = s3_client.list_buckets() + else: + response = s3_obj.s3_client.list_buckets() + + return [bucket["Name"] for bucket in response["Buckets"]] + + +def create_s3client_from_assume_role_creds(mcg_obj, assume_role_creds): + """ + Create s3client from the creds passed and endpoint fetched from MCG object + + Args: + mcg_obj (MCG): MCG object + creds (Dict): Dictionary representing the credentials + + Returns: + Boto3 s3 client object + + """ + + assumed_access_key_id = assume_role_creds.get("Credentials").get("AccessKeyId") + assumed_access_key = assume_role_creds.get("Credentials").get("SecretAccessKey") + assumed_session_token = assume_role_creds.get("Credentials").get("SessionToken") + + assumed_s3_resource = boto3.resource( + "s3", + verify=retrieve_verification_mode(), + endpoint_url=mcg_obj.s3_endpoint, + aws_access_key_id=assumed_access_key_id, + aws_secret_access_key=assumed_access_key, + aws_session_token=assumed_session_token, + ) + return assumed_s3_resource.meta.client diff --git a/ocs_ci/ocs/constants.py b/ocs_ci/ocs/constants.py index b6fb8a5bf90..766c6a115ec 100644 --- a/ocs_ci/ocs/constants.py +++ b/ocs_ci/ocs/constants.py @@ -146,6 +146,9 @@ BUCKET_LOG_REPLICATOR_DELAY_PARAM = CONFIG_JS_PREFIX + "BUCKET_LOG_REPLICATOR_DELAY" LIFECYCLE_INTERVAL_PARAM = CONFIG_JS_PREFIX + "LIFECYCLE_INTERVAL" BUCKET_LOG_UPLOADER_DELAY_PARAM = CONFIG_JS_PREFIX + "BUCKET_LOG_UPLOADER_DELAY" +STS_DEFAULT_SESSION_TOKEN_EXPIRY_MS = ( + CONFIG_JS_PREFIX + "STS_DEFAULT_SESSION_TOKEN_EXPIRY_MS" +) # Resources / Kinds CEPHFILESYSTEM = "CephFileSystem" @@ -235,6 +238,7 @@ OPERATOR_KIND = "Operator" DRIVER = "Driver" IMAGECONTENTSOURCEPOLICY_KIND = "ImageContentSourcePolicy" +NOOBAA_ACCOUNT = "NoobaaAccount" # Provisioners AWS_EFS_PROVISIONER = "openshift.org/aws-efs" diff --git a/ocs_ci/ocs/resources/bucket_policy.py b/ocs_ci/ocs/resources/bucket_policy.py index 15089ce365f..dd0275044e4 100644 --- a/ocs_ci/ocs/resources/bucket_policy.py +++ b/ocs_ci/ocs/resources/bucket_policy.py @@ -57,6 +57,7 @@ def __init__( mcg, name, email, + allow_bucket_creation=True, buckets=None, admin_access=False, s3_access=True, @@ -79,6 +80,7 @@ def __init__( """ self.account_name = name self.email_id = email + self.mcg = mcg if buckets: params_dict = { "email": email, @@ -99,6 +101,9 @@ def __init__( "s3_access": s3_access, "default_pool": backingstore_name, } + + if not allow_bucket_creation: + params_dict["allow_bucket_creation"] = allow_bucket_creation ( params_dict if (version.get_semantic_ocs_version_from_config() < version.VERSION_4_9) @@ -128,6 +133,19 @@ def __init__( aws_secret_access_key=self.access_key, ) + def delete_account(self): + """ + Delete the noobaa account + + Returns: + Response for noobaa `delete_account` api call + + """ + params_dict = {"email": self.email_id} + return self.mcg.send_rpc_query( + api="account_api", method="delete_account", params=params_dict + ) + def gen_bucket_policy( user_list, diff --git a/ocs_ci/ocs/resources/mcg.py b/ocs_ci/ocs/resources/mcg.py index 2f5fcf0c7d9..83db3060df4 100644 --- a/ocs_ci/ocs/resources/mcg.py +++ b/ocs_ci/ocs/resources/mcg.py @@ -26,7 +26,10 @@ UnsupportedPlatformError, ) from ocs_ci.ocs.ocp import OCP -from ocs_ci.ocs.resources.pod import get_pods_having_label, Pod +from ocs_ci.ocs.resources.pod import ( + get_pods_having_label, + Pod, +) from ocs_ci.utility import templating, version from ocs_ci.utility.retry import retry from ocs_ci.utility.utils import ( @@ -119,6 +122,20 @@ def __init__(self, *args, **kwargs): .get("serviceS3") .get("internalDNS")[0] ) + self.sts_endpoint = ( + get_noobaa.get("items")[0] + .get("status") + .get("services") + .get("serviceSts") + .get("externalDNS")[0] + ) + self.sts_internal_endpoint = ( + get_noobaa.get("items")[0] + .get("status") + .get("services") + .get("serviceSts") + .get("internalDNS")[0] + ) self.mgmt_endpoint = ( get_noobaa.get("items")[0] .get("status") @@ -959,6 +976,37 @@ def reset_core_pod(self): ) wait_for_resource_state(self.core_pod, constants.STATUS_RUNNING) + def reset_endpoint_pods(self): + """ + Delete the noobaa endpoint pod and wait for it to come up again + + """ + + from ocs_ci.ocs.resources.pod import wait_for_pods_by_label_count + + endpoint_pods = [ + Pod(**pod_data) + for pod_data in get_pods_having_label( + constants.NOOBAA_ENDPOINT_POD_LABEL, self.namespace + ) + ] + for pod in endpoint_pods: + pod.delete(wait=True) + + wait_for_pods_by_label_count( + label=constants.NOOBAA_ENDPOINT_POD_LABEL, + exptected_count=len(endpoint_pods), + ) + + endpoint_pods = [ + Pod(**pod_data) + for pod_data in get_pods_having_label( + constants.NOOBAA_ENDPOINT_POD_LABEL, self.namespace + ) + ] + for pod in endpoint_pods: + wait_for_resource_state(pod, constants.STATUS_RUNNING) + def get_noobaa_admin_credentials_from_secret(self): """ Get the NooBaa admin credentials from the OCP secret @@ -1094,3 +1142,31 @@ def get_default_bc_backingstore_name(self): .get("tiers")[0] .get("backingStores")[0] ) + + def assign_sts_role(self, account_id, role_config): + """ + Assign STS role to a Noobaa account + + Args: + account_id (str): Name/email/id of the noobaa account + role_config (dict): Role config consisting of role name, role policy etc + + """ + + cmd = f"sts assign-role --email {account_id} --role_config '{str(role_config)}'" + self.exec_mcg_cmd( + cmd=cmd, + ) + + def remove_sts_role(self, account_id): + """ + Remove STS role from a Noobaa account + + Args: + account_id (str): Name/email/id of the noobaa account + + """ + cmd = f"sts remove-role --email {account_id}" + self.exec_mcg_cmd( + cmd=cmd, + ) diff --git a/ocs_ci/utility/retry.py b/ocs_ci/utility/retry.py index 89d3175eb9a..82f0e6e2699 100644 --- a/ocs_ci/utility/retry.py +++ b/ocs_ci/utility/retry.py @@ -74,3 +74,55 @@ def wrapper(*args, **kwargs): return wrapper return decorator + + +def retry_until_exception( + exception_to_check, tries=4, delay=3, backoff=2, text_in_exception=None, func=None +): + """ + Retry calling the decorated function using exponential backoff until the exception occurs. + + Args: + exception_to_check: the exception to check. may be a tuple of exceptions to check + tries: number of times to try (not retry) before giving up + delay: initial delay between retries in seconds + backoff: backoff multiplier e.g. value of 2 will double the delay each retry + text_in_exception: Retry only when text_in_exception is in the text of exception + func: function for garbage collector + """ + + def deco_retry(f): + @wraps(f) + def f_retry(*args, **kwargs): + mtries, mdelay = tries, delay + while mtries > 1: + try: + if func is not None: + func() + f(*args, **kwargs) + except exception_to_check as e: + if text_in_exception: + if text_in_exception in str(e): + logger.debug( + f"Text: {text_in_exception} found in exception: {e}" + ) + return True + else: + logger.debug( + f"Text: {text_in_exception} not found in exception: {e}" + ) + raise + else: + logger.warning( + f"{exception_to_check} didn't seem to occur, Retrying in {mdelay} seconds..." + ) + time.sleep(mdelay) + mtries -= 1 + mdelay *= backoff + if func is not None: + func() + return False + + return f_retry + + return deco_retry diff --git a/tests/conftest.py b/tests/conftest.py index 6c901f82686..de5c0ef231d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7357,6 +7357,115 @@ def finalizer(): return add_env_vars_to_noobaa_core_implementation +@pytest.fixture(scope="class") +def add_env_vars_to_noobaa_endpoint_class(request, mcg_obj_session): + """ + Class-scoped fixture for adding env vars to the noobaa-core sts + + """ + return add_env_vars_to_noobaa_endpoint_fixture(request, mcg_obj_session) + + +def add_env_vars_to_noobaa_endpoint_fixture(request, mcg_obj_session): + """ + Add env vars to the noobaa endpoint + """ + dep_obj = OCP( + kind=constants.DEPLOYMENT, namespace=ocsci_config.ENV_DATA["cluster_namespace"] + ) + yaml_path_to_env_variables = "/spec/template/spec/containers/0/env" + op_template_dict = {"op": "", "path": "", "value": {"name": "", "value": ""}} + + added_env_vars = [] + + def add_env_vars_to_noobaa_endpoint_implementation(new_env_vars_touples): + """ + Implementation of add_env_vars_to_noobaa_core_fixture() + + Args: + new_env_vars_touples (list): A list of touples, each containing the env var name and + value to be added to the noobaa-core sts + i.e. [("env_var_name_1", "env_var_value_1"), ("env_var_name_2", "env_var_value_2")] + + """ + + nb_endpoint_dep = dep_obj.get( + resource_name=constants.NOOBAA_ENDPOINT_DEPLOYMENT + ) + dep_env_vars = nb_endpoint_dep["spec"]["template"]["spec"]["containers"][0][ + "env" + ] + dep_env_vars = [env_var_in_dep["name"] for env_var_in_dep in dep_env_vars] + + patch_ops = [] + + for env_var, value in new_env_vars_touples: + if env_var in dep_env_vars: + log.warning(f"Env var {env_var} already exists in the noobaa-core sts") + continue + + # Copy and modify the template to create the required dict for the first addition + add_env_var_op = copy.deepcopy(op_template_dict) + add_env_var_op["op"] = "add" + add_env_var_op["path"] = f"{yaml_path_to_env_variables}/-" + add_env_var_op["value"] = {"name": env_var, "value": str(value)} + + patch_ops.append(copy.deepcopy(add_env_var_op)) + added_env_vars.append(env_var) + + log.info( + f"Adding following new env vars to the noobaa-core sts: {added_env_vars}" + ) + dep_obj.patch( + resource_name=constants.NOOBAA_ENDPOINT_DEPLOYMENT, + params=json.dumps(patch_ops), + format_type="json", + ) + + # Reset noobaa endpoint pods + mcg_obj_session.reset_endpoint_pods() + + def finalizer(): + """ + Remove any env vars that were added to the noobaa-core sts + + """ + log.info("Removing the added env vars from the noobaa-core statefulset:") + + # Adjust the template for removal ops + remove_env_var_op = copy.deepcopy(op_template_dict) + remove_env_var_op["op"] = "remove" + remove_env_var_op["path"] = "" + del remove_env_var_op["value"] + + for target_env_var in added_env_vars: + # Fetch the target's index from the noobaa-core statefulset + nb_endpoint_dep = dep_obj.get( + resource_name=constants.NOOBAA_ENDPOINT_DEPLOYMENT + ) + env_vars_in_dep = nb_endpoint_dep["spec"]["template"]["spec"]["containers"][ + 0 + ]["env"] + env_vars_names_in_dep = [ + env_var_in_dep["name"] for env_var_in_dep in env_vars_in_dep + ] + target_index = env_vars_names_in_dep.index(target_env_var) + remove_env_var_op["path"] = f"{yaml_path_to_env_variables}/{target_index}" + + # Patch the noobaa-core sts to remove the env var + dep_obj.patch( + resource_name=constants.NOOBAA_ENDPOINT_DEPLOYMENT, + params=json.dumps([remove_env_var_op]), + format_type="json", + ) + + # Reset noobaa endpoint pods + mcg_obj_session.reset_endpoint_pods() + + request.addfinalizer(finalizer) + return add_env_vars_to_noobaa_endpoint_implementation + + @pytest.fixture() def logwriter_cephfs_many_pvc_factory(request, pvc_factory): return logwriter_cephfs_many_pvc(request, pvc_factory) @@ -8510,3 +8619,42 @@ def cleanup(): @pytest.fixture(scope="session") def virtctl_binary(): get_virtctl_tool() + + +@pytest.fixture() +def nb_assign_user_role_fixture(request, mcg_obj_session): + + email = None + + def factory(user_email, role_name, principal="*"): + """ + Assign assume role policy to the user + + Args: + user_email (str): Name/id/email of the user + role_name (str): Name of the role + + """ + nonlocal email + email = user_email + noobaa_assume_role_policy = ( + f'{{"role_name": "{role_name}","assume_role_policy": ' + f'{{"version": "2024-07-16","statement": [{{"action": ["sts:AssumeRole"],' + f'"effect": "allow","principal": ["{principal}"]}}]}}}}' + ) + + mcg_obj_session.assign_sts_role(user_email, noobaa_assume_role_policy) + + def teardown(): + """ + Remove role from the user + + """ + try: + mcg_obj_session.remove_sts_role(email) + except CommandFailed as e: + if "No such account email" not in e.args[0]: + raise + + request.addfinalizer(teardown) + return factory diff --git a/tests/functional/object/mcg/test_sts_client.py b/tests/functional/object/mcg/test_sts_client.py new file mode 100644 index 00000000000..73ff8685dd9 --- /dev/null +++ b/tests/functional/object/mcg/test_sts_client.py @@ -0,0 +1,180 @@ +import pytest +import logging + +from botocore.exceptions import ClientError + +from ocs_ci.ocs.utils import retry +from uuid import uuid4 + +from ocs_ci.ocs.bucket_utils import ( + sts_assume_role, + s3_create_bucket, + s3_delete_bucket, + s3_list_buckets, + create_s3client_from_assume_role_creds, +) +from ocs_ci.ocs.exceptions import CommandFailed +from ocs_ci.utility.retry import retry_until_exception +from ocs_ci.ocs import constants +from ocs_ci.framework.pytest_customization.marks import mcg, red_squad, tier2 + +logger = logging.getLogger(__name__) + + +@pytest.fixture() +def new_bucket(request, mcg_obj_session): + """ + Create new bucket using s3 + + """ + buckets_created = [] + + def factory(bucket_name, s3_client=None): + s3_create_bucket(mcg_obj_session, bucket_name, s3_client) + logger.info(f"Created new-bucket {bucket_name}") + bucket_obj = { + "s3client": s3_client, + "bucket": bucket_name, + "mcg": mcg_obj_session, + } + buckets_created.append(bucket_obj) + + def finalizer(): + """ + Cleanup the created bucket + + """ + for bucket in buckets_created: + logger.info(f"Deleting bucket {bucket.get('bucket')}") + s3_delete_bucket( + bucket.get("mcg"), bucket.get("bucket"), bucket.get("s3client") + ) + logger.info(f"Deleted the bucket {bucket}") + + request.addfinalizer(finalizer) + return factory + + +@mcg +@red_squad +@tier2 +class TestSTSClient: + def test_sts_assume_role( + self, + mcg_obj_session, + awscli_pod_session, + mcg_account_factory, + nb_assign_user_role_fixture, + new_bucket, + add_env_vars_to_noobaa_core_class, + add_env_vars_to_noobaa_endpoint_class, + ): + """ + Test sts support for Noobaa clients. + As part of this, we test the following: + * Assign role to an user + * Assume the role of an user + * Perform IO by assuming of the role of another user + * Remove the role from the user + * Try to assume the role + + """ + # change sts token expire time to 10 minute + add_env_vars_to_noobaa_core_class( + [(constants.STS_DEFAULT_SESSION_TOKEN_EXPIRY_MS, 600000)] + ) + logger.info("Changed the sts token expiration time to 10 minutes") + + add_env_vars_to_noobaa_endpoint_class( + [(constants.STS_DEFAULT_SESSION_TOKEN_EXPIRY_MS, 600000)] + ) + logger.info("Changed the sts token expiration time to 10 minutes") + + # create a bucket using noobaa admin creds + bucket_1 = "first-bucket" + retry(ClientError, tries=5, delay=5)(new_bucket)(bucket_name=bucket_1) + logger.info(f"Created bucket {bucket_1}") + + # create a noobaa account + user_1 = f"user-{uuid4().hex}" + nb_account_1 = mcg_account_factory( + name=user_1, + ) + signed_request_creds = { + "region": mcg_obj_session.region, + "endpoint": mcg_obj_session.sts_internal_endpoint, + "access_key_id": nb_account_1.get("access_key_id"), + "access_key": nb_account_1.get("access_key"), + "ssl": False, + } + + # create another noobaa account + user_2 = f"user-{uuid4().hex}" + nb_account_2 = mcg_account_factory( + name=user_2, + allow_bucket_create=False, + ) + nb_user_access_key_id = nb_account_2.get("access_key_id") + logger.info(f"Created new user '{user_2}'") + + # assign assume role policy to the user-1 + role_name = "user-1-assume-role" + nb_assign_user_role_fixture(user_2, role_name, principal=user_1) + logger.info(f"Assigned the assume role policy to the user {user_2}") + + # noobaa admin assumes the above role + creds_generated = sts_assume_role( + awscli_pod_session, + role_name, + nb_user_access_key_id, + signed_request_creds=signed_request_creds, + ) + assumed_user_s3client = create_s3client_from_assume_role_creds( + mcg_obj_session, creds_generated + ) + + # perform io to validate the role assumption + bucket_2 = "second-bucket" + try: + new_bucket(bucket_2, assumed_user_s3client) + assert ( + False + ), "Bucket was created even though assumed role user doesnt have ability to create new bucket" + except Exception as err: + if "AccessDenied" not in err.args[0]: + raise + logger.info("Bucket creation failed as expected") + logger.info( + f"Listing buckets with the assumed user: {s3_list_buckets(mcg_obj_session, assumed_user_s3client)}" + ) + + # remove the role from the user + mcg_obj_session.remove_sts_role(user_2) + logger.info(f"Removed the assume role policy from the user {user_2}") + + # try to assume role after the assume role policy is removed + assert retry_until_exception( + exception_to_check=CommandFailed, + tries=15, + delay=60, + backoff=1, + text_in_exception="An error occurred (Unknown) when calling the AssumeRole operation: Unknown", + )(sts_assume_role)( + awscli_pod_session, + role_name, + nb_user_access_key_id, + signed_request_creds=signed_request_creds, + ), "AssumeRole operation expected to fail but it seems to be succeeding even after several tries" + + # perform io to validate the old token is no longer valid + assert retry_until_exception( + exception_to_check=ClientError, + tries=20, + delay=60, + backoff=1, + text_in_exception="ExpiredToken", + )(s3_list_buckets)( + mcg_obj_session, + assumed_user_s3client, + ), "Token doesn't seem to have expired." + logger.info("Token is expired as expected.")