From b26ed18c71389e2975ce8ab322bcc18f237f14c4 Mon Sep 17 00:00:00 2001 From: Nareshp1 <44296460+Nareshp1@users.noreply.github.com> Date: Sun, 20 Jul 2025 10:23:37 -0400 Subject: [PATCH 01/43] fix(secrets): Move changes from old api repo to monorepo branch --- api/requirements.txt | 5 +- api/src/app/api/v1/users.py | 84 +++++++++- api/src/app/cloud/aws_creds.py | 172 +++++++++++++++++++++ api/src/app/cloud/base_creds.py | 31 ++++ api/src/app/cloud/creds_factory.py | 48 ++++++ api/src/app/crud/crud_secrets.py | 64 ++++++++ api/src/app/schemas/creds_verify_schema.py | 19 +++ api/src/app/schemas/secret_schema.py | 50 +++++- 8 files changed, 470 insertions(+), 3 deletions(-) create mode 100644 api/src/app/cloud/aws_creds.py create mode 100644 api/src/app/cloud/base_creds.py create mode 100644 api/src/app/cloud/creds_factory.py create mode 100644 api/src/app/crud/crud_secrets.py create mode 100644 api/src/app/schemas/creds_verify_schema.py diff --git a/api/requirements.txt b/api/requirements.txt index 3d8b83b0..f62fe2e7 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -21,4 +21,7 @@ argon2-cffi>=23.1.0 # Async Jobs arq>=0.26.3 aiofiles>=24.1.0 -tenacity>=9.1.2 \ No newline at end of file +tenacity>=9.1.2 + +# Provider SDKs +boto3>=1.38.0 \ No newline at end of file diff --git a/api/src/app/api/v1/users.py b/api/src/app/api/v1/users.py index f86d5c34..6bd45ce8 100644 --- a/api/src/app/api/v1/users.py +++ b/api/src/app/api/v1/users.py @@ -3,18 +3,24 @@ from fastapi import APIRouter, Depends, HTTPException, status from fastapi.responses import JSONResponse +from pydantic import ValidationError from sqlalchemy import select from sqlalchemy.ext.asyncio.session import AsyncSession +from src.app.cloud.creds_factory import CredsFactory +from src.app.schemas.creds_verify_schema import CredsVerifySchema + from ...core.auth.auth import get_current_user from ...core.config import settings from ...core.db.database import async_get_db +from ...crud.crud_secrets import get_user_secrets, upsert_user_secrets from ...crud.crud_users import get_user_by_id, update_user_password from ...models.secret_model import SecretModel from ...models.user_model import UserModel from ...schemas.message_schema import ( AWSUpdateSecretMessageSchema, AzureUpdateSecretMessageSchema, + MessageSchema, UpdatePasswordMessageSchema, ) from ...schemas.secret_schema import ( @@ -116,7 +122,7 @@ async def update_password( @router.get("/me/secrets") -async def get_user_secrets( +async def fetch_user_secrets( current_user: UserModel = Depends(get_current_user), # noqa: B008 db: AsyncSession = Depends(async_get_db), # noqa: B008 ) -> UserSecretResponseSchema: @@ -170,6 +176,82 @@ async def get_user_secrets( ) +@router.post("/me/secrets") +async def update_user_secrets( + creds: CredsVerifySchema, + current_user: UserModel = Depends(get_current_user), # noqa: B008 + db: AsyncSession = Depends(async_get_db), # noqa: B008 +) -> MessageSchema: + """Update the current user's secrets. + + Args: + ---- + creds (CredsVerifySchema): The provider credentials to store. + current_user (UserModel): The authenticated user. + db (Session): Database connection. + + Returns: + ------- + MessageSchema: Status message of updating user secrets. + + """ + # Fetch secrets from the database + secrets = await get_user_secrets(db, current_user.id) + if not secrets: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="User secrets record not found", + ) + + # Verify credentials are valid before storing + try: + creds_obj = CredsFactory.create_creds_verification( + provider=creds.provider, credentials=creds.credentials + ) + except ValueError as e: + # Handles missing credential fields in request + raise HTTPException(status_code=422, detail=str(e)) from None + except ValidationError as e: + # Handles Pydantic schema validation errors (e.g., bad format/length of credentials) + error_msg = e.errors()[0]["msg"].split(", ", 1)[-1] + raise HTTPException(status_code=422, detail=error_msg) from None + + verified, msg = creds_obj.verify_creds() + + if not verified: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=msg.message, + ) + + # Encrypt the AWS credentials using the user's public key + if not current_user.public_key: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="User encryption keys not set up. Please register a new account.", + ) + + # Convert provided credentials to dictionary for encryption + user_creds = creds_obj.get_user_creds() + + # Encrypt with the user's public key + encrypted_data = encrypt_with_public_key( + data=user_creds, public_key_b64=current_user.public_key + ) + + # Update the secrets with encrypted values + secrets = creds_obj.update_secret_schema( + secrets=secrets, encrypted_data=encrypted_data + ) + + # Add new secrets to database + await upsert_user_secrets(db, secrets, current_user.id) + + return MessageSchema( + message=f"{str(creds.provider.value).upper()} credentials successfully verified and updated" + ) + + @router.post("/me/secrets/aws") async def update_aws_secrets( aws_secrets: AWSSecrets, diff --git a/api/src/app/cloud/aws_creds.py b/api/src/app/cloud/aws_creds.py new file mode 100644 index 00000000..2382d7a5 --- /dev/null +++ b/api/src/app/cloud/aws_creds.py @@ -0,0 +1,172 @@ +import logging +from datetime import UTC, datetime +from typing import Any, List, Tuple + +import boto3 +from botocore.exceptions import ClientError + +from src.app.schemas.message_schema import MessageSchema +from src.app.schemas.secret_schema import AWSSecrets, SecretSchema + +from .base_creds import AbstractBaseCreds + +# Configure logging +logger = logging.getLogger(__name__) + + +class AWSCreds(AbstractBaseCreds): + """Credential verification for AWS.""" + + credentials: AWSSecrets + + def __init__(self, credentials: dict[str, Any]) -> None: + """Initialize AWS credentials verification object.""" + # Check for missing fields + required_fields = ["aws_access_key", "aws_secret_key"] + if not all(field in credentials for field in required_fields): + msg = "Partial credentials or no credentials provided. Please ensure you are providing proper AWS credentials." + raise ValueError(msg) + + self.credentials = AWSSecrets.model_validate(credentials) + + def get_user_creds(self) -> dict[str, str]: + """Convert user AWS secrets to dictionary for encryption.""" + return { + "aws_access_key": self.credentials.aws_access_key, + "aws_secret_key": self.credentials.aws_secret_key, + } + + def update_secret_schema( + self, secrets: SecretSchema, encrypted_data: dict[str, str] + ) -> SecretSchema: + """Update user secrets schema with newly encrypted secrets.""" + secrets.aws_access_key = encrypted_data["aws_access_key"] + secrets.aws_secret_key = encrypted_data["aws_secret_key"] + secrets.aws_created_at = datetime.now(UTC) + return secrets + + def verify_creds(self) -> Tuple[bool, MessageSchema]: + """Verify credentials authenticate to an AWS account.""" + try: + # --- Step 1: Basic Authentication with STS --- + # Created shared session for authenticaion and IAM permission check + session = boto3.Session( + aws_access_key_id=self.credentials.aws_access_key, + aws_secret_access_key=self.credentials.aws_secret_key, + ) + client = session.client("sts") + caller_identity = ( + client.get_caller_identity() + ) # will raise an error if not valid + caller_arn = caller_identity["Arn"] + logger.info( + "AWS credentials successfully authenticated for ARN: %s", caller_arn + ) + + if caller_arn.endswith( + ":root" + ): # If root access key credentials are used, skip permissions check as root user has all permissions + return ( + True, + MessageSchema( + message="AWS credentials authenticated and all required permissions are present." + ), + ) + + # --- Step 2: Simulate permissions for a sample of minimum critical actions --- + iam_client = session.client("iam") + + actions_to_test = [ + # For Instance + "ec2:RunInstances", + "ec2:TerminateInstances", + "ec2:DescribeInstances", + # For Vpc + "ec2:CreateVpc", + "ec2:DeleteVpc", + "ec2:DescribeVpcs", + # For Subnet + "ec2:CreateSubnet", + "ec2:DeleteSubnet", + "ec2:DescribeSubnets", + # For InternetGateway + "ec2:CreateInternetGateway", + "ec2:DeleteInternetGateway", + "ec2:AttachInternetGateway", + "ec2:DetachInternetGateway", + # For Eip and NatGateway + "ec2:AllocateAddress", # Create EIP + "ec2:ReleaseAddress", # Delete EIP + "ec2:AssociateAddress", + "ec2:CreateNatGateway", + "ec2:DeleteNatGateway", + # For KeyPair + "ec2:CreateKeyPair", + "ec2:DeleteKeyPair", + # For SecurityGroup and SecurityGroupRule + "ec2:CreateSecurityGroup", + "ec2:DeleteSecurityGroup", + "ec2:AuthorizeSecurityGroupIngress", + "ec2:RevokeSecurityGroupIngress", + "ec2:AuthorizeSecurityGroupEgress", + "ec2:RevokeSecurityGroupEgress", + # For RouteTable, Route, and RouteTableAssociation + "ec2:CreateRouteTable", + "ec2:DeleteRouteTable", + "ec2:CreateRoute", + "ec2:DeleteRoute", + "ec2:AssociateRouteTable", + "ec2:DisassociateRouteTable", + # For Transit Gateway --- + "ec2:CreateTransitGateway", + "ec2:DeleteTransitGateway", + "ec2:CreateTransitGatewayVpcAttachment", + "ec2:DeleteTransitGatewayVpcAttachment", + "ec2:CreateTransitGatewayRoute", + "ec2:DeleteTransitGatewayRoute", + "ec2:DescribeTransitGateways", + "ec2:DescribeTransitGatewayVpcAttachments", + ] + + simulation_results = iam_client.simulate_principal_policy( + PolicySourceArn=caller_arn, ActionNames=actions_to_test + ) + + # --- Step 3: Evaluate the simulation results --- + denied_actions: List[str] = [] + for result in simulation_results["EvaluationResults"]: + if result["EvalDecision"] != "allowed": + denied_actions.append(result["EvalActionName"]) + + if not denied_actions: + logger.info( + "All simulated actions were allowed for ARN: %s", caller_arn + ) + return ( + True, + MessageSchema( + message="AWS credentials authenticated and all required permissions are present." + ), + ) + + error_message = f"Authentication succeeded, but the user/group is missing required permissions. The following actions were denied: {', '.join(denied_actions)}" + logger.error(error_message) + return ( + False, + MessageSchema( + message=f"Insufficient permissions for your AWS account user/group. Please ensure the following permissions are added: {', '.join(denied_actions)}" + ), + ) + except ClientError as e: + error_code = e.response["Error"]["Code"] + if error_code in ("InvalidClientTokenId", "SignatureDoesNotMatch"): + message = "AWS credentials could not be authenticated. Please ensure you are providing credentials that are linked to a valid AWS account." + elif error_code == "AccessDenied": + message = "AWS credentials are valid, but lack permissions to perform the permissions verification. Please ensure you give your AWS account user/group has the iam:SimulatePrincipalPolicy permission attached to a policy." + else: + message = e.response["Error"]["Message"] + logger.error("AWS verification failed: %s", message) + return ( + False, + MessageSchema(message=message), + ) diff --git a/api/src/app/cloud/base_creds.py b/api/src/app/cloud/base_creds.py new file mode 100644 index 00000000..d5430b2f --- /dev/null +++ b/api/src/app/cloud/base_creds.py @@ -0,0 +1,31 @@ +from abc import ABC, abstractmethod +from typing import Any, Tuple + +from src.app.schemas.message_schema import MessageSchema +from src.app.schemas.secret_schema import SecretSchema + + +class AbstractBaseCreds(ABC): + """Abstract class to enforce common credential verification functionality across range cloud providers.""" + + @abstractmethod + def __init__(self, credentials: dict[str, Any]) -> None: + """Expected constructor for all credential subclasses.""" # noqa: D401 + pass + + @abstractmethod + def get_user_creds(self) -> dict[str, str]: + """Convert user secrets to dictionary for encryption.""" + pass + + @abstractmethod + def update_secret_schema( + self, secrets: SecretSchema, encrypted_data: dict[str, str] + ) -> SecretSchema: + """Update user secrets schema with newly encrypted secrets.""" + pass + + @abstractmethod + def verify_creds(self) -> Tuple[bool, MessageSchema]: + """Verify that user provided credentials properly authenticate to a provider account.""" + pass diff --git a/api/src/app/cloud/creds_factory.py b/api/src/app/cloud/creds_factory.py new file mode 100644 index 00000000..5ff7ec89 --- /dev/null +++ b/api/src/app/cloud/creds_factory.py @@ -0,0 +1,48 @@ +import logging +from typing import Any, ClassVar, Type + +from src.app.cloud.aws_creds import AWSCreds +from src.app.cloud.base_creds import AbstractBaseCreds +from src.app.enums.providers import OpenLabsProvider + +# Configure logging +logger = logging.getLogger(__name__) + + +class CredsFactory: + """Create creds objects.""" + + _registry: ClassVar[dict[OpenLabsProvider, Type[AbstractBaseCreds]]] = { + OpenLabsProvider.AWS: AWSCreds, + } + + @classmethod + def create_creds_verification( + cls, provider: OpenLabsProvider, credentials: dict[str, Any] + ) -> AbstractBaseCreds: + """Create creds object. + + **Note:** This function accepts a creation schema as the OpenLabs resource ID is not required + for terraform. + + Args: + ---- + cls (CredsFactory): The CredsFactory class. + provider (OpenLabsProvider): Cloud provider the credentials to verify are for + credentials (dict[str, Any]): User cloud credentials to verify + + Returns: + ------- + AbstractBaseCreds: Creds object that will be used to verify the user cloud credentials provided + + """ + creds_class = cls._registry.get(provider) + + if creds_class is None: + msg = ( + f"Failed to build creds object. Non-existent provider given: {provider}" + ) + logger.error(msg) + raise ValueError(msg) + + return creds_class(credentials=credentials) diff --git a/api/src/app/crud/crud_secrets.py b/api/src/app/crud/crud_secrets.py new file mode 100644 index 00000000..de71f636 --- /dev/null +++ b/api/src/app/crud/crud_secrets.py @@ -0,0 +1,64 @@ +import logging + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.app.models.secret_model import SecretModel + +from ..schemas.secret_schema import SecretSchema + +logger = logging.getLogger(__name__) + + +async def get_user_secrets(db: AsyncSession, user_id: int) -> SecretSchema | None: + """Get user provider secrets. + + Args: + ---- + db (Session): Database connection. + user_id (int): ID of the user requesting data. + + Returns: + ------- + Optional[SecretSchema]: User provider secrets if it exists in the database. + + """ + stmt = select(SecretModel).where(SecretModel.user_id == user_id) + result = await db.execute(stmt) + + if not result: + logger.info( + "Failed to fetch secrets for user: %s. Not found in database!", user_id + ) + return None + + secrets = result.scalars().first() + + return SecretSchema.model_validate(secrets) + + +async def upsert_user_secrets( + db: AsyncSession, secrets: SecretSchema, user_id: int +) -> None: + """Update user provider secrets. + + Args: + ---- + db (Session): Database connection. + user_id (int): ID of the user requesting data. + secrets (SecretSchema): User secrets record containing new secrets to add to database + + Returns: + ------- + None + + """ + db_object_to_merge = SecretModel(user_id=user_id, **secrets.model_dump()) + # 2. Merge the instance into the session. + # SQLAlchemy checks if a record with this primary key exists. + # - If yes, it copies the new data onto the existing record. + # - If no, it stages a new record for insertion. + await db.merge(db_object_to_merge) + # 3. Commit the transaction to save the changes. + # This will execute either an UPDATE or INSERT statement. + await db.commit() diff --git a/api/src/app/schemas/creds_verify_schema.py b/api/src/app/schemas/creds_verify_schema.py new file mode 100644 index 00000000..ed25132b --- /dev/null +++ b/api/src/app/schemas/creds_verify_schema.py @@ -0,0 +1,19 @@ +from typing import Any + +from pydantic import BaseModel, Field + +from src.app.enums.providers import OpenLabsProvider + + +class CredsVerifySchema(BaseModel): + """Base creds object for OpenLabs credential verification.""" + + provider: OpenLabsProvider = Field( + ..., + description="Cloud provider", + examples=[OpenLabsProvider.AWS, OpenLabsProvider.AZURE], + ) + + credentials: dict[str, Any] = Field( + ..., description="Cloud provider credentials to verify" + ) diff --git a/api/src/app/schemas/secret_schema.py b/api/src/app/schemas/secret_schema.py index b6fbdccd..7ec25033 100644 --- a/api/src/app/schemas/secret_schema.py +++ b/api/src/app/schemas/secret_schema.py @@ -1,6 +1,6 @@ from datetime import datetime, timezone -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator class SecretBaseSchema(BaseModel): @@ -66,6 +66,54 @@ class AWSSecrets(BaseModel): description="Secret key for AWS account", ) + @field_validator("aws_access_key") + @classmethod + def validate_access_key(cls, aws_access_key: str) -> str: + """Check AWS access key is correct length. + + Args: + ---- + cls: AWSSecrets object. + aws_access_key (str): AWS access key. + + Returns: + ------- + str: AWS access key. + + """ + access_key_length = 20 + if len(aws_access_key.strip()) == 0: + msg = "Partial credentials or no credentials provided.. Please ensure you are providing proper AWS credentials." + raise ValueError(msg) + if len(aws_access_key.strip()) != access_key_length: + msg = "Invalid credential format. Please ensure your AWS credentials are of proper length." + raise ValueError(msg) + return aws_access_key + + @field_validator("aws_secret_key") + @classmethod + def validate_secret_key(cls, aws_secret_key: str) -> str: + """Check AWS secret key is correct length. + + Args: + ---- + cls: AWSSecrets object. + aws_secret_key (str): AWS secret key. + + Returns: + ------- + str: AWS access key. + + """ + secret_key_length = 40 + if len(aws_secret_key.strip()) == 0: + msg = "Partial credentials or no credentials provided. Please ensure you are providing proper AWS credentials." + raise ValueError(msg) + if len(aws_secret_key.strip()) != secret_key_length: + msg = "Invalid credential format. Please ensure your AWS credentials are of proper length." + raise ValueError(msg) + return aws_secret_key + class AzureSecrets(BaseModel): """Azure secret object for setting secrets on OpenLabs.""" From 7a17d57544991adc5a13367f2906b6c3bed72ddd Mon Sep 17 00:00:00 2001 From: Nareshp1 <44296460+Nareshp1@users.noreply.github.com> Date: Sun, 20 Jul 2025 10:52:48 -0400 Subject: [PATCH 02/43] fix(secrets): Simplified error handling for invalid credential payloads --- api/src/app/api/v1/users.py | 9 +++------ api/src/app/cloud/aws_creds.py | 6 ------ 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/api/src/app/api/v1/users.py b/api/src/app/api/v1/users.py index 6bd45ce8..7a613a5c 100644 --- a/api/src/app/api/v1/users.py +++ b/api/src/app/api/v1/users.py @@ -208,13 +208,10 @@ async def update_user_secrets( creds_obj = CredsFactory.create_creds_verification( provider=creds.provider, credentials=creds.credentials ) - except ValueError as e: - # Handles missing credential fields in request - raise HTTPException(status_code=422, detail=str(e)) from None except ValidationError as e: # Handles Pydantic schema validation errors (e.g., bad format/length of credentials) - error_msg = e.errors()[0]["msg"].split(", ", 1)[-1] - raise HTTPException(status_code=422, detail=error_msg) from None + error_msg = f"Invalid {creds.provider.value.upper()} credentials payload." + raise HTTPException(status_code=422, detail=error_msg) from e verified, msg = creds_obj.verify_creds() @@ -248,7 +245,7 @@ async def update_user_secrets( await upsert_user_secrets(db, secrets, current_user.id) return MessageSchema( - message=f"{str(creds.provider.value).upper()} credentials successfully verified and updated" + message=f"{creds.provider.value.upper()} credentials successfully verified and updated" ) diff --git a/api/src/app/cloud/aws_creds.py b/api/src/app/cloud/aws_creds.py index 2382d7a5..6d14a7c9 100644 --- a/api/src/app/cloud/aws_creds.py +++ b/api/src/app/cloud/aws_creds.py @@ -21,12 +21,6 @@ class AWSCreds(AbstractBaseCreds): def __init__(self, credentials: dict[str, Any]) -> None: """Initialize AWS credentials verification object.""" - # Check for missing fields - required_fields = ["aws_access_key", "aws_secret_key"] - if not all(field in credentials for field in required_fields): - msg = "Partial credentials or no credentials provided. Please ensure you are providing proper AWS credentials." - raise ValueError(msg) - self.credentials = AWSSecrets.model_validate(credentials) def get_user_creds(self) -> dict[str, str]: From 5663551de3eeea1d8d71fb329936d497c286ee7c Mon Sep 17 00:00:00 2001 From: Nareshp1 Date: Sun, 20 Jul 2025 13:17:24 -0400 Subject: [PATCH 03/43] fix(tests): Ignore test coverage files --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 1bfea5db..e522da89 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ testing-out/ # Misc .vscode/ +# Tests +.coverage \ No newline at end of file From 2467078effd6ec56f81bad2831834756d26f02fb Mon Sep 17 00:00:00 2001 From: Nareshp1 Date: Sun, 20 Jul 2025 13:17:55 -0400 Subject: [PATCH 04/43] feat(tests): Adding initial tests for creds verification --- api/src/app/api/v1/users.py | 116 +------------------------- api/tests/common/api/v1/config.py | 9 +- api/tests/common/api/v1/test_users.py | 89 +++++--------------- api/tests/unit/api/v1/test_users.py | 42 ++++++++++ 4 files changed, 69 insertions(+), 187 deletions(-) create mode 100644 api/tests/unit/api/v1/test_users.py diff --git a/api/src/app/api/v1/users.py b/api/src/app/api/v1/users.py index 7a613a5c..cf1e2517 100644 --- a/api/src/app/api/v1/users.py +++ b/api/src/app/api/v1/users.py @@ -211,7 +211,7 @@ async def update_user_secrets( except ValidationError as e: # Handles Pydantic schema validation errors (e.g., bad format/length of credentials) error_msg = f"Invalid {creds.provider.value.upper()} credentials payload." - raise HTTPException(status_code=422, detail=error_msg) from e + raise HTTPException(status_code=400, detail=error_msg) from e verified, msg = creds_obj.verify_creds() @@ -247,117 +247,3 @@ async def update_user_secrets( return MessageSchema( message=f"{creds.provider.value.upper()} credentials successfully verified and updated" ) - - -@router.post("/me/secrets/aws") -async def update_aws_secrets( - aws_secrets: AWSSecrets, - current_user: UserModel = Depends(get_current_user), # noqa: B008 - db: AsyncSession = Depends(async_get_db), # noqa: B008 -) -> AWSUpdateSecretMessageSchema: - """Update the current user's AWS secrets. - - Args: - ---- - aws_secrets (AWSSecrets): The AWS credentials to store. - current_user (UserModel): The authenticated user. - db (Session): Database connection. - - Returns: - ------- - AWSUpdateSecretMessageSchema: Status message. - - """ - # Fetch secrets explicitly from the database - stmt = select(SecretModel).where(SecretModel.user_id == current_user.id) - result = await db.execute(stmt) - secrets = result.scalars().first() - if not secrets: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="User secrets record not found", - ) - - # Encrypt the AWS credentials using the user's public key - if not current_user.public_key: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="User encryption keys not set up. Please register a new account.", - ) - - # Convert to dictionary for encryption - aws_data = { - "aws_access_key": aws_secrets.aws_access_key, - "aws_secret_key": aws_secrets.aws_secret_key, - } - - # Encrypt with the user's public key - encrypted_data = encrypt_with_public_key(aws_data, current_user.public_key) - - # Update the secrets with encrypted values - secrets.aws_access_key = encrypted_data["aws_access_key"] - secrets.aws_secret_key = encrypted_data["aws_secret_key"] - secrets.aws_created_at = datetime.now(UTC) - await db.commit() - - return AWSUpdateSecretMessageSchema(message="AWS credentials updated successfully") - - -@router.post("/me/secrets/azure") -async def update_azure_secrets( - azure_secrets: AzureSecrets, - current_user: UserModel = Depends(get_current_user), # noqa: B008 - db: AsyncSession = Depends(async_get_db), # noqa: B008 -) -> AzureUpdateSecretMessageSchema: - """Update the current user's Azure secrets. - - Args: - ---- - azure_secrets (AzureSecrets): The Azure credentials to store. - current_user (UserModel): The authenticated user. - db (Session): Database connection. - - Returns: - ------- - AzureUpdateSecretMessageSchema: Success message. - - """ - # Fetch secrets explicitly from the database - stmt = select(SecretModel).where(SecretModel.user_id == current_user.id) - result = await db.execute(stmt) - secrets = result.scalars().first() - if not secrets: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="User secrets record not found", - ) - - # Encrypt the Azure credentials using the user's public key - if not current_user.public_key: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="User encryption keys not set up. Please register a new account.", - ) - - # Convert to dictionary for encryption - azure_data = { - "azure_client_id": azure_secrets.azure_client_id, - "azure_client_secret": azure_secrets.azure_client_secret, - "azure_tenant_id": azure_secrets.azure_tenant_id, - "azure_subscription_id": azure_secrets.azure_subscription_id, - } - - # Encrypt with the user's public key - encrypted_data = encrypt_with_public_key(azure_data, current_user.public_key) - - # Update the secrets with encrypted values - secrets.azure_client_id = encrypted_data["azure_client_id"] - secrets.azure_client_secret = encrypted_data["azure_client_secret"] - secrets.azure_tenant_id = encrypted_data["azure_tenant_id"] - secrets.azure_subscription_id = encrypted_data["azure_subscription_id"] - secrets.azure_created_at = datetime.now(UTC) - await db.commit() - - return AzureUpdateSecretMessageSchema( - message="Azure credentials updated successfully" - ) diff --git a/api/tests/common/api/v1/config.py b/api/tests/common/api/v1/config.py index ca5af7d9..66c2c8c5 100644 --- a/api/tests/common/api/v1/config.py +++ b/api/tests/common/api/v1/config.py @@ -336,9 +336,12 @@ } # Test data for AWS secrets -aws_secrets_payload = { - "aws_access_key": "AKIAIOSFODNN7EXAMPLE", - "aws_secret_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", +aws_secrets_payload: dict[str, Any] = { + "provider": "aws", + "credentials": { + "aws_access_key": "AKIAIOSFODNN7EXAMPLE", + "aws_secret_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + }, } # Test data for Azure secrets diff --git a/api/tests/common/api/v1/test_users.py b/api/tests/common/api/v1/test_users.py index 577dc43a..2787f049 100644 --- a/api/tests/common/api/v1/test_users.py +++ b/api/tests/common/api/v1/test_users.py @@ -47,74 +47,31 @@ async def test_update_password_with_incorrect_current( assert update_response.status_code == status.HTTP_400_BAD_REQUEST assert update_response.json()["detail"] == "Current password is incorrect" - async def test_update_aws_credentials(self, auth_api_client: AsyncClient) -> None: - """Test updating AWS credentials.""" - # Add AWS credentials - aws_response = await auth_api_client.post( - f"{BASE_ROUTE}/users/me/secrets/aws", json=aws_secrets_payload + async def test_update_secrets_with_invalid_payload(self, auth_api_client: AsyncClient) -> None: + """Test updating user secrets with invalid credentials format""" + # Try update with invalid secrets format - Use AWS secrets specifically for this test + invalid_payload = copy.deepcopy(aws_secrets_payload) + # Using incorrect credentials to test validation - not a security risk + invalid_payload["credentials"]["aws_access_key"] = ( + "string" # noqa: S105 ) - assert aws_response.status_code == status.HTTP_200_OK - assert aws_response.json()["message"] == "AWS credentials updated successfully" - # Check updated status - updated_status_response = await auth_api_client.get( - f"{BASE_ROUTE}/users/me/secrets" - ) - assert updated_status_response.status_code == status.HTTP_200_OK - - aws_status = updated_status_response.json() - assert aws_status["aws"]["has_credentials"] is True - assert "created_at" in aws_status["aws"] - - async def test_update_azure_credentials(self, auth_api_client: AsyncClient) -> None: - """Test updating Azure credentials.""" - # Add Azure credentials - azure_response = await auth_api_client.post( - f"{BASE_ROUTE}/users/me/secrets/azure", json=azure_secrets_payload - ) - assert azure_response.status_code == status.HTTP_200_OK - assert ( - azure_response.json()["message"] == "Azure credentials updated successfully" + update_response = await auth_api_client.post( + f"{BASE_ROUTE}/users/me/secrets", json=invalid_payload ) + assert update_response.status_code == status.HTTP_400_BAD_REQUEST + assert update_response.json()["detail"] == "Invalid AWS credentials payload." - # Check updated status - status_response = await auth_api_client.get(f"{BASE_ROUTE}/users/me/secrets") - assert status_response.status_code == status.HTTP_200_OK - - azure_status = status_response.json() - assert azure_status["azure"]["has_credentials"] is True - assert "created_at" in azure_status["azure"] - - async def test_both_provider_credentials_status( - self, auth_api_client: AsyncClient - ) -> None: - """Test that status shows both provider credentials when set.""" - # Add AWS credentials - aws_response = await auth_api_client.post( - f"{BASE_ROUTE}/users/me/secrets/aws", json=aws_secrets_payload - ) - assert aws_response.status_code == status.HTTP_200_OK - assert aws_response.json()["message"] == "AWS credentials updated successfully" + async def test_update_secrets_with_invalid_credentials(self, auth_api_client: AsyncClient) -> None: + """Test updating user secrets with invalid credentials that do not authenticate""" + # Try update with invalid secrets - Use AWS secrets specifically for this test + invalid_payload = copy.deepcopy(aws_secrets_payload) # Example secrets correct format but do not authenticate - # Add Azure credentials - azure_response = await auth_api_client.post( - f"{BASE_ROUTE}/users/me/secrets/azure", json=azure_secrets_payload - ) - assert azure_response.status_code == status.HTTP_200_OK - assert ( - azure_response.json()["message"] == "Azure credentials updated successfully" + update_response = await auth_api_client.post( + f"{BASE_ROUTE}/users/me/secrets", json=invalid_payload ) - - # Check final status with both credentials - status_response = await auth_api_client.get(f"{BASE_ROUTE}/users/me/secrets") - assert status_response.status_code == status.HTTP_200_OK - - provider_status = status_response.json() - assert provider_status["aws"]["has_credentials"] is True - assert provider_status["azure"]["has_credentials"] is True - assert "created_at" in provider_status["aws"] - assert "created_at" in provider_status["azure"] - + assert update_response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + assert update_response.json()["detail"] == "AWS credentials could not be authenticated. Please ensure you are providing credentials that are linked to a valid AWS account." @pytest.mark.asyncio(loop_scope="session") @pytest.mark.parametrize( @@ -195,13 +152,7 @@ async def test_unauthenticated_access(self, api_client: AsyncClient) -> None: # Try to update AWS secrets without authentication response = await api_client.post( - f"{BASE_ROUTE}/users/me/secrets/aws", json=aws_secrets_payload - ) - assert response.status_code == status.HTTP_401_UNAUTHORIZED - - # Try to update Azure secrets without authentication - response = await api_client.post( - f"{BASE_ROUTE}/users/me/secrets/azure", json=azure_secrets_payload + f"{BASE_ROUTE}/users/me/secrets", json=aws_secrets_payload ) assert response.status_code == status.HTTP_401_UNAUTHORIZED diff --git a/api/tests/unit/api/v1/test_users.py b/api/tests/unit/api/v1/test_users.py new file mode 100644 index 00000000..a556315c --- /dev/null +++ b/api/tests/unit/api/v1/test_users.py @@ -0,0 +1,42 @@ +from typing import Any +from httpx import AsyncClient +import pytest +from pytest_mock import MockerFixture + +from src.app.schemas.message_schema import MessageSchema +from tests.unit.api.v1.config import BASE_ROUTE +from fastapi import status +from tests.unit.api.v1.config import aws_secrets_payload +from unittest.mock import MagicMock + + +@pytest.fixture +def users_api_v1_endpoints_path() -> str: + """Get the dot path to the v1 API endpoints for users.""" + return "src.app.api.v1.users" + + +@pytest.fixture +def mock_update_secrets_success( + mocker: MockerFixture, users_api_v1_endpoints_path: str +) -> None: + """Bypass provider credentials verification to succeed.""" + + mock_creds_class = MagicMock() + mock_creds_class.verify_creds.return_value = [True, MessageSchema(message="true")] + # Patch the function + mocker.patch( + f"{users_api_v1_endpoints_path}.CredsFactory.create_creds_verification", + return_value=mock_creds_class, + ) + + +async def test_update_secrets_database_fetch_failure( + auth_client: AsyncClient, mock_update_secrets_success: None +) -> None: + """Test that attempting to update user provider credentials fails when user record is not found in the database""" + response = await auth_client.post( + f"{BASE_ROUTE}/me/secrets", + json=aws_secrets_payload, + ) + assert response.status_code == status.HTTP_200_OK From 10d6117e353b9999c230020e1b3fdb9e078d607a Mon Sep 17 00:00:00 2001 From: Nareshp1 Date: Sun, 20 Jul 2025 14:52:03 -0400 Subject: [PATCH 05/43] fix(secrets): Added additional handling for invalid provider in secrets payload. --- api/src/app/api/v1/users.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/src/app/api/v1/users.py b/api/src/app/api/v1/users.py index cf1e2517..fc07298a 100644 --- a/api/src/app/api/v1/users.py +++ b/api/src/app/api/v1/users.py @@ -208,8 +208,8 @@ async def update_user_secrets( creds_obj = CredsFactory.create_creds_verification( provider=creds.provider, credentials=creds.credentials ) - except ValidationError as e: - # Handles Pydantic schema validation errors (e.g., bad format/length of credentials) + except (ValidationError, ValueError) as e: + # Handles Pydantic schema validation errors (e.g., bad format/length of credentials) or invalid providers in payload error_msg = f"Invalid {creds.provider.value.upper()} credentials payload." raise HTTPException(status_code=400, detail=error_msg) from e From 9218f894060d5b92d32a53c6c1ec93168ea274e0 Mon Sep 17 00:00:00 2001 From: Nareshp1 Date: Sun, 20 Jul 2025 14:52:35 -0400 Subject: [PATCH 06/43] fix(tests): Updated AWS Secrets payload in config to reflect current payload design. --- api/tests/unit/api/v1/config.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/api/tests/unit/api/v1/config.py b/api/tests/unit/api/v1/config.py index d8126aed..257aafd0 100644 --- a/api/tests/unit/api/v1/config.py +++ b/api/tests/unit/api/v1/config.py @@ -309,9 +309,12 @@ } # Test data for AWS secrets -aws_secrets_payload = { - "aws_access_key": "AKIAIOSFODNN7EXAMPLE", - "aws_secret_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", +aws_secrets_payload: dict[str, Any] = { + "provider": "aws", + "credentials": { + "aws_access_key": "AKIAIOSFODNN7EXAMPLE", + "aws_secret_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + }, } # Test data for Azure secrets From 8517b8c8003185d1097531adea71f5cc67916663 Mon Sep 17 00:00:00 2001 From: Nareshp1 Date: Sun, 20 Jul 2025 14:52:54 -0400 Subject: [PATCH 07/43] feat(tests): Added tests for CredsFactory class --- api/tests/unit/cloud/test_creds_factory.py | 38 ++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 api/tests/unit/cloud/test_creds_factory.py diff --git a/api/tests/unit/cloud/test_creds_factory.py b/api/tests/unit/cloud/test_creds_factory.py new file mode 100644 index 00000000..fc5677b9 --- /dev/null +++ b/api/tests/unit/cloud/test_creds_factory.py @@ -0,0 +1,38 @@ +import copy + +import pytest + +from src.app.cloud.aws_creds import AWSCreds +from src.app.cloud.creds_factory import CredsFactory +from src.app.schemas.creds_verify_schema import CredsVerifySchema +from tests.unit.api.v1.config import aws_secrets_payload + + +def test_creds_factory_non_existent_provider_type() -> None: + """Test that CredsFactory.create_creds_verification() raises a ValueError when invalid provider is provided.""" + # Set provider to non-existent provider + bad_provider_creds_payload = copy.deepcopy(aws_secrets_payload) + + invalid_creds_verify_schema = CredsVerifySchema.model_validate( + bad_provider_creds_payload + ) + # Ignore invalid string assignment since we are triggering a ValueError + invalid_creds_verify_schema.provider = "FakeProvider" # type: ignore + + with pytest.raises(ValueError): + _ = CredsFactory.create_creds_verification( + provider=invalid_creds_verify_schema.provider, + credentials=invalid_creds_verify_schema.credentials, + ) + + +def test_creds_factory_build_aws_creds() -> None: + """Test that CredsFactory can build an AWSCreds.""" + # Use AWS secrets payload with provider already set to AWS + aws_creds = CredsVerifySchema.model_validate(aws_secrets_payload) + + creds_object = CredsFactory.create_creds_verification( + provider=aws_creds.provider, credentials=aws_creds.credentials + ) + + assert type(creds_object) is AWSCreds From 4b2ff5e2cc3317946695471af842c5c94779b8b4 Mon Sep 17 00:00:00 2001 From: Nareshp1 Date: Sun, 20 Jul 2025 15:40:40 -0400 Subject: [PATCH 08/43] fix(schemas): Extra period in error message --- api/src/app/schemas/secret_schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/app/schemas/secret_schema.py b/api/src/app/schemas/secret_schema.py index 7ec25033..1cde759b 100644 --- a/api/src/app/schemas/secret_schema.py +++ b/api/src/app/schemas/secret_schema.py @@ -83,7 +83,7 @@ def validate_access_key(cls, aws_access_key: str) -> str: """ access_key_length = 20 if len(aws_access_key.strip()) == 0: - msg = "Partial credentials or no credentials provided.. Please ensure you are providing proper AWS credentials." + msg = "Partial credentials or no credentials provided. Please ensure you are providing proper AWS credentials." raise ValueError(msg) if len(aws_access_key.strip()) != access_key_length: msg = "Invalid credential format. Please ensure your AWS credentials are of proper length." From f5311d91a2157b411170b2e44128bbf709c1b6e1 Mon Sep 17 00:00:00 2001 From: Nareshp1 Date: Sun, 20 Jul 2025 15:42:06 -0400 Subject: [PATCH 09/43] fix(tests): Update credential payload to use empty dictionary when testing for invalid payload --- api/tests/common/api/v1/test_users.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/api/tests/common/api/v1/test_users.py b/api/tests/common/api/v1/test_users.py index 2787f049..ddd67845 100644 --- a/api/tests/common/api/v1/test_users.py +++ b/api/tests/common/api/v1/test_users.py @@ -47,14 +47,14 @@ async def test_update_password_with_incorrect_current( assert update_response.status_code == status.HTTP_400_BAD_REQUEST assert update_response.json()["detail"] == "Current password is incorrect" - async def test_update_secrets_with_invalid_payload(self, auth_api_client: AsyncClient) -> None: - """Test updating user secrets with invalid credentials format""" + async def test_update_secrets_with_invalid_payload( + self, auth_api_client: AsyncClient + ) -> None: + """Test updating user secrets with invalid credentials payload""" # Try update with invalid secrets format - Use AWS secrets specifically for this test invalid_payload = copy.deepcopy(aws_secrets_payload) - # Using incorrect credentials to test validation - not a security risk - invalid_payload["credentials"]["aws_access_key"] = ( - "string" # noqa: S105 - ) + # Using incorrect credentials to test validation - submit payload without required fields + invalid_payload["credentials"] = {} # noqa: S105 update_response = await auth_api_client.post( f"{BASE_ROUTE}/users/me/secrets", json=invalid_payload @@ -62,16 +62,24 @@ async def test_update_secrets_with_invalid_payload(self, auth_api_client: AsyncC assert update_response.status_code == status.HTTP_400_BAD_REQUEST assert update_response.json()["detail"] == "Invalid AWS credentials payload." - async def test_update_secrets_with_invalid_credentials(self, auth_api_client: AsyncClient) -> None: + async def test_update_secrets_with_invalid_credentials( + self, auth_api_client: AsyncClient + ) -> None: """Test updating user secrets with invalid credentials that do not authenticate""" # Try update with invalid secrets - Use AWS secrets specifically for this test - invalid_payload = copy.deepcopy(aws_secrets_payload) # Example secrets correct format but do not authenticate + invalid_payload = copy.deepcopy( + aws_secrets_payload + ) # Example secrets correct format but do not authenticate update_response = await auth_api_client.post( f"{BASE_ROUTE}/users/me/secrets", json=invalid_payload ) assert update_response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - assert update_response.json()["detail"] == "AWS credentials could not be authenticated. Please ensure you are providing credentials that are linked to a valid AWS account." + assert ( + update_response.json()["detail"] + == "AWS credentials could not be authenticated. Please ensure you are providing credentials that are linked to a valid AWS account." + ) + @pytest.mark.asyncio(loop_scope="session") @pytest.mark.parametrize( From e6bf1fc1871cc5c605892ffa8fe62fda6cadc81d Mon Sep 17 00:00:00 2001 From: Nareshp1 Date: Sun, 20 Jul 2025 15:42:48 -0400 Subject: [PATCH 10/43] fix(tests): fix api route for fixture test (in-progress) --- api/tests/unit/api/v1/test_users.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/tests/unit/api/v1/test_users.py b/api/tests/unit/api/v1/test_users.py index a556315c..7a819fd8 100644 --- a/api/tests/unit/api/v1/test_users.py +++ b/api/tests/unit/api/v1/test_users.py @@ -36,7 +36,7 @@ async def test_update_secrets_database_fetch_failure( ) -> None: """Test that attempting to update user provider credentials fails when user record is not found in the database""" response = await auth_client.post( - f"{BASE_ROUTE}/me/secrets", + f"{BASE_ROUTE}/users/me/secrets", json=aws_secrets_payload, ) assert response.status_code == status.HTTP_200_OK From b8c39110fe844856727ce33a18249194c75ed2ef Mon Sep 17 00:00:00 2001 From: Nareshp1 Date: Sun, 20 Jul 2025 15:43:16 -0400 Subject: [PATCH 11/43] feat(tests): add test coverage for AWS secrets schema --- api/tests/unit/schemas/test_secret_schema.py | 52 ++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 api/tests/unit/schemas/test_secret_schema.py diff --git a/api/tests/unit/schemas/test_secret_schema.py b/api/tests/unit/schemas/test_secret_schema.py new file mode 100644 index 00000000..77dc4cdf --- /dev/null +++ b/api/tests/unit/schemas/test_secret_schema.py @@ -0,0 +1,52 @@ +import copy +import re + +import pytest +from pydantic import ValidationError + +from src.app.schemas.secret_schema import AWSSecrets +from tests.unit.api.v1.config import aws_secrets_payload + + +def test_aws_secrets_schema_invalid_access_key_length() -> None: + """Test that the AWS Secrets schema fails when aws_access_key is invalid length.""" + invalid_creds = copy.deepcopy(aws_secrets_payload) + invalid_creds["credentials"]["aws_access_key"] = "string" + + expected_msg = re.compile(r"Invalid credential format", re.IGNORECASE) + with pytest.raises(ValidationError, match=expected_msg): + AWSSecrets.model_validate(invalid_creds["credentials"]) + + +def test_aws_secrets_schema_missing_access_key_length() -> None: + """Test that the AWS Secrets schema fails when aws_access_key is missing.""" + invalid_creds = copy.deepcopy(aws_secrets_payload) + invalid_creds["credentials"][ + "aws_access_key" + ] = "" # Empty string = missing access key + + expected_msg = re.compile(r"Partial credentials", re.IGNORECASE) + with pytest.raises(ValidationError, match=expected_msg): + AWSSecrets.model_validate(invalid_creds["credentials"]) + + +def test_aws_secrets_schema_invalid_secret_key_length() -> None: + """Test that the AWS Secrets schema fails when aws_secret_key is invalid length.""" + invalid_creds = copy.deepcopy(aws_secrets_payload) + invalid_creds["credentials"]["aws_secret_key"] = "string" + + expected_msg = re.compile(r"Invalid credential format", re.IGNORECASE) + with pytest.raises(ValidationError, match=expected_msg): + AWSSecrets.model_validate(invalid_creds["credentials"]) + + +def test_aws_secrets_schema_missing_secret_key_length() -> None: + """Test that the AWS Secrets schema fails when aws_secret_key is missing.""" + invalid_creds = copy.deepcopy(aws_secrets_payload) + invalid_creds["credentials"][ + "aws_secret_key" + ] = "" # Empty string = missing secret key + + expected_msg = re.compile(r"Partial credentials", re.IGNORECASE) + with pytest.raises(ValidationError, match=expected_msg): + AWSSecrets.model_validate(invalid_creds["credentials"]) From fcd20a25f586b6a44d999fd3c0eee23bcbb5874b Mon Sep 17 00:00:00 2001 From: Nareshp1 Date: Fri, 25 Jul 2025 19:52:54 -0400 Subject: [PATCH 12/43] fix(tests): Update pyproject.toml to ignore botocore deprecated warnings. --- api/pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/pyproject.toml b/api/pyproject.toml index 6d6c73e9..5b329793 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -76,7 +76,8 @@ addopts = [ asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "session" filterwarnings = [ - "ignore:cannot collect test class 'Testing':pytest.PytestCollectionWarning" + "ignore:cannot collect test class 'Testing':pytest.PytestCollectionWarning", + "ignore::DeprecationWarning:botocore.auth", ] # Register markers to easily select unit or integration tests. From 0ae48eb06ec84d9a499670f1a08143aa9ade340f Mon Sep 17 00:00:00 2001 From: Nareshp1 Date: Fri, 25 Jul 2025 19:53:50 -0400 Subject: [PATCH 13/43] fix(api): Update user error message for updating secrets endpoint. --- api/src/app/api/v1/users.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/src/app/api/v1/users.py b/api/src/app/api/v1/users.py index fc07298a..44ab03ee 100644 --- a/api/src/app/api/v1/users.py +++ b/api/src/app/api/v1/users.py @@ -200,7 +200,7 @@ async def update_user_secrets( if not secrets: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="User secrets record not found", + detail="User secrets record not found!", ) # Verify credentials are valid before storing @@ -245,5 +245,5 @@ async def update_user_secrets( await upsert_user_secrets(db, secrets, current_user.id) return MessageSchema( - message=f"{creds.provider.value.upper()} credentials successfully verified and updated" + message=f"{creds.provider.value.upper()} credentials successfully verified and updated." ) From 0b226520c824875fe9c270d25826479ee9573c66 Mon Sep 17 00:00:00 2001 From: Nareshp1 Date: Fri, 25 Jul 2025 19:54:49 -0400 Subject: [PATCH 14/43] fix(crud): Remove unnecessary commit line from crud function. --- api/src/app/crud/crud_secrets.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/api/src/app/crud/crud_secrets.py b/api/src/app/crud/crud_secrets.py index de71f636..4f3ef855 100644 --- a/api/src/app/crud/crud_secrets.py +++ b/api/src/app/crud/crud_secrets.py @@ -59,6 +59,3 @@ async def upsert_user_secrets( # - If yes, it copies the new data onto the existing record. # - If no, it stages a new record for insertion. await db.merge(db_object_to_merge) - # 3. Commit the transaction to save the changes. - # This will execute either an UPDATE or INSERT statement. - await db.commit() From 83e76f84c7d6231cbc9dca8ad7e28e6f762590bb Mon Sep 17 00:00:00 2001 From: Nareshp1 Date: Fri, 25 Jul 2025 19:55:38 -0400 Subject: [PATCH 15/43] fix(tests): Working full coverage unit api tests for users secrets endpoint. --- api/tests/unit/api/v1/test_users.py | 83 +++++++++++++++++++++++++++-- 1 file changed, 78 insertions(+), 5 deletions(-) diff --git a/api/tests/unit/api/v1/test_users.py b/api/tests/unit/api/v1/test_users.py index 7a819fd8..716b6267 100644 --- a/api/tests/unit/api/v1/test_users.py +++ b/api/tests/unit/api/v1/test_users.py @@ -1,8 +1,13 @@ +from datetime import UTC, datetime from typing import Any from httpx import AsyncClient import pytest -from pytest_mock import MockerFixture +from pytest_mock import MockerFixture, mocker +from src.app.core.auth.auth import get_current_user +from src.app.main import app +from src.app.models.user_model import UserModel +from src.app.schemas.secret_schema import SecretSchema from src.app.schemas.message_schema import MessageSchema from tests.unit.api.v1.config import BASE_ROUTE from fastapi import status @@ -20,23 +25,91 @@ def users_api_v1_endpoints_path() -> str: def mock_update_secrets_success( mocker: MockerFixture, users_api_v1_endpoints_path: str ) -> None: - """Bypass provider credentials verification to succeed.""" + """Bypass provider credentials verification and updating user secrets record to succeed.""" mock_creds_class = MagicMock() mock_creds_class.verify_creds.return_value = [True, MessageSchema(message="true")] - # Patch the function + mock_creds_class.update_secret_schema.return_value = SecretSchema() + # Patch the functions mocker.patch( f"{users_api_v1_endpoints_path}.CredsFactory.create_creds_verification", return_value=mock_creds_class, ) -async def test_update_secrets_database_fetch_failure( +@pytest.fixture +def mock_get_secrets_failure( + mocker: MockerFixture, users_api_v1_endpoints_path: str +) -> None: + """Bypass fetching users secrets to fail.""" + # Patch the function + mocker.patch(f"{users_api_v1_endpoints_path}.get_user_secrets", return_value=None) + + +@pytest.fixture +def mock_get_secrets(mocker: MockerFixture, users_api_v1_endpoints_path: str) -> None: + """Bypass fetching users secrets to pass for a fake user.""" + + def override_get_current_user_no_key(): + """Fake dependency that returns a user without a public key.""" + return UserModel( + name="FakeUser", + email="fakeuser@gmail.com", + hashed_password="faskpasswordhash", + created_at=datetime.now(UTC), + last_active=datetime.now(UTC), + is_admin=False, + public_key=None, + ) + + # Temporarily override the dependency + app.dependency_overrides[get_current_user] = override_get_current_user_no_key + # Patch the function + mocker.patch( + f"{users_api_v1_endpoints_path}.get_user_secrets", + return_value=SecretSchema(), + ) + + +async def test_update_aws_secrets_success( auth_client: AsyncClient, mock_update_secrets_success: None ) -> None: - """Test that attempting to update user provider credentials fails when user record is not found in the database""" + """Test that attempting to update user AWS provider credentials succeeds.""" response = await auth_client.post( f"{BASE_ROUTE}/users/me/secrets", json=aws_secrets_payload, ) assert response.status_code == status.HTTP_200_OK + assert ( + response.json()["message"] + == "AWS credentials successfully verified and updated." + ) + + +async def test_update_user_secrets_database_fetch_failure( + auth_client: AsyncClient, mock_get_secrets_failure: None +) -> None: + """Test that attempting to update user provider credentials fails when user record is not found in database.""" + response = await auth_client.post( + f"{BASE_ROUTE}/users/me/secrets", + json=aws_secrets_payload, + ) + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + assert response.json()["detail"] == "User secrets record not found!" + + +async def test_update_user_secrets_encryption_failure( + auth_client: AsyncClient, + mock_get_secrets: None, + mock_update_secrets_success: None, +) -> None: + """Test that attempting to update user provider credentials fails when user public key does not exist.""" + response = await auth_client.post( + f"{BASE_ROUTE}/users/me/secrets", + json=aws_secrets_payload, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert ( + response.json()["detail"] + == "User encryption keys not set up. Please register a new account." + ) From b672026ce7273d6ac6d7b578f96a31b016366629 Mon Sep 17 00:00:00 2001 From: Nareshp1 Date: Fri, 25 Jul 2025 19:56:11 -0400 Subject: [PATCH 16/43] feat(tests): working full unit test coverage for crud secrets. --- api/tests/unit/crud/test_crud_secrets.py | 72 ++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 api/tests/unit/crud/test_crud_secrets.py diff --git a/api/tests/unit/crud/test_crud_secrets.py b/api/tests/unit/crud/test_crud_secrets.py new file mode 100644 index 00000000..d09f70db --- /dev/null +++ b/api/tests/unit/crud/test_crud_secrets.py @@ -0,0 +1,72 @@ +import re +from unittest.mock import MagicMock +import pytest +from pytest_mock import MockerFixture + +from src.app.models.secret_model import SecretModel +from .crud_mocks import DummyDB +from src.app.crud.crud_secrets import get_user_secrets, upsert_user_secrets +from src.app.schemas.secret_schema import SecretSchema + + +@pytest.fixture +def crud_secrets_path() -> str: + """Return the dot path of the tested crud secrets file.""" + return "src.app.crud.crud_secrets" + + +async def test_upsert_user_secrets() -> None: + """Test that updating user secrets works correctly.""" + mock_db = DummyDB() + fake_secrets = SecretSchema() + mock_user_id = 1 + + await upsert_user_secrets(db=mock_db, secrets=fake_secrets, user_id=mock_user_id) + + # Check we upsert correctly + mock_db.merge.assert_awaited_once() + + # Check we are upserting the creds for the correct user + called_with_object = mock_db.merge.call_args[0][0] + + # 5. Assert that the object has the correct attributes + assert isinstance(called_with_object, SecretModel) + assert called_with_object.user_id == mock_user_id + + +async def test_get_user_secrets_success(mocker: MockerFixture) -> None: + """Tests successfully retrieving user secrets when they exist.""" + dummy_db = DummyDB() + user_id = 123 + mock_secret_model = MagicMock() + + # Mock the full database call to return mock model + mock_result = dummy_db.execute.return_value + mock_result.scalars = MagicMock() + mock_result.scalars.return_value.first.return_value = mock_secret_model + + # Patch validation of secrets + mock_validate = mocker.patch.object( + SecretSchema, "model_validate", return_value="validated_secrets" + ) + + result = await get_user_secrets(db=dummy_db, user_id=user_id) + assert result == "validated_secrets" + mock_validate.assert_called_once_with(mock_secret_model) + + # Verify the fetching secrets for correct user + stmt = dummy_db.execute.call_args[0][0] + assert str(SecretModel.user_id == user_id) in str(stmt.whereclause) + + +async def test_get_user_secrets_not_found(caplog: pytest.LogCaptureFixture) -> None: + """Tests that None is returned and a message is logged when secrets are not found.""" + dummy_db = DummyDB() + user_id = 404 + + # Mock the database to return no results + dummy_db.execute.return_value = None + + result = await get_user_secrets(db=dummy_db, user_id=user_id) + assert result is None + assert re.search(f"failed to fetch secrets.*{user_id}", caplog.text, re.IGNORECASE) From 67c999fd75af28c5fd283bbf9f34bc5c2dccd99e Mon Sep 17 00:00:00 2001 From: Nareshp1 Date: Mon, 28 Jul 2025 20:54:58 -0400 Subject: [PATCH 17/43] feat(tests): full test coverage for creds verification on AWS. --- api/tests/unit/cloud/test_aws_creds.py | 176 +++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 api/tests/unit/cloud/test_aws_creds.py diff --git a/api/tests/unit/cloud/test_aws_creds.py b/api/tests/unit/cloud/test_aws_creds.py new file mode 100644 index 00000000..46679083 --- /dev/null +++ b/api/tests/unit/cloud/test_aws_creds.py @@ -0,0 +1,176 @@ +import pytest +from unittest.mock import MagicMock +from botocore.exceptions import ClientError + +from src.app.utils.crypto import encrypt_with_public_key, generate_rsa_key_pair +from src.app.schemas.secret_schema import AWSSecrets, SecretSchema +from src.app.cloud.aws_creds import AWSCreds +from tests.unit.api.v1.config import aws_secrets_payload + + +@pytest.fixture(scope="module") +def aws_creds_class() -> AWSCreds: + """Create a AWS creds class object.""" + return AWSCreds(aws_secrets_payload["credentials"]) + + +def test_init(aws_creds_class: AWSCreds) -> None: + """Test that initializing the aws creds object creates and stores and AWS Secrets Schema object.""" + assert isinstance(aws_creds_class.credentials, AWSSecrets) + + +def test_get_user_creds(aws_creds_class: AWSCreds) -> None: + """Test that getting user credentials is returned as a proper dictionary.""" + user_creds = aws_creds_class.get_user_creds() + + # Keys + access_key = "aws_access_key" + secret_key = "aws_secret_key" + + assert user_creds[access_key] == aws_secrets_payload["credentials"][access_key] + assert user_creds[secret_key] == aws_secrets_payload["credentials"][secret_key] + + +def test_update_secret_schema(aws_creds_class: AWSCreds) -> None: + """Test that Secret Schema is updated with encrypted user credentials""" + user_creds = aws_creds_class.get_user_creds() + + # Encrypt with the user's public key + _, public_key = generate_rsa_key_pair() + encrypted_data = encrypt_with_public_key(data=user_creds, public_key_b64=public_key) + + # Update the secrets with encrypted values + secrets = SecretSchema() + secrets = aws_creds_class.update_secret_schema( + secrets=secrets, encrypted_data=encrypted_data + ) + + assert secrets.aws_access_key == encrypted_data["aws_access_key"] + assert secrets.aws_secret_key == encrypted_data["aws_secret_key"] + + +def test_verify_creds_success(aws_creds_class: AWSCreds, mocker: MagicMock) -> None: + """Test successful credential verification with sufficient permissions.""" + # Mock the boto3 session and its clients + mock_session = mocker.patch("boto3.Session").return_value + mock_sts = mock_session.client.return_value + mock_iam = mock_session.client.return_value + + # Configure mock responses + mock_sts.get_caller_identity.return_value = { + "Arn": "arn:aws:iam::123456789012:user/test-user" + } + mock_iam.simulate_principal_policy.return_value = { + "EvaluationResults": [ + {"EvalDecision": "allowed", "EvalActionName": "ec2:RunInstances"} + ] + } + + # Execute the function + verified, msg = aws_creds_class.verify_creds() + + # Assert the results + assert verified is True + assert "authenticated and all required permissions are present" in msg.message + mock_sts.get_caller_identity.assert_called_once() + mock_iam.simulate_principal_policy.assert_called_once() + + +def test_verify_creds_root_user(aws_creds_class: AWSCreds, mocker: MagicMock) -> None: + """Test that the permissions check is skipped for the root user.""" + mock_session = mocker.patch("boto3.Session").return_value + mock_sts = mock_session.client.return_value + mock_iam = mock_session.client.return_value + + mock_sts.get_caller_identity.return_value = { + "Arn": "arn:aws:iam::123456789012:root" + } + + verified, msg = aws_creds_class.verify_creds() + + assert verified is True + assert "authenticated and all required permissions are present" in msg.message + mock_iam.simulate_principal_policy.assert_not_called() # Ensure IAM check was skipped + + +def test_verify_creds_insufficient_permissions( + aws_creds_class: AWSCreds, mocker: MagicMock +) -> None: + """Test verification failure due to denied actions in the simulation.""" + mock_session = mocker.patch("boto3.Session").return_value + mock_sts = mock_session.client.return_value + mock_iam = mock_session.client.return_value + + mock_sts.get_caller_identity.return_value = { + "Arn": "arn:aws:iam::123456789012:user/limited-user" + } + mock_iam.simulate_principal_policy.return_value = { + "EvaluationResults": [ + {"EvalDecision": "allowed", "EvalActionName": "ec2:DescribeInstances"}, + {"EvalDecision": "implicitDeny", "EvalActionName": "ec2:RunInstances"}, + {"EvalDecision": "implicitDeny", "EvalActionName": "ec2:CreateVpc"}, + ] + } + + verified, msg = aws_creds_class.verify_creds() + + assert verified is False + assert "Insufficient permissions" in msg.message + assert "ec2:RunInstances" in msg.message + assert "ec2:CreateVpc" in msg.message + assert ( + "ec2:DescribeInstances" not in msg.message + ) # Ensure only denied actions are listed + + +def test_verify_creds_invalid_token( + aws_creds_class: AWSCreds, mocker: MagicMock +) -> None: + """Test verification failure due to invalid credentials.""" + mock_session = mocker.patch("boto3.Session").return_value + mock_sts = mock_session.client.return_value + + # Simulate a ClientError for an invalid token + error_response = { + "Error": { + "Code": "InvalidTokenId", + "Message": "The security token included in the request is invalid.", + } + } + mock_sts.get_caller_identity.side_effect = ClientError( + error_response, "GetCallerIdentity" + ) + + verified, msg = aws_creds_class.verify_creds() + + assert verified is False + assert msg.message == "The security token included in the request is invalid." + + +def test_verify_creds_iam_access_denied( + aws_creds_class: AWSCreds, mocker: MagicMock +) -> None: + """Test failure when credentials are valid but cannot perform the IAM simulation.""" + mock_session = mocker.patch("boto3.Session").return_value + mock_sts = mock_session.client.return_value + mock_iam = mock_session.client.return_value + + mock_sts.get_caller_identity.return_value = { + "Arn": "arn:aws:iam::123456789012:user/test-user" + } + + # Simulate a ClientError for lack of iam:SimulatePrincipalPolicy permission + error_response = { + "Error": { + "Code": "AccessDenied", + "Message": "User is not authorized to perform iam:SimulatePrincipalPolicy", + } + } + mock_iam.simulate_principal_policy.side_effect = ClientError( + error_response, "SimulatePrincipalPolicy" + ) + + verified, msg = aws_creds_class.verify_creds() + + assert verified is False + assert "iam:SimulatePrincipalPolicy permission" in msg.message From 9603e5ac57532b80fe0cdf4719c2c5d5acbf92da Mon Sep 17 00:00:00 2001 From: Nareshp1 Date: Mon, 28 Jul 2025 23:01:37 -0400 Subject: [PATCH 18/43] feat(frontend): Combined secrets pages into a single section with tabs to separate providers --- frontend/src/lib/api.ts | 13 + frontend/src/lib/types/api.ts | 6 + frontend/src/routes/settings/+page.svelte | 700 ++++++++-------------- 3 files changed, 253 insertions(+), 466 deletions(-) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index c4c29f4d..03b95462 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -16,6 +16,8 @@ import type { PasswordUpdateResponse, AWSSecretsRequest, AWSSecretsResponse, + SecretsRequest, + SecretsResponse, AzureSecretsRequest, AzureSecretsResponse, DeployRangeRequest @@ -175,6 +177,17 @@ export const userApi = { ) }, + updateSecrets: async (payload: any): Promise> => { + const request: SecretsRequest = { + secrets: payload + } + return await apiRequest( + '/api/v1/users/me/secrets', + 'POST', + request + ) + }, + // Set Azure secrets setAzureSecrets: async ( clientId: string, diff --git a/frontend/src/lib/types/api.ts b/frontend/src/lib/types/api.ts index b8530f98..1f544d2e 100644 --- a/frontend/src/lib/types/api.ts +++ b/frontend/src/lib/types/api.ts @@ -200,6 +200,12 @@ export interface AWSSecretsRequest { export interface AWSSecretsResponse { message: string; } +export interface SecretsRequest { + secrets: any; +} +export interface SecretsResponse { + message: string; +} // Azure secrets types export interface AzureSecretsRequest { diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index 9880f002..6a19273c 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -7,6 +7,9 @@ import { fade } from 'svelte/transition' import logger from '$lib/utils/logger' + // Active tab state + let activeTab: 'aws' | 'azure' = 'aws' + // Password form let currentPassword = '' let newPassword = '' @@ -41,18 +44,18 @@ let secretsStatus = { aws: { configured: false, - createdAt: null, + createdAt: null as string | null, }, azure: { configured: false, - createdAt: null, + createdAt: null as string | null, }, } let loadingSecrets = true let loadingUserData = true // Format date for tooltip display - function formatDateForTooltip(dateString) { + function formatDateForTooltip(dateString: string | null) { if (!dateString) return 'Date unavailable' try { return `Configured on ${new Date(dateString).toLocaleString()}` @@ -62,54 +65,49 @@ } } - // Custom tooltip management - let showAwsTooltip = false - let showAzureTooltip = false - - // Position tracking for tooltips - let awsTooltipPosition = { x: 0, y: 0 } - let azureTooltipPosition = { x: 0, y: 0 } - - function handleMouseEnter(event, tooltipType) { - // Calculate position for tooltip - const rect = event.target.getBoundingClientRect() - const position = { - x: rect.left + window.scrollX + rect.width / 2, // Center horizontally - y: rect.top + window.scrollY - 40, // Position higher above the element + // Helper for tab styling + function getTabClass(tabName: 'aws' | 'azure') { + const baseClasses = 'whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium' + const inactiveClasses = 'border-transparent text-gray-400 hover:border-gray-500 hover:text-gray-300' + + if (activeTab === tabName) { + const activeClasses = tabName === 'aws' + ? 'border-yellow-500 text-yellow-500' + : 'border-blue-500 text-blue-500' + return `${baseClasses} ${activeClasses}` } + + return `${baseClasses} ${inactiveClasses}` + } - // Set position and show appropriate tooltip - if (tooltipType === 'aws') { - awsTooltipPosition = position - showAwsTooltip = true - } else if (tooltipType === 'azure') { - azureTooltipPosition = position - showAzureTooltip = true + // Custom tooltip management + let showTooltip = false + let tooltipPosition = { x: 0, y: 0 } + + function handleMouseEnter(event: MouseEvent) { + const rect = (event.target as HTMLElement).getBoundingClientRect() + tooltipPosition = { + x: rect.left + window.scrollX + rect.width / 2, + y: rect.top + window.scrollY - 10, } + showTooltip = true } - function handleMouseLeave(tooltipType) { - if (tooltipType === 'aws') { - showAwsTooltip = false - } else if (tooltipType === 'azure') { - showAzureTooltip = false - } + function handleMouseLeave() { + showTooltip = false } // Load user data and secrets status onMount(async () => { + loadingUserData = true try { - // Load user data first const { authApi } = await import('$lib/api') const userResponse = await authApi.getCurrentUser() - if (userResponse.data?.user) { userData = { name: userResponse.data.user.name || '', email: userResponse.data.user.email || '', } - - // Update auth store auth.updateUser(userResponse.data.user) } } catch (error) { @@ -118,22 +116,18 @@ loadingUserData = false } + loadingSecrets = true try { - // Then load secrets status const result = await userApi.getUserSecrets() - if (result.data) { - const awsDate = result.data.aws?.created_at - const azureDate = result.data.azure?.created_at - secretsStatus = { aws: { configured: result.data.aws?.has_credentials || false, - createdAt: awsDate, + createdAt: result.data.aws?.created_at || null, }, azure: { configured: result.data.azure?.has_credentials || false, - createdAt: azureDate, + createdAt: result.data.azure?.created_at || null, }, } } @@ -146,42 +140,27 @@ // Handle password update async function handlePasswordUpdate() { - // Reset messages passwordError = '' passwordSuccess = '' - - // Validate input - if (!currentPassword) { - passwordError = 'Current password is required' - return - } - - if (!newPassword) { - passwordError = 'New password is required' + if (!currentPassword || !newPassword) { + passwordError = 'All password fields are required' return } - if (newPassword !== confirmPassword) { passwordError = 'New passwords do not match' return } - if (newPassword.length < 8) { passwordError = 'Password must be at least 8 characters long' return } - isPasswordLoading = true - try { const result = await userApi.updatePassword(currentPassword, newPassword) - if (result.error) { passwordError = result.error return } - - // Success passwordSuccess = 'Password updated successfully' currentPassword = '' newPassword = '' @@ -194,124 +173,110 @@ } } - // Handle AWS secrets update - async function handleAwsSecretsUpdate() { - // Reset messages - awsError = '' - awsSuccess = '' - - // Validate input - if (!awsAccessKey) { - awsError = 'AWS Access Key is required' - return - } - - if (!awsSecretKey) { - awsError = 'AWS Secret Key is required' - return + // Shared function to call the single endpoint + async function updateSecrets(provider: 'aws' | 'azure', payload: any) { + // Set loading state based on provider + if (provider === 'aws') { + isAwsLoading = true + } else { + isAzureLoading = true } - isAwsLoading = true - try { - const result = await userApi.setAwsSecrets(awsAccessKey, awsSecretKey) + // **IMPORTANT**: This now calls a single, unified API endpoint. + // Make sure `userApi.updateSecrets` exists and handles the payload format. + const result = await userApi.updateSecrets(payload) if (result.error) { - awsError = result.error + if (provider === 'aws') awsError = result.error + else azureError = result.error return } - // Success - awsSuccess = 'AWS credentials updated successfully' - secretsStatus.aws.configured = true // Update local status - secretsStatus.aws.createdAt = new Date().toISOString() - awsAccessKey = '' - awsSecretKey = '' + // Handle success based on provider + if (provider === 'aws') { + awsSuccess = 'AWS credentials updated successfully' + secretsStatus.aws = { configured: true, createdAt: new Date().toISOString() } + awsAccessKey = '' + awsSecretKey = '' + } else { + azureSuccess = 'Azure credentials updated successfully' + secretsStatus.azure = { configured: true, createdAt: new Date().toISOString() } + azureClientId = '' + azureClientSecret = '' + azureTenantId = '' + azureSubscriptionId = '' + } } catch (error) { - awsError = - error instanceof Error - ? error.message - : 'Failed to update AWS credentials' + const errorMessage = error instanceof Error ? error.message : 'Failed to update credentials' + if (provider === 'aws') awsError = errorMessage + else azureError = errorMessage } finally { - isAwsLoading = false + // Reset loading state based on provider + if (provider === 'aws') isAwsLoading = false + else isAzureLoading = false } } - // Handle Azure secrets update - async function handleAzureSecretsUpdate() { - // Reset messages - azureError = '' - azureSuccess = '' - - // Validate input - if (!azureClientId) { - azureError = 'Azure Client ID is required' + // Handle AWS secrets update + async function handleAwsSecretsUpdate() { + awsError = '' + awsSuccess = '' + if (!awsAccessKey || !awsSecretKey) { + awsError = 'Both AWS Access Key and Secret Key are required' return } - - if (!azureClientSecret) { - azureError = 'Azure Client Secret is required' - return + + // Construct the AWS-specific payload + const payload = { + provider: 'aws', + credentials: { + aws_access_key: awsAccessKey, + aws_secret_key: awsSecretKey, + }, } - if (!azureTenantId) { - azureError = 'Azure Tenant ID is required' - return - } + // Call the shared update function + await updateSecrets('aws', payload) + } - if (!azureSubscriptionId) { - azureError = 'Azure Subscription ID is required' + // Handle Azure secrets update + async function handleAzureSecretsUpdate() { + azureError = '' + azureSuccess = '' + if ( + !azureClientId || + !azureClientSecret || + !azureTenantId || + !azureSubscriptionId + ) { + azureError = 'All Azure fields are required' return } - - isAzureLoading = true - - try { - const result = await userApi.setAzureSecrets( - azureClientId, - azureClientSecret, - azureTenantId, - azureSubscriptionId - ) - - if (result.error) { - azureError = result.error - return - } - - // Success - azureSuccess = 'Azure credentials updated successfully' - secretsStatus.azure.configured = true // Update local status - secretsStatus.azure.createdAt = new Date().toISOString() - azureClientId = '' - azureClientSecret = '' - azureTenantId = '' - azureSubscriptionId = '' - } catch (error) { - azureError = - error instanceof Error - ? error.message - : 'Failed to update Azure credentials' - } finally { - isAzureLoading = false + + // Construct the Azure-specific payload + const payload = { + provider: 'azure', + credentials: { + azure_client_id: azureClientId, + azure_client_secret: azureClientSecret, + azure_tenant_id: azureTenantId, + azure_subscription_id: azureSubscriptionId, + }, } + + // Call the shared update function + await updateSecrets('azure', payload) } -
+
{:else} -
+
- {userData.name?.[0] || 'U'} + {userData.name?.[0]?.toUpperCase() || 'U'}

{userData.name || 'User'}

@@ -361,7 +326,6 @@

Change Password

-
-
-
- {#if passwordError} -
- {passwordError} -
+
{passwordError}
{/if} - {#if passwordSuccess} -
- {passwordSuccess} -
+
{passwordSuccess}
{/if} -
- -
+ +
-

Cloud Provider Credentials

- - - -
-
- -
-
- - - - End-to-End Encrypted +
+ +
+
+ + End-to-End Encrypted +
+

Your credentials are encrypted before being stored and are only decrypted when needed for a range. We cannot access your cloud provider credentials.

+
-

- Your credentials are encrypted before entering the database and - are only decrypted when needed for a range. Even the person - hosting OpenLabs cannot access your cloud provider credentials. -

-
-
{#if loadingSecrets} @@ -518,255 +419,122 @@
{:else} -
- -
-
-

AWS Credentials

- handleMouseEnter(e, 'aws')} - on:mouseleave={() => handleMouseLeave('aws')} +
+ +
+
- -
-
-
- - -
+ AWS + + + +
-
-