diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index fec41c975..000000000 Binary files a/.DS_Store and /dev/null differ diff --git a/executor/migrations/0046_secret_secret_executor_se_key_60cc82_idx_and_more.py b/executor/migrations/0046_secret_secret_executor_se_key_60cc82_idx_and_more.py new file mode 100644 index 000000000..d95095267 --- /dev/null +++ b/executor/migrations/0046_secret_secret_executor_se_key_60cc82_idx_and_more.py @@ -0,0 +1,41 @@ +# Generated by Django 4.1.13 on 2025-03-05 18:05 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import encrypted_model_fields.fields +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_accountuseroauth2sessioncodestore'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('executor', '0045_upgrade_step_relation_conditions'), + ] + + operations = [ + migrations.CreateModel( + name='Secret', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('key', models.CharField(help_text='Reference key for the secret', max_length=255)), + ('value', encrypted_model_fields.fields.EncryptedTextField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('is_active', models.BooleanField(default=True)), + ('description', models.TextField(blank=True)), + ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='accounts.account')), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_secrets', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddIndex( + model_name='secret', + index=models.Index(fields=['key', 'account', 'is_active'], name='executor_se_key_60cc82_idx'), + ), + migrations.AddConstraint( + model_name='secret', + constraint=models.UniqueConstraint(condition=models.Q(('is_active', True)), fields=('key', 'account', 'is_active'), name='unique_active_key_per_account'), + ), + ] diff --git a/executor/migrations/0047_remove_secret_unique_active_key_per_account_and_more.py b/executor/migrations/0047_remove_secret_unique_active_key_per_account_and_more.py new file mode 100644 index 000000000..77168683d --- /dev/null +++ b/executor/migrations/0047_remove_secret_unique_active_key_per_account_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 4.1.13 on 2025-03-12 08:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("executor", "0046_secret_secret_executor_se_key_60cc82_idx_and_more"), + ] + + operations = [ + migrations.RemoveConstraint( + model_name="secret", + name="unique_active_key_per_account", + ), + migrations.AddIndex( + model_name="secret", + index=models.Index( + fields=["account", "created_by"], name="executor_se_account_02f4c3_idx" + ), + ), + migrations.AddConstraint( + model_name="secret", + constraint=models.UniqueConstraint( + fields=("key", "account"), name="unique_key_per_account" + ), + ), + ] diff --git a/executor/models.py b/executor/models.py index 87c245ab2..02055f3cf 100644 --- a/executor/models.py +++ b/executor/models.py @@ -21,10 +21,13 @@ PlaybookStepExecutionLog as PlaybookStepExecutionLogProto, PlaybookExecution as PlaybookExecutionProto, \ PlaybookStepRelation as PlaybookStepRelationProto, \ PlaybookStepRelationExecutionLog as PlaybookStepRelationExecutionLogProto, PlaybookStepResultCondition -from utils.model_utils import generate_choices +from protos.secrets.api_pb2 import Secret as SecretProto from accounts.models import Account from utils.proto_utils import dict_to_proto +from utils.model_utils import generate_choices +from encrypted_model_fields.fields import EncryptedTextField +import uuid class PlayBookTask(models.Model): @@ -670,3 +673,67 @@ def proto(self) -> PlaybookStepRelationExecutionLogProto: InterpretationProto) if self.interpretation else InterpretationProto(), ) + + +class Secret(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + key = models.CharField(max_length=255, help_text="Reference key for the secret") + value = EncryptedTextField() + account = models.ForeignKey('accounts.Account', on_delete=models.CASCADE) + created_by = models.ForeignKey('accounts.User', on_delete=models.SET_NULL, null=True, related_name='created_secrets') + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + is_active = models.BooleanField(default=True) + description = models.TextField(blank=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=['key', 'account'], + name='unique_key_per_account' + ) + ] + indexes = [ + models.Index(fields=['key', 'account', 'is_active']), + models.Index(fields=['account', 'created_by']), + ] + + def deactivate(self): + """ + Deactivate a secret by setting is_active to False and modifying the key + to prevent conflicts with future secrets using the same key + """ + if not self.is_active: + return False + + self.is_active = False + # Generate a unique suffix for the key + unique_suffix = str(uuid.uuid4()) + self.key = f"{self.key}_inactive_{unique_suffix}" + self.save(update_fields=['is_active', 'key', 'updated_at']) + return True + + @staticmethod + def _mask_secret_value(value): + """Mask secret value for display purposes""" + if not value or len(value) <= 8: + return "••••••••" + return value[:2] + "••••••" + value[-2:] + + def to_proto(self, include_masked_value: bool = True) -> 'SecretProto': + """Convert this Secret model to a Secret proto with full details""" + + proto = SecretProto( + id=StringValue(value=str(self.id)), + key=StringValue(value=self.key), + description=StringValue(value=self.description or ""), + created_by=StringValue(value=self.created_by.email if self.created_by else ""), + created_at=int(self.created_at.replace(tzinfo=timezone.utc).timestamp()) if self.created_at else 0, + updated_at=int(self.updated_at.replace(tzinfo=timezone.utc).timestamp()) if self.updated_at else 0, + is_active=self.is_active + ) + + if include_masked_value: + proto.masked_value.value = self._mask_secret_value(self.value) + + return proto \ No newline at end of file diff --git a/executor/playbook_source_manager.py b/executor/playbook_source_manager.py index 8830659ac..23e141c31 100644 --- a/executor/playbook_source_manager.py +++ b/executor/playbook_source_manager.py @@ -16,6 +16,7 @@ from protos.playbooks.source_task_definitions.lambda_function_task_pb2 import Lambda from protos.ui_definition_pb2 import FormField from utils.proto_utils import proto_to_dict, dict_to_proto +from executor.secrets.secret_resolver import SecretResolver def apply_result_transformer(result_dict, lambda_function: Lambda.Function) -> Dict: @@ -191,9 +192,18 @@ def execute_task(self, account_id, time_range: TimeRange, global_variable_set: S form_fields = self.task_type_callable_map[task_type]['form_fields'] - # Resolve global variables in source_type_task_def + # First resolve secrets + resolved_source_type_task_def = SecretResolver.resolve_secrets( + form_fields=form_fields, + account_id=account_id, + source_type_task_def=source_type_task_def + ) + + # Then resolve global variables resolved_source_type_task_def, task_local_variable_map = resolve_global_variables( - form_fields, global_variable_set, source_type_task_def) + form_fields, global_variable_set, resolved_source_type_task_def + ) + source_task[task_type_name] = resolved_source_type_task_def resolved_task_def_proto = dict_to_proto(source_task, self.task_proto) diff --git a/executor/secrets/__init__.py b/executor/secrets/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/executor/secrets/crud/__init__.py b/executor/secrets/crud/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/executor/secrets/crud/__init__.py @@ -0,0 +1 @@ + diff --git a/executor/secrets/crud/secrets_crud_manager.py b/executor/secrets/crud/secrets_crud_manager.py new file mode 100644 index 000000000..a341ed808 --- /dev/null +++ b/executor/secrets/crud/secrets_crud_manager.py @@ -0,0 +1,250 @@ +import logging +from typing import List, Optional, Union +from datetime import timezone +import uuid + +from django.db.models import QuerySet +from google.protobuf.wrappers_pb2 import BoolValue, StringValue + +from executor.models import Secret +from protos.secrets.api_pb2 import UpdateSecretOp, Secret as SecretProto +from protos.base_pb2 import Message, Meta, Page +from utils.update_processor_mixin import UpdateProcessorMixin + +logger = logging.getLogger(__name__) + + +class SecretsCrudManager(UpdateProcessorMixin): + """ + Centralized manager for all CRUD operations on Secrets + """ + update_op_cls = UpdateSecretOp + + @staticmethod + def get_by_id(secret_id: str, account_id: int, user_id: int = None) -> Optional[Secret]: + """ + Get a secret by ID + + Args: + secret_id: The ID of the secret to retrieve + account_id: The account ID the secret belongs to + user_id: If provided, only return secrets created by this user + + Returns: + Secret object if found, None otherwise + """ + try: + query = { + 'id': secret_id, + 'account_id': account_id, + 'is_active': True + } + + # Add user filter if provided + if user_id: + query['created_by_id'] = user_id + + return Secret.objects.get(**query) + except Secret.DoesNotExist: + return None + + @staticmethod + def list_secrets(account_id: int, user_id: int = None, secret_ids: List[str] = None, + key_filter: str = None, show_inactive: bool = False, + page: Page = None) -> QuerySet: + """ + List secrets with optional filtering + + Args: + account_id: The account ID to filter secrets by + user_id: If provided, only return secrets created by this user + secret_ids: Optional list of secret IDs to filter by + key_filter: Optional key substring to filter by + show_inactive: Whether to include inactive secrets + page: Pagination information + + Returns: + QuerySet of Secret objects + """ + # Base queryset + qs = Secret.objects.filter(account_id=account_id) + + # Filtering as per options + if user_id: + qs = qs.filter(created_by_id=user_id) + + if secret_ids: + qs = qs.filter(id__in=secret_ids) + + elif key_filter: + qs = qs.filter(key__icontains=key_filter.lower()) + + elif not show_inactive: + qs = qs.filter(is_active=True) + + return qs.order_by('-created_at') + + @staticmethod + def create_secret(account_id: int, key: str, value: str, + description: str, user) -> Union[Secret, str]: + """ + Create a new secret + + Args: + account_id: The account ID to create the secret under + key: The key for the secret + value: The value of the secret + description: Optional description for the secret + user: The user creating the secret + + Returns: + Secret object if successful, error message string if failed + """ + # Validate required fields + if not key or not value: + return "Key and value are required" + + if not key.isalnum() and not '_' in key: + return "Key must contain only letters, numbers, and underscores" + + if Secret.objects.filter(account_id=account_id, key=key, is_active=True).exists(): + return f"A secret with key '{key}' already exists" + + # Create the secret + try: + secret = Secret.objects.create( + account_id=account_id, + key=key, + value=value, + description=description, + created_by=user, + is_active=True + ) + return secret + except Exception as ex: + logger.exception(f"Error creating secret with key {key}") + return f"Failed to create secret: {str(ex)}" + + @staticmethod + def verify_ownership(secret: Secret, account_id: int, user_id: int) -> bool: + """ + Verify that the secret belongs to the specified account and user + + Args: + secret: The secret to verify + account_id: The account ID to check against + user_id: The user ID to check against + + Returns: + True if the secret belongs to the account and user, False otherwise + """ + return (secret.account_id == account_id and + secret.created_by_id == user_id and + secret.is_active) + + @staticmethod + def update_secret(elem: Secret, update_op: UpdateSecretOp.UpdateSecret, account_id: int, user_id: int) -> Secret: + """ + Update a secret's description and/or value + + Args: + elem: The secret to update + update_op: The update operation to apply + account_id: If provided, verify the secret belongs to this account + user_id: If provided, verify the secret was created by this user + + Returns: + The updated secret + + Raises: + Exception: If the update fails or the user doesn't have permission + """ + # Verify ownership if account_id and user_id are provided + if not SecretsCrudManager.verify_ownership(elem, account_id, user_id): + raise Exception(f"You don't have permission to update secret {elem.key}") + + update_fields = ['updated_at'] + + # Update description if provided and has a value + if update_op.HasField('description') and update_op.description.value: + elem.description = update_op.description.value + update_fields.append('description') + + # Update value if provided and has a value + if update_op.HasField('value') and update_op.value.value: + elem.value = update_op.value.value + update_fields.append('value') + + if len(update_fields) > 1: # Only save if we have fields to update + try: + elem.save(update_fields=update_fields) + except Exception as ex: + logger.exception(f"Error occurred updating secret {elem.key}") + raise Exception(f"Error updating secret: {str(ex)}") + + return elem + + @staticmethod + def update_secret_status(elem: Secret, update_op: UpdateSecretOp.UpdateSecretStatus, account_id: int, user_id: int) -> Secret: + """ + Update a secret's active status (soft delete) + + When deactivating a secret, the key is modified to prevent conflicts + with future secrets using the same key + + Args: + elem: The secret to update + update_op: The update operation to apply + account_id: If provided, verify the secret belongs to this account + user_id: If provided, verify the secret was created by this user + + Returns: + The updated secret + + Raises: + Exception: If the update fails or the user doesn't have permission + """ + # Verify ownership if account_id and user_id are provided + if not SecretsCrudManager.verify_ownership(elem, account_id, user_id): + raise Exception(f"You don't have permission to deactivate secret {elem.key}") + + is_active = update_op.is_active.value + + # We only support deactivation, not reactivation + if is_active or not elem.is_active: + raise Exception(f"Secret {elem.key} cannot be reactivated once deactivated") + + # Use the deactivate method to handle the key modification + if not elem.deactivate(): + raise Exception(f"Secret {elem.key} is already inactive") + + return elem + + def update(self, elem: Secret, update_ops, account_id: int, user_id: int): + """ + Apply a list of update operations to a secret + + Args: + elem: The secret to update + update_ops: The list of update operations to apply + account_id: If provided, verify the secret belongs to this account + user_id: If provided, verify the secret was created by this user + + Returns: + The updated secret + + Raises: + Exception: If any update fails or the user doesn't have permission + """ + if not self.verify_ownership(elem, account_id, user_id): + raise Exception(f"You don't have permission to update secret {elem.key}") + # Apply each update operation + for update_op in update_ops: + if update_op.HasField('update_secret'): + elem = self.update_secret(elem, update_op.update_secret, account_id, user_id) + elif update_op.HasField('update_secret_status'): + elem = self.update_secret_status(elem, update_op.update_secret_status, account_id, user_id) + return elem + + +secrets_crud_manager = SecretsCrudManager() diff --git a/executor/secrets/secret_resolver.py b/executor/secrets/secret_resolver.py new file mode 100644 index 000000000..d6b68a939 --- /dev/null +++ b/executor/secrets/secret_resolver.py @@ -0,0 +1,89 @@ +import re +import logging +from typing import Dict, Set +from protos.ui_definition_pb2 import FormField +from protos.literal_pb2 import LiteralType +from executor.models import Secret +from executor.secrets.crud.secrets_crud_manager import secrets_crud_manager + +logger = logging.getLogger(__name__) + +class SecretResolver: + SECRET_PATTERN = r'!([\w-]+)' + + @classmethod + def extract_secret_refs(cls, value: str) -> Set[str]: + """Extract secret references from a string value""" + return set(re.findall(cls.SECRET_PATTERN, str(value))) + + @classmethod + def replace_secret_refs(cls, value: str, secret_map: Dict[str, str]) -> str: + """Replace secret references with their values""" + result = str(value) + for secret_key, secret_value in secret_map.items(): + result = result.replace(f"!{secret_key}", secret_value) + return result + + @classmethod + def resolve_secrets(cls, form_fields: [FormField], account_id: int, source_type_task_def: Dict) -> Dict: + """ + Resolves secret references (prefixed with !) in task definition + + Args: + form_fields: List of form fields defining the task structure + account_id: ID of the account owning the secrets + source_type_task_def: Task definition dictionary to resolve secrets in + + Returns: + Dict with secrets resolved + """ + try: + # Collect all secret references + string_fields = [ff.key_name.value for ff in form_fields if ff.data_type == LiteralType.STRING] + string_array_fields = [ff.key_name.value for ff in form_fields if ff.data_type == LiteralType.STRING_ARRAY] + + secret_refs = set() + for field_name, value in source_type_task_def.items(): + if field_name in string_fields: + secret_refs.update(cls.extract_secret_refs(value)) + elif field_name in string_array_fields: + for item in value: + secret_refs.update(cls.extract_secret_refs(item)) + + if not secret_refs: + return source_type_task_def + + # Get referenced secrets from DB using the CRUD manager's list method + # Note: We don't filter by user here since secrets might be created by different users + # but need to be accessible for task execution + secrets_qs = secrets_crud_manager.list_secrets( + account_id=account_id, + user_id=None, # Don't filter by user for task execution + secret_ids=None, + key_filter=None, + show_inactive=False + ).filter(key__in=secret_refs).values('key', 'value') + + if not secrets_qs: + logger.warning(f"No secrets found for given references") + return source_type_task_def + + # Create mapping + secret_map = {s['key']: s['value'] for s in secrets_qs} + + # Final resolution + resolved_def = source_type_task_def.copy() + for field_name, value in source_type_task_def.items(): + if field_name in string_fields: + resolved_def[field_name] = cls.replace_secret_refs(value, secret_map) + elif field_name in string_array_fields: + resolved_def[field_name] = [ + cls.replace_secret_refs(item, secret_map) + for item in value + ] + + return resolved_def + + except Exception as e: + logger.error(f"Error resolving secrets: {str(e)}") + return source_type_task_def \ No newline at end of file diff --git a/executor/secrets/urls.py b/executor/secrets/urls.py new file mode 100644 index 000000000..c6a3de645 --- /dev/null +++ b/executor/secrets/urls.py @@ -0,0 +1,9 @@ +from django.urls import path +from executor.secrets import views + +urlpatterns = [ + path('list/', views.secrets_list, name='secrets_list'), + path('get/', views.secret_get, name='secret_get'), + path('create/', views.secret_create, name='secret_create'), + path('update/', views.secret_update, name='secret_update') +] \ No newline at end of file diff --git a/executor/secrets/views.py b/executor/secrets/views.py new file mode 100644 index 000000000..ffcd9785d --- /dev/null +++ b/executor/secrets/views.py @@ -0,0 +1,194 @@ +import logging +from typing import Union +from django.http import HttpResponse +from google.protobuf.wrappers_pb2 import BoolValue, StringValue + +from accounts.models import Account, get_request_account, get_request_user +from executor.models import Secret +from playbooks.utils.decorators import web_api +from playbooks.utils.meta import get_meta +from playbooks.utils.queryset import filter_page +from protos.base_pb2 import Message, Meta, Page +from protos.secrets.api_pb2 import ( + GetSecretsRequest, GetSecretsResponse, + GetSecretRequest, GetSecretResponse, + CreateSecretRequest, CreateSecretResponse, + UpdateSecretRequest, UpdateSecretResponse, +) +from executor.secrets.crud.secrets_crud_manager import secrets_crud_manager + +logger = logging.getLogger(__name__) + +@web_api(GetSecretsRequest) +def secrets_list(request_message: GetSecretsRequest) -> Union[GetSecretsResponse, HttpResponse]: + """List secrets with optional filtering by IDs or key""" + account: Account = get_request_account() + user = get_request_user() + meta: Meta = request_message.meta + show_inactive = meta.show_inactive if meta.show_inactive else False + page: Page = meta.page + + # Get secret IDs if provided + secret_ids = request_message.secret_ids if request_message.secret_ids else None + + # Get key filter if provided + key_filter = request_message.key.value if request_message.key else None + + # Determine if we should show inactive secrets + show_inactive_value = show_inactive.value if show_inactive else False + + # Use the CRUD manager to get the queryset, including user filtering + qs = secrets_crud_manager.list_secrets( + account_id=account.id, + secret_ids=secret_ids, + key_filter=key_filter, + show_inactive=show_inactive_value + ) + + total_count = qs.count() + qs = filter_page(qs, page) + + # Use appropriate proto conversion based on list_all flag + secrets = [secret.to_proto(include_masked_value=False) for secret in qs] + return GetSecretsResponse( + meta=get_meta(page=page, total_count=total_count), + success=BoolValue(value=True), + secrets=secrets + ) + + +@web_api(GetSecretRequest) +def secret_get(request_message: GetSecretRequest) -> Union[GetSecretResponse, HttpResponse]: + """Get a specific secret by ID""" + account: Account = get_request_account() + user = get_request_user() + secret_id = request_message.secret_id.value + + if not secret_id: + return GetSecretResponse( + meta=get_meta(), + success=BoolValue(value=False), + message=Message(title="Invalid Request", description="Secret ID is required") + ) + + # Use the CRUD manager to get the secret + secret = secrets_crud_manager.get_by_id(secret_id, account.id) + if secret: + if secrets_crud_manager.verify_ownership(secret, account.id, user.id): + return GetSecretResponse( + meta=get_meta(), + success=BoolValue(value=True), + secret=secret.to_proto() + ) + else: + # Do not show even masked value if user wasn't the creator + return GetSecretResponse( + meta=get_meta(), + success=BoolValue(value=True), + secret=secret.to_proto(include_masked_value=False) + ) + else: + return GetSecretResponse( + meta=get_meta(), + success=BoolValue(value=False), + message=Message(title="Not Found", description="Secret not found or you don't have permission to view it") + ) + + +@web_api(CreateSecretRequest) +def secret_create(request_message: CreateSecretRequest) -> Union[CreateSecretResponse, HttpResponse]: + """Create a new secret""" + account: Account = get_request_account() + user = get_request_user() + + key = request_message.key.value + value = request_message.value.value + description = request_message.description.value + + # Use the CRUD manager to create the secret + result = secrets_crud_manager.create_secret( + account_id=account.id, + key=key, + value=value, + description=description, + user=user + ) + + # If result is a string, it's an error message + if isinstance(result, str): + return CreateSecretResponse( + meta=get_meta(), + success=BoolValue(value=False), + message=Message(title="Error", description=result) + ) + + # Otherwise, it's a Secret object + return CreateSecretResponse( + meta=get_meta(), + success=BoolValue(value=True), + message=Message(title="Success", description="Secret created successfully"), + secret=result.to_proto() + ) + + +@web_api(UpdateSecretRequest) +def secret_update(request_message: UpdateSecretRequest) -> Union[UpdateSecretResponse, HttpResponse]: + """Update a secret using operations""" + account: Account = get_request_account() + user = get_request_user() + + update_secret_ops = request_message.update_secret_ops + + if not update_secret_ops: + return UpdateSecretResponse( + meta=get_meta(), + success=BoolValue(value=False), + message=Message(title="Invalid Request", description="No update operations provided") + ) + + # All operations should reference the same secret + secret_ids = set(op.secret_id.value for op in update_secret_ops) + if len(secret_ids) != 1: + return UpdateSecretResponse( + meta=get_meta(), + success=BoolValue(value=False), + message=Message(title="Invalid Request", description="All operations must reference the same secret") + ) + + secret_id = list(secret_ids)[0] + + try: + # Use the CRUD manager to get the secret, including user filtering + secret = secrets_crud_manager.get_by_id(secret_id, account.id, user.id) + + if not secret: + return UpdateSecretResponse( + meta=get_meta(), + success=BoolValue(value=False), + message=Message(title="Not Found", description="Secret not found or you don't have permission to update it") + ) + + try: + # Use the CRUD manager to update the secret, passing account and user IDs for permission check + updated_secret = secrets_crud_manager.update(secret, update_secret_ops, account_id=account.id, user_id=user.id) + # Get the updated secret + return UpdateSecretResponse( + meta=get_meta(), + success=BoolValue(value=True), + message=Message(title="Success", description="Secret updated successfully"), + secret=updated_secret.to_proto() + ) + except Exception as e: + logger.error(f"Error updating secret: {str(e)}") + return UpdateSecretResponse( + meta=get_meta(), + success=BoolValue(value=False), + message=Message(title="Error", description=str(e)) + ) + except Exception as e: + logger.error(f"Error updating secret: {str(e)}") + return UpdateSecretResponse( + meta=get_meta(), + success=BoolValue(value=False), + message=Message(title="Error", description="Failed to update secret") + ) diff --git a/playbooks/base_settings.py b/playbooks/base_settings.py index 6004d8944..fc7235cec 100644 --- a/playbooks/base_settings.py +++ b/playbooks/base_settings.py @@ -32,6 +32,9 @@ DEBUG = env.bool("DJANGO_DEBUG", default=True) DATA_UPLOAD_MAX_MEMORY_SIZE = 10485760 +# Set your own field encryption key +FIELD_ENCRYPTION_KEY = env.list("FIELD_ENCRYPTION_KEY", default=['sY2EtYEgvPZk7Fu3pgl-BoVLN4hoXYmpJQdJiNNRlRE=']) + ALLOWED_HOSTS = ['*'] # Application definition diff --git a/playbooks/urls.py b/playbooks/urls.py index ef4b29467..351182ee5 100644 --- a/playbooks/urls.py +++ b/playbooks/urls.py @@ -27,6 +27,7 @@ path('executor/', include('executor.urls')), path('executor/workflows/', include('executor.workflows.urls')), path('executor/engine/', include('executor.engine_manager.urls')), + path('executor/secrets/', include('executor.secrets.urls')), path('pb/', include('executor.urls')), path('management/', include('management.urls')), path('media/', include('media.urls')), diff --git a/protos/secrets/api.proto b/protos/secrets/api.proto new file mode 100644 index 000000000..07835a42c --- /dev/null +++ b/protos/secrets/api.proto @@ -0,0 +1,91 @@ +syntax = "proto3"; +package protos.secrets; + +import "google/protobuf/wrappers.proto"; +import "protos/base.proto"; + +message Secret { + google.protobuf.StringValue id = 1; + google.protobuf.StringValue key = 2; + google.protobuf.StringValue masked_value = 3; + google.protobuf.StringValue description = 4; + google.protobuf.StringValue created_by = 5; + int64 created_at = 6; + int64 updated_at = 7; + bool is_active = 8; +} + +message GetSecretsRequest { + Meta meta = 1; + repeated string secret_ids = 2; // Optional list of secret IDs to fetch + google.protobuf.StringValue key = 3; // Optional key to filter by (key_name) +} + +message GetSecretsResponse { + Meta meta = 1; + google.protobuf.BoolValue success = 2; + Message message = 3; + repeated Secret secrets = 4; +} + +message GetSecretRequest { + Meta meta = 1; + google.protobuf.StringValue secret_id = 2; +} + +message GetSecretResponse { + Meta meta = 1; + google.protobuf.BoolValue success = 2; + Message message = 3; + Secret secret = 4; +} + +message CreateSecretRequest { + Meta meta = 1; + google.protobuf.StringValue key = 2; + google.protobuf.StringValue value = 3; + google.protobuf.StringValue description = 4; +} + +message CreateSecretResponse { + Meta meta = 1; + google.protobuf.BoolValue success = 2; + Message message = 3; + Secret secret = 4; +} + +message UpdateSecretOp { + enum Op { + UNKNOWN = 0; + UPDATE_SECRET = 1; + UPDATE_SECRET_STATUS = 2; + } + + message UpdateSecret { + google.protobuf.StringValue description = 1; + google.protobuf.StringValue value = 2; + } + + message UpdateSecretStatus { + google.protobuf.BoolValue is_active = 1; + } + + Op op = 1; + google.protobuf.StringValue secret_id = 2; + oneof update { + UpdateSecret update_secret = 3; + UpdateSecretStatus update_secret_status = 4; + } +} + +message UpdateSecretRequest { + Meta meta = 1; + repeated UpdateSecretOp update_secret_ops = 2; +} + +message UpdateSecretResponse { + Meta meta = 1; + google.protobuf.BoolValue success = 2; + Message message = 3; + Secret secret = 4; +} diff --git a/protos/secrets/api_pb2.py b/protos/secrets/api_pb2.py new file mode 100644 index 000000000..292789871 --- /dev/null +++ b/protos/secrets/api_pb2.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: protos/secrets/api.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import builder as _builder +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from google.protobuf import wrappers_pb2 as google_dot_protobuf_dot_wrappers__pb2 +from protos import base_pb2 as protos_dot_base__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x18protos/secrets/api.proto\x12\x0eprotos.secrets\x1a\x1egoogle/protobuf/wrappers.proto\x1a\x11protos/base.proto\"\xb1\x02\n\x06Secret\x12(\n\x02id\x18\x01 \x01(\x0b\x32\x1c.google.protobuf.StringValue\x12)\n\x03key\x18\x02 \x01(\x0b\x32\x1c.google.protobuf.StringValue\x12\x32\n\x0cmasked_value\x18\x03 \x01(\x0b\x32\x1c.google.protobuf.StringValue\x12\x31\n\x0b\x64\x65scription\x18\x04 \x01(\x0b\x32\x1c.google.protobuf.StringValue\x12\x30\n\ncreated_by\x18\x05 \x01(\x0b\x32\x1c.google.protobuf.StringValue\x12\x12\n\ncreated_at\x18\x06 \x01(\x03\x12\x12\n\nupdated_at\x18\x07 \x01(\x03\x12\x11\n\tis_active\x18\x08 \x01(\x08\"n\n\x11GetSecretsRequest\x12\x1a\n\x04meta\x18\x01 \x01(\x0b\x32\x0c.protos.Meta\x12\x12\n\nsecret_ids\x18\x02 \x03(\t\x12)\n\x03key\x18\x03 \x01(\x0b\x32\x1c.google.protobuf.StringValue\"\xa8\x01\n\x12GetSecretsResponse\x12\x1a\n\x04meta\x18\x01 \x01(\x0b\x32\x0c.protos.Meta\x12+\n\x07success\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.BoolValue\x12 \n\x07message\x18\x03 \x01(\x0b\x32\x0f.protos.Message\x12\'\n\x07secrets\x18\x04 \x03(\x0b\x32\x16.protos.secrets.Secret\"_\n\x10GetSecretRequest\x12\x1a\n\x04meta\x18\x01 \x01(\x0b\x32\x0c.protos.Meta\x12/\n\tsecret_id\x18\x02 \x01(\x0b\x32\x1c.google.protobuf.StringValue\"\xa6\x01\n\x11GetSecretResponse\x12\x1a\n\x04meta\x18\x01 \x01(\x0b\x32\x0c.protos.Meta\x12+\n\x07success\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.BoolValue\x12 \n\x07message\x18\x03 \x01(\x0b\x32\x0f.protos.Message\x12&\n\x06secret\x18\x04 \x01(\x0b\x32\x16.protos.secrets.Secret\"\xbc\x01\n\x13\x43reateSecretRequest\x12\x1a\n\x04meta\x18\x01 \x01(\x0b\x32\x0c.protos.Meta\x12)\n\x03key\x18\x02 \x01(\x0b\x32\x1c.google.protobuf.StringValue\x12+\n\x05value\x18\x03 \x01(\x0b\x32\x1c.google.protobuf.StringValue\x12\x31\n\x0b\x64\x65scription\x18\x04 \x01(\x0b\x32\x1c.google.protobuf.StringValue\"\xa9\x01\n\x14\x43reateSecretResponse\x12\x1a\n\x04meta\x18\x01 \x01(\x0b\x32\x0c.protos.Meta\x12+\n\x07success\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.BoolValue\x12 \n\x07message\x18\x03 \x01(\x0b\x32\x0f.protos.Message\x12&\n\x06secret\x18\x04 \x01(\x0b\x32\x16.protos.secrets.Secret\"\x88\x04\n\x0eUpdateSecretOp\x12-\n\x02op\x18\x01 \x01(\x0e\x32!.protos.secrets.UpdateSecretOp.Op\x12/\n\tsecret_id\x18\x02 \x01(\x0b\x32\x1c.google.protobuf.StringValue\x12\x44\n\rupdate_secret\x18\x03 \x01(\x0b\x32+.protos.secrets.UpdateSecretOp.UpdateSecretH\x00\x12Q\n\x14update_secret_status\x18\x04 \x01(\x0b\x32\x31.protos.secrets.UpdateSecretOp.UpdateSecretStatusH\x00\x1an\n\x0cUpdateSecret\x12\x31\n\x0b\x64\x65scription\x18\x01 \x01(\x0b\x32\x1c.google.protobuf.StringValue\x12+\n\x05value\x18\x02 \x01(\x0b\x32\x1c.google.protobuf.StringValue\x1a\x43\n\x12UpdateSecretStatus\x12-\n\tis_active\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.BoolValue\">\n\x02Op\x12\x0b\n\x07UNKNOWN\x10\x00\x12\x11\n\rUPDATE_SECRET\x10\x01\x12\x18\n\x14UPDATE_SECRET_STATUS\x10\x02\x42\x08\n\x06update\"l\n\x13UpdateSecretRequest\x12\x1a\n\x04meta\x18\x01 \x01(\x0b\x32\x0c.protos.Meta\x12\x39\n\x11update_secret_ops\x18\x02 \x03(\x0b\x32\x1e.protos.secrets.UpdateSecretOp\"\xa9\x01\n\x14UpdateSecretResponse\x12\x1a\n\x04meta\x18\x01 \x01(\x0b\x32\x0c.protos.Meta\x12+\n\x07success\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.BoolValue\x12 \n\x07message\x18\x03 \x01(\x0b\x32\x0f.protos.Message\x12&\n\x06secret\x18\x04 \x01(\x0b\x32\x16.protos.secrets.Secretb\x06proto3') + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'protos.secrets.api_pb2', globals()) +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + _SECRET._serialized_start=96 + _SECRET._serialized_end=401 + _GETSECRETSREQUEST._serialized_start=403 + _GETSECRETSREQUEST._serialized_end=513 + _GETSECRETSRESPONSE._serialized_start=516 + _GETSECRETSRESPONSE._serialized_end=684 + _GETSECRETREQUEST._serialized_start=686 + _GETSECRETREQUEST._serialized_end=781 + _GETSECRETRESPONSE._serialized_start=784 + _GETSECRETRESPONSE._serialized_end=950 + _CREATESECRETREQUEST._serialized_start=953 + _CREATESECRETREQUEST._serialized_end=1141 + _CREATESECRETRESPONSE._serialized_start=1144 + _CREATESECRETRESPONSE._serialized_end=1313 + _UPDATESECRETOP._serialized_start=1316 + _UPDATESECRETOP._serialized_end=1836 + _UPDATESECRETOP_UPDATESECRET._serialized_start=1583 + _UPDATESECRETOP_UPDATESECRET._serialized_end=1693 + _UPDATESECRETOP_UPDATESECRETSTATUS._serialized_start=1695 + _UPDATESECRETOP_UPDATESECRETSTATUS._serialized_end=1762 + _UPDATESECRETOP_OP._serialized_start=1764 + _UPDATESECRETOP_OP._serialized_end=1826 + _UPDATESECRETREQUEST._serialized_start=1838 + _UPDATESECRETREQUEST._serialized_end=1946 + _UPDATESECRETRESPONSE._serialized_start=1949 + _UPDATESECRETRESPONSE._serialized_end=2118 +# @@protoc_insertion_point(module_scope) diff --git a/protos/secrets/api_pb2.pyi b/protos/secrets/api_pb2.pyi new file mode 100644 index 000000000..c10502c3e --- /dev/null +++ b/protos/secrets/api_pb2.pyi @@ -0,0 +1,355 @@ +""" +@generated by mypy-protobuf. Do not edit manually! +isort:skip_file +""" +import builtins +import collections.abc +import google.protobuf.descriptor +import google.protobuf.internal.containers +import google.protobuf.internal.enum_type_wrapper +import google.protobuf.message +import google.protobuf.wrappers_pb2 +import protos.base_pb2 +import sys +import typing + +if sys.version_info >= (3, 10): + import typing as typing_extensions +else: + import typing_extensions + +DESCRIPTOR: google.protobuf.descriptor.FileDescriptor + +@typing_extensions.final +class Secret(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + ID_FIELD_NUMBER: builtins.int + KEY_FIELD_NUMBER: builtins.int + MASKED_VALUE_FIELD_NUMBER: builtins.int + DESCRIPTION_FIELD_NUMBER: builtins.int + CREATED_BY_FIELD_NUMBER: builtins.int + CREATED_AT_FIELD_NUMBER: builtins.int + UPDATED_AT_FIELD_NUMBER: builtins.int + IS_ACTIVE_FIELD_NUMBER: builtins.int + @property + def id(self) -> google.protobuf.wrappers_pb2.StringValue: ... + @property + def key(self) -> google.protobuf.wrappers_pb2.StringValue: ... + @property + def masked_value(self) -> google.protobuf.wrappers_pb2.StringValue: ... + @property + def description(self) -> google.protobuf.wrappers_pb2.StringValue: ... + @property + def created_by(self) -> google.protobuf.wrappers_pb2.StringValue: ... + created_at: builtins.int + updated_at: builtins.int + is_active: builtins.bool + def __init__( + self, + *, + id: google.protobuf.wrappers_pb2.StringValue | None = ..., + key: google.protobuf.wrappers_pb2.StringValue | None = ..., + masked_value: google.protobuf.wrappers_pb2.StringValue | None = ..., + description: google.protobuf.wrappers_pb2.StringValue | None = ..., + created_by: google.protobuf.wrappers_pb2.StringValue | None = ..., + created_at: builtins.int = ..., + updated_at: builtins.int = ..., + is_active: builtins.bool = ..., + ) -> None: ... + def HasField(self, field_name: typing_extensions.Literal["created_by", b"created_by", "description", b"description", "id", b"id", "key", b"key", "masked_value", b"masked_value"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["created_at", b"created_at", "created_by", b"created_by", "description", b"description", "id", b"id", "is_active", b"is_active", "key", b"key", "masked_value", b"masked_value", "updated_at", b"updated_at"]) -> None: ... + +global___Secret = Secret + +@typing_extensions.final +class GetSecretsRequest(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + META_FIELD_NUMBER: builtins.int + SECRET_IDS_FIELD_NUMBER: builtins.int + KEY_FIELD_NUMBER: builtins.int + @property + def meta(self) -> protos.base_pb2.Meta: ... + @property + def secret_ids(self) -> google.protobuf.internal.containers.RepeatedScalarFieldContainer[builtins.str]: + """Optional list of secret IDs to fetch""" + @property + def key(self) -> google.protobuf.wrappers_pb2.StringValue: + """Optional key to filter by (key_name)""" + def __init__( + self, + *, + meta: protos.base_pb2.Meta | None = ..., + secret_ids: collections.abc.Iterable[builtins.str] | None = ..., + key: google.protobuf.wrappers_pb2.StringValue | None = ..., + ) -> None: ... + def HasField(self, field_name: typing_extensions.Literal["key", b"key", "meta", b"meta"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["key", b"key", "meta", b"meta", "secret_ids", b"secret_ids"]) -> None: ... + +global___GetSecretsRequest = GetSecretsRequest + +@typing_extensions.final +class GetSecretsResponse(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + META_FIELD_NUMBER: builtins.int + SUCCESS_FIELD_NUMBER: builtins.int + MESSAGE_FIELD_NUMBER: builtins.int + SECRETS_FIELD_NUMBER: builtins.int + @property + def meta(self) -> protos.base_pb2.Meta: ... + @property + def success(self) -> google.protobuf.wrappers_pb2.BoolValue: ... + @property + def message(self) -> protos.base_pb2.Message: ... + @property + def secrets(self) -> google.protobuf.internal.containers.RepeatedCompositeFieldContainer[global___Secret]: ... + def __init__( + self, + *, + meta: protos.base_pb2.Meta | None = ..., + success: google.protobuf.wrappers_pb2.BoolValue | None = ..., + message: protos.base_pb2.Message | None = ..., + secrets: collections.abc.Iterable[global___Secret] | None = ..., + ) -> None: ... + def HasField(self, field_name: typing_extensions.Literal["message", b"message", "meta", b"meta", "success", b"success"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["message", b"message", "meta", b"meta", "secrets", b"secrets", "success", b"success"]) -> None: ... + +global___GetSecretsResponse = GetSecretsResponse + +@typing_extensions.final +class GetSecretRequest(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + META_FIELD_NUMBER: builtins.int + SECRET_ID_FIELD_NUMBER: builtins.int + @property + def meta(self) -> protos.base_pb2.Meta: ... + @property + def secret_id(self) -> google.protobuf.wrappers_pb2.StringValue: ... + def __init__( + self, + *, + meta: protos.base_pb2.Meta | None = ..., + secret_id: google.protobuf.wrappers_pb2.StringValue | None = ..., + ) -> None: ... + def HasField(self, field_name: typing_extensions.Literal["meta", b"meta", "secret_id", b"secret_id"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["meta", b"meta", "secret_id", b"secret_id"]) -> None: ... + +global___GetSecretRequest = GetSecretRequest + +@typing_extensions.final +class GetSecretResponse(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + META_FIELD_NUMBER: builtins.int + SUCCESS_FIELD_NUMBER: builtins.int + MESSAGE_FIELD_NUMBER: builtins.int + SECRET_FIELD_NUMBER: builtins.int + @property + def meta(self) -> protos.base_pb2.Meta: ... + @property + def success(self) -> google.protobuf.wrappers_pb2.BoolValue: ... + @property + def message(self) -> protos.base_pb2.Message: ... + @property + def secret(self) -> global___Secret: ... + def __init__( + self, + *, + meta: protos.base_pb2.Meta | None = ..., + success: google.protobuf.wrappers_pb2.BoolValue | None = ..., + message: protos.base_pb2.Message | None = ..., + secret: global___Secret | None = ..., + ) -> None: ... + def HasField(self, field_name: typing_extensions.Literal["message", b"message", "meta", b"meta", "secret", b"secret", "success", b"success"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["message", b"message", "meta", b"meta", "secret", b"secret", "success", b"success"]) -> None: ... + +global___GetSecretResponse = GetSecretResponse + +@typing_extensions.final +class CreateSecretRequest(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + META_FIELD_NUMBER: builtins.int + KEY_FIELD_NUMBER: builtins.int + VALUE_FIELD_NUMBER: builtins.int + DESCRIPTION_FIELD_NUMBER: builtins.int + @property + def meta(self) -> protos.base_pb2.Meta: ... + @property + def key(self) -> google.protobuf.wrappers_pb2.StringValue: ... + @property + def value(self) -> google.protobuf.wrappers_pb2.StringValue: ... + @property + def description(self) -> google.protobuf.wrappers_pb2.StringValue: ... + def __init__( + self, + *, + meta: protos.base_pb2.Meta | None = ..., + key: google.protobuf.wrappers_pb2.StringValue | None = ..., + value: google.protobuf.wrappers_pb2.StringValue | None = ..., + description: google.protobuf.wrappers_pb2.StringValue | None = ..., + ) -> None: ... + def HasField(self, field_name: typing_extensions.Literal["description", b"description", "key", b"key", "meta", b"meta", "value", b"value"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["description", b"description", "key", b"key", "meta", b"meta", "value", b"value"]) -> None: ... + +global___CreateSecretRequest = CreateSecretRequest + +@typing_extensions.final +class CreateSecretResponse(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + META_FIELD_NUMBER: builtins.int + SUCCESS_FIELD_NUMBER: builtins.int + MESSAGE_FIELD_NUMBER: builtins.int + SECRET_FIELD_NUMBER: builtins.int + @property + def meta(self) -> protos.base_pb2.Meta: ... + @property + def success(self) -> google.protobuf.wrappers_pb2.BoolValue: ... + @property + def message(self) -> protos.base_pb2.Message: ... + @property + def secret(self) -> global___Secret: ... + def __init__( + self, + *, + meta: protos.base_pb2.Meta | None = ..., + success: google.protobuf.wrappers_pb2.BoolValue | None = ..., + message: protos.base_pb2.Message | None = ..., + secret: global___Secret | None = ..., + ) -> None: ... + def HasField(self, field_name: typing_extensions.Literal["message", b"message", "meta", b"meta", "secret", b"secret", "success", b"success"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["message", b"message", "meta", b"meta", "secret", b"secret", "success", b"success"]) -> None: ... + +global___CreateSecretResponse = CreateSecretResponse + +@typing_extensions.final +class UpdateSecretOp(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + class _Op: + ValueType = typing.NewType("ValueType", builtins.int) + V: typing_extensions.TypeAlias = ValueType + + class _OpEnumTypeWrapper(google.protobuf.internal.enum_type_wrapper._EnumTypeWrapper[UpdateSecretOp._Op.ValueType], builtins.type): # noqa: F821 + DESCRIPTOR: google.protobuf.descriptor.EnumDescriptor + UNKNOWN: UpdateSecretOp._Op.ValueType # 0 + UPDATE_SECRET: UpdateSecretOp._Op.ValueType # 1 + UPDATE_SECRET_STATUS: UpdateSecretOp._Op.ValueType # 2 + + class Op(_Op, metaclass=_OpEnumTypeWrapper): ... + UNKNOWN: UpdateSecretOp.Op.ValueType # 0 + UPDATE_SECRET: UpdateSecretOp.Op.ValueType # 1 + UPDATE_SECRET_STATUS: UpdateSecretOp.Op.ValueType # 2 + + @typing_extensions.final + class UpdateSecret(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + DESCRIPTION_FIELD_NUMBER: builtins.int + VALUE_FIELD_NUMBER: builtins.int + @property + def description(self) -> google.protobuf.wrappers_pb2.StringValue: ... + @property + def value(self) -> google.protobuf.wrappers_pb2.StringValue: ... + def __init__( + self, + *, + description: google.protobuf.wrappers_pb2.StringValue | None = ..., + value: google.protobuf.wrappers_pb2.StringValue | None = ..., + ) -> None: ... + def HasField(self, field_name: typing_extensions.Literal["description", b"description", "value", b"value"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["description", b"description", "value", b"value"]) -> None: ... + + @typing_extensions.final + class UpdateSecretStatus(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + IS_ACTIVE_FIELD_NUMBER: builtins.int + @property + def is_active(self) -> google.protobuf.wrappers_pb2.BoolValue: ... + def __init__( + self, + *, + is_active: google.protobuf.wrappers_pb2.BoolValue | None = ..., + ) -> None: ... + def HasField(self, field_name: typing_extensions.Literal["is_active", b"is_active"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["is_active", b"is_active"]) -> None: ... + + OP_FIELD_NUMBER: builtins.int + SECRET_ID_FIELD_NUMBER: builtins.int + UPDATE_SECRET_FIELD_NUMBER: builtins.int + UPDATE_SECRET_STATUS_FIELD_NUMBER: builtins.int + op: global___UpdateSecretOp.Op.ValueType + @property + def secret_id(self) -> google.protobuf.wrappers_pb2.StringValue: ... + @property + def update_secret(self) -> global___UpdateSecretOp.UpdateSecret: ... + @property + def update_secret_status(self) -> global___UpdateSecretOp.UpdateSecretStatus: ... + def __init__( + self, + *, + op: global___UpdateSecretOp.Op.ValueType = ..., + secret_id: google.protobuf.wrappers_pb2.StringValue | None = ..., + update_secret: global___UpdateSecretOp.UpdateSecret | None = ..., + update_secret_status: global___UpdateSecretOp.UpdateSecretStatus | None = ..., + ) -> None: ... + def HasField(self, field_name: typing_extensions.Literal["secret_id", b"secret_id", "update", b"update", "update_secret", b"update_secret", "update_secret_status", b"update_secret_status"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["op", b"op", "secret_id", b"secret_id", "update", b"update", "update_secret", b"update_secret", "update_secret_status", b"update_secret_status"]) -> None: ... + def WhichOneof(self, oneof_group: typing_extensions.Literal["update", b"update"]) -> typing_extensions.Literal["update_secret", "update_secret_status"] | None: ... + +global___UpdateSecretOp = UpdateSecretOp + +@typing_extensions.final +class UpdateSecretRequest(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + META_FIELD_NUMBER: builtins.int + UPDATE_SECRET_OPS_FIELD_NUMBER: builtins.int + @property + def meta(self) -> protos.base_pb2.Meta: ... + @property + def update_secret_ops(self) -> google.protobuf.internal.containers.RepeatedCompositeFieldContainer[global___UpdateSecretOp]: ... + def __init__( + self, + *, + meta: protos.base_pb2.Meta | None = ..., + update_secret_ops: collections.abc.Iterable[global___UpdateSecretOp] | None = ..., + ) -> None: ... + def HasField(self, field_name: typing_extensions.Literal["meta", b"meta"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["meta", b"meta", "update_secret_ops", b"update_secret_ops"]) -> None: ... + +global___UpdateSecretRequest = UpdateSecretRequest + +@typing_extensions.final +class UpdateSecretResponse(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + META_FIELD_NUMBER: builtins.int + SUCCESS_FIELD_NUMBER: builtins.int + MESSAGE_FIELD_NUMBER: builtins.int + SECRET_FIELD_NUMBER: builtins.int + @property + def meta(self) -> protos.base_pb2.Meta: ... + @property + def success(self) -> google.protobuf.wrappers_pb2.BoolValue: ... + @property + def message(self) -> protos.base_pb2.Message: ... + @property + def secret(self) -> global___Secret: ... + def __init__( + self, + *, + meta: protos.base_pb2.Meta | None = ..., + success: google.protobuf.wrappers_pb2.BoolValue | None = ..., + message: protos.base_pb2.Message | None = ..., + secret: global___Secret | None = ..., + ) -> None: ... + def HasField(self, field_name: typing_extensions.Literal["message", b"message", "meta", b"meta", "secret", b"secret", "success", b"success"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["message", b"message", "meta", b"meta", "secret", b"secret", "success", b"success"]) -> None: ... + +global___UpdateSecretResponse = UpdateSecretResponse diff --git a/requirements.txt b/requirements.txt index 393550702..3d114a77c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,6 +20,7 @@ django-allauth==0.52.0 django-celery-beat==2.4.0 django-celery-email==3.0.0 django-cors-headers==3.13.0 +django-encrypted-model-fields==0.6.5 django-filter==22.1 django-prometheus==2.4.0.dev5 django-redis==5.2.0 diff --git a/web/.DS_Store b/web/.DS_Store deleted file mode 100644 index a0418a4e5..000000000 Binary files a/web/.DS_Store and /dev/null differ diff --git a/web/public/.DS_Store b/web/public/.DS_Store deleted file mode 100644 index 0bac88180..000000000 Binary files a/web/public/.DS_Store and /dev/null differ diff --git a/web/src/components.ts b/web/src/components.ts index 45c18436d..b27008241 100644 --- a/web/src/components.ts +++ b/web/src/components.ts @@ -42,4 +42,5 @@ export const components = { import("./pages/dynamicAlerts/CreateDynamicAlert"), [PageKeys.DYNAMIC_ALERT_VIEW]: () => import("./pages/dynamicAlerts/CreateDynamicAlert"), + [PageKeys.SECRET_MANAGEMENT]: () => import("./pages/secret-management"), }; diff --git a/web/src/components/Inputs/HandleInputRender.tsx b/web/src/components/Inputs/HandleInputRender.tsx index 7144ca69e..99c5e779a 100644 --- a/web/src/components/Inputs/HandleInputRender.tsx +++ b/web/src/components/Inputs/HandleInputRender.tsx @@ -15,6 +15,7 @@ import TypingDropdownMultipleSelectionInput from "./InputTypes/TypingDropdownMul import TextButton from "./InputTypes/TextButton.tsx"; import CronInput from "../common/CronInput/index.tsx"; import Checkbox from "../common/Checkbox/index.tsx"; +import SearchInput from "./InputTypes/SearchInput.tsx"; export type HandleInputRenderType = { inputType: InputType; @@ -48,6 +49,8 @@ function HandleInputRender({ inputType, ...props }: HandleInputRenderType) { switch (inputType) { case InputTypes.TEXT: return ; + case InputTypes.SEARCH: + return ; case InputTypes.MULTILINE: return ; case InputTypes.BUTTON: diff --git a/web/src/components/Inputs/InputTypes/SearchInput.tsx b/web/src/components/Inputs/InputTypes/SearchInput.tsx new file mode 100644 index 000000000..733038ea8 --- /dev/null +++ b/web/src/components/Inputs/InputTypes/SearchInput.tsx @@ -0,0 +1,25 @@ +import { SearchRounded } from "@mui/icons-material"; +import CustomInput from "../CustomInput.tsx"; +import { InputTypes } from "../../../types/index.ts"; +import { HandleInputRenderType } from "../HandleInputRender"; + +function SearchInput(props: Omit) { + return ( +
+
+ +
+ +
+ ); +} + +export default SearchInput; diff --git a/web/src/components/Sidebar/index.tsx b/web/src/components/Sidebar/index.tsx index 4b9456220..28b7733b3 100644 --- a/web/src/components/Sidebar/index.tsx +++ b/web/src/components/Sidebar/index.tsx @@ -4,7 +4,12 @@ import useToggle from "../../hooks/common/useToggle"; import SlackConnectOverlay from "../SlackConnectOverlay"; import { elements } from "./utils"; import SidebarElement from "./SidebarElement"; -import { LogoutRounded, SettingsRounded } from "@mui/icons-material"; +import { + LockRounded, + LogoutRounded, + SecurityRounded, + SettingsRounded, +} from "@mui/icons-material"; import SidebarButtonElement from "./SidebarButtonElement"; import HeadElement from "./HeadElement"; import useSidebar from "../../hooks/common/sidebar/useSidebar"; @@ -36,6 +41,11 @@ function Sidebar() {
+ } + /> { + const [deleteVariable, { isLoading, isSuccess, status }] = + useDeleteSecretMutation(); + const handleSuccess = () => { + deleteVariable({ id: secret.id }); + }; + + useEffect(() => { + if (isSuccess) { + toggleOverlay(); + refreshTable(); + } + }, [status]); + + return ( + <> + {isOpen && ( + +
+
+ Delete {secret.name}? +
+
+ Cancel + Yes + {isLoading && ( + + )} +
+
+
+ )} + + ); +}; + +export default SecretActionOverlay; diff --git a/web/src/components/secret-management/create/CreateSecretButton.tsx b/web/src/components/secret-management/create/CreateSecretButton.tsx new file mode 100644 index 000000000..c61b30f79 --- /dev/null +++ b/web/src/components/secret-management/create/CreateSecretButton.tsx @@ -0,0 +1,32 @@ +import useToggle from "../../../hooks/common/useToggle"; +import CustomButton from "../../common/CustomButton"; +import { AddRounded } from "@mui/icons-material"; +import SecretCreateOverlay from "./SecretCreateOverlay"; + +type CreateVariableButtonProps = { + buttonText?: string; +}; + +function CreateSecretButton({ + buttonText = "Secret", +}: CreateVariableButtonProps) { + const { isOpen: isActionOpen, toggle } = useToggle(); + + const handleCreateVariable = () => { + toggle(); + }; + + return ( +
+ + {buttonText} + + + +
+ ); +} + +export default CreateSecretButton; diff --git a/web/src/components/secret-management/create/CreateSecretForm.tsx b/web/src/components/secret-management/create/CreateSecretForm.tsx new file mode 100644 index 000000000..c70933631 --- /dev/null +++ b/web/src/components/secret-management/create/CreateSecretForm.tsx @@ -0,0 +1,185 @@ +import CustomInput from "../../Inputs/CustomInput"; +import { InputTypes } from "../../../types"; +import { useDispatch, useSelector } from "react-redux"; +import CustomButton from "../../common/CustomButton"; +import { showSnackbar } from "../../../store/features/snackbar/snackbarSlice"; +import { CircularProgress } from "@mui/material"; +import React, { useEffect } from "react"; +import TableLoader from "../../Skeleton/TableLoader"; +import { secretsSelector } from "../../../store/features/secrets/selectors"; +import { + resetSecretState, + setSecretKey, +} from "../../../store/features/secrets/secretsSlice"; +import { SecretsInitialState } from "../../../store/features/secrets/initialState"; +import { + useCreateSecretMutation, + useGetSecretQuery, + useUpdateSecretMutation, +} from "../../../store/features/secrets/api"; +import { SaveRounded } from "@mui/icons-material"; + +type CreateSecretFormProps = { + toggleOverlay?: () => void; + id?: string; +}; + +function CreateSecretForm({ + toggleOverlay = () => {}, + id, +}: CreateSecretFormProps) { + const dispatch = useDispatch(); + const [triggerCreate, { isLoading }] = useCreateSecretMutation(); + const [triggerUpdate, { isLoading: updateLoading }] = + useUpdateSecretMutation(); + const secret = useSelector(secretsSelector); + const { key, description, value } = secret; + const { isLoading: isLoadingSecret } = useGetSecretQuery( + { + id: id!, + }, + { + skip: !id, + refetchOnMountOrArgChange: true, + }, + ); + + const handleChange = (key: keyof SecretsInitialState, value: any) => { + dispatch( + setSecretKey({ + key, + value, + }), + ); + }; + + const validate = () => { + let error = ""; + if (!key || !value) { + error = "Please fill all the required fields"; + } + + if (error) { + dispatch(showSnackbar({ message: error, type: "error" })); + } + return error; + }; + + const handleCreate = async ( + e: React.MouseEvent, + ) => { + e.preventDefault(); + if (validate()) return; + + try { + await triggerCreate(secret).unwrap(); + dispatch(resetSecretState()); + toggleOverlay(); + } catch (e: any) { + dispatch( + showSnackbar({ + message: `Failed to create variable: ${JSON.stringify( + e?.message ?? e, + )}`, + type: "error", + }), + ); + } + }; + + const handleUpdate = async ( + e: React.MouseEvent, + ) => { + e.preventDefault(); + if (validate()) return; + + try { + await triggerUpdate({ + id: id!, + key, + description, + value: value, + }).unwrap(); + dispatch(resetSecretState()); + toggleOverlay(); + } catch (e: any) { + dispatch( + showSnackbar({ + message: `Failed to update variable: ${JSON.stringify( + e?.message ?? e, + )}`, + type: "error", + }), + ); + } + }; + + useEffect(() => { + return () => { + dispatch(resetSecretState()); + }; + }, [dispatch]); + + if (isLoadingSecret) { + return ( +
+ +
+ ); + } + + return ( +
+ handleChange("key", value)} + containerClassName="!w-full" + className="w-full" + disabled={id !== undefined} + /> + handleChange("description", value)} + containerClassName="!w-full" + className="w-full !h-20" + /> + handleChange("value", value)} + containerClassName="!w-full" + className="w-full" + /> +
+ {id ? ( + + + Update + + ) : ( + + + Save + + )} + {(isLoading || updateLoading) && } +
+ + ); +} + +export default CreateSecretForm; diff --git a/web/src/components/secret-management/create/SecretCreateOverlay.tsx b/web/src/components/secret-management/create/SecretCreateOverlay.tsx new file mode 100644 index 000000000..6310d6cd2 --- /dev/null +++ b/web/src/components/secret-management/create/SecretCreateOverlay.tsx @@ -0,0 +1,32 @@ +import { CloseRounded } from "@mui/icons-material"; +import Overlay from "../../Overlay"; +import CreateSecretForm from "./CreateSecretForm"; + +type SecretCreateOverlayProps = { + isOpen: boolean; + toggleOverlay: () => void; + id?: string; +}; + +const SecretCreateOverlay = ({ + isOpen, + toggleOverlay, + id, +}: SecretCreateOverlayProps) => { + return ( + <> + {isOpen && ( + +
+
+ +
+ +
+
+ )} + + ); +}; + +export default SecretCreateOverlay; diff --git a/web/src/components/secret-management/index.module.css b/web/src/components/secret-management/index.module.css new file mode 100644 index 000000000..71a144ae7 --- /dev/null +++ b/web/src/components/secret-management/index.module.css @@ -0,0 +1,887 @@ +.link { + text-decoration: underline; + color: #9553fe; +} + +div.ruleBox { + padding-left: 10px; + padding-right: 10px; + padding-top: 10px; + border: thin solid black; + padding-bottom: 0px; + background-color: #efebf4; + border-radius: 10px; + margin-top: 5px; +} + +.row__container { + padding: 12px 12px; + border-bottom: 1px solid #e5e7eb; + padding: 12px 12px; + /* background-color: #f8f8f8; */ + /* background-color: #fafafa; */ + display: flex; + flex-direction: row; + /* border-radius: 4px; */ + /* box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.05); */ +} + +.row__container .eventTypeSelectionSection { + display: flex; + flex-direction: row; + gap: 10px; + margin-bottom: 10px; + min-width: 200px; +} + +.row__container .eventTypeSelectionSection input { + width: 200px; +} + +.row__container .eventTypeConditionsSection { +} + +/* .row__container .content { + font-style: normal; + font-weight: 400; + font-size: 12px; + line-height: 150%; + color: #6b7280; + padding-top: 0px !important; + min-width: 100px; + } */ + +.right { + font-style: normal; + font-weight: 400; + font-size: 12px; + line-height: 150%; + color: #6b7280; + padding-top: 4px; + min-width: 100px; + text-align: right; +} + +.halfContainer { + width: 50%; +} + +.horizontal { + flex-direction: row; + display: flex; +} + +.row__container .heading { + padding-bottom: 12px; + gap: 10px; + padding: 10px; + background-color: #f8fafc; +} + +.row__container .heading-h { + padding-bottom: 12px; + gap: 10px; + padding: 15px; + flex-direction: row; + display: flex; + border-bottom: 1px solid lightgray; +} + +.row__container .content { + font-style: normal; + font-weight: 600; + font-size: 12px; + line-height: 150%; + color: #6b7280; + /* padding-top: 4px; */ + padding-top: 0px; + min-width: 100px; + margin-bottom: 8px; + /* width: 200px; */ +} + +.row__container .panelContent { + /* display: flex; + flex-direction: row; */ + /* min-height: 100vh; */ +} + +.row__container .panelConfig { + /* width: 40%; */ + /* float: left; */ +} + +.select__container { + width: 400px; + display: flex; + flex-direction: row; +} + +.groupByList { + width: 400px; + display: flex; + flex-direction: row; + margin-bottom: 8px; +} + +.row__container .panelPlot { + /* width: 60%; + float: right; + margin: 10px; */ +} + +.metricLabelName { + color: #ffffff; + background-color: #9553fe; + font-style: normal; + font-weight: 600; + font-size: 12px; + line-height: 150%; + min-width: 100px; + padding: 5px; + border-radius: 5px; +} + +.vl { + border-left: 1px solid lightgray; +} + +.tableChart { + display: flex; + flex-direction: column; + margin-top: 20px; + gap: 0.5rem; +} +.showTitle { + text-align: initial; + font-weight: 600; +} +.reactChart { + height: 600px !important; +} + +.flexDisplay { + display: flex; + flex-direction: row; +} +.selectContainer { + min-width: max-content; +} + +.crossIcon { + margin: 2px; + cursor: pointer; +} + +.submitButton { + color: #9553fe; + border: 1px solid #9553fe; + border-radius: 5px; + padding-left: 12px; + padding-right: 12px; + padding-top: 5px; + padding-bottom: 5px; + font-size: 0.75rem; + line-height: 1rem; + margin-top: 5px; +} + +.submitButtonRight { + color: #9553fe; + border: 1px solid #9553fe; + border-radius: 5px; + padding-left: 12px; + padding-right: 12px; + padding-top: 5px; + padding-bottom: 5px; + font-size: 0.75rem; + line-height: 1rem; + margin-top: 5px; + margin-left: 5px; +} + +.row__container .subHeading { + font-size: 12px; + padding-top: 5px; + color: #6b7280; + margin-left: 5px; + font-style: italic; +} + +.tableTitle { + color: black; + font-weight: bold; +} + +.link { + color: #9553fe; +} + +.addConditionStyle { + cursor: pointer; + margin-bottom: 10px; +} + +.crossIcon { + margin: 2px; +} + +.arrow-down-icon { + margin-left: 20px; + transform: rotate(0deg); + transition: all 0.3s ease-in-out; +} + +.arrow-down-icon.open { + transform: rotate(-180deg); +} + +.kvCell { + paddingbottom: 0; + paddingtop: 0; +} + +.toolTip { + padding: 1px; +} + +.addGroupingButton { + font-style: normal; + font-weight: 600; + font-size: 12px; + line-height: 150%; + color: #8993a6; + padding-top: 4px; + min-width: 100px; + margin-bottom: 10px; + cursor: pointer; +} + +.searchBox { + padding: 10px; + display: flex; + gap: 20px; +} + +.dataCount { + padding-top: 4px; + color: #6b7280; + font-size: 12px; + font-style: italic; +} + +.addGroupingStyle { + cursor: pointer; + font-style: normal; + font-weight: 400; + font-size: 12px; + line-height: 150%; + color: #6b7280; + padding-top: 4px; + min-width: 120px; +} + +.groupingLabel { + margin-left: 50px; +} + +.attributesTypeContainer { + display: flex; + flex-direction: column; + gap: 4px; + width: 250px; +} + +.timeRangeContext { + /* font-style: italic; */ + padding-left: 10px; + color: #6b7280; + font-size: 12px; +} + +.saveOverlay { + box-sizing: border-box; + width: calc(400px); + min-height: calc(100px); + max-height: 500px; + border-radius: 8px; + background-color: #ffffff; + padding: 0px 24px 24px 24px; + position: relative; + overflow: scroll; + display: flex; + flex-direction: column; +} + +.dashName { + margin-top: 20px; +} +.dashboardSaveOverlay { + box-sizing: border-box; + min-width: calc(600px); + min-height: calc(200px); + height: max-content; + border-radius: 8px; + background-color: #ffffff; + padding: 0px 24px 24px 24px; + position: relative; + display: flex; + flex-direction: column; + box-sizing: border-box; +} +.dashboardSaveOverlay__content { + margin-top: 2rem; + display: flex; + flex-direction: column; + gap: 2rem; +} + +.dashboardSaveOverlay__content .panel__description { + display: flex; + gap: 2rem; +} + +.dashboardSaveOverlay__select { + width: 220px; + color: #6b7280; +} +.dashboardSaveOverlay__title { + font-style: normal; + font-weight: 600; + font-size: 16px; + line-height: 150%; + color: #6b7280; +} + +.actions { + display: flex; + flex-direction: row; + align-items: center; + margin-top: 48px; +} + +.mr-3 { + margin-right: 12px; +} + +.panel__heading { + display: flex; + flex-direction: row; + gap: 20px; +} + +.actionOverlay { + box-sizing: border-box; + width: calc(400px); + min-height: calc(100px); + max-height: 500px; + border-radius: 8px; + background-color: #ffffff; + padding: 18px 18px 18px 18px; + position: relative; + overflow: scroll; + display: flex; + flex-direction: column; +} + +.panel__container { + display: flex; + flex-direction: column; +} +.panelConfig { + border: 1px solid #d1d5db; + margin: 1rem; + background-color: #ffffff; + border-radius: 8px; + box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.05); +} +.panelPlot { + margin: 1rem; + background-color: #ffffff; + padding: 1rem; + border: 2px solid #e5e7eb; +} +.content { + font-style: normal; + font-weight: 400; + font-size: 14px; + line-height: 150%; + color: #6b7280; + padding-top: 4px; + min-width: 100px; +} + +.config_panel__action__container { + padding: 12px 12px; + background-color: #f9fafb; +} + +.config_panel__action__container .viz-button { + background-color: #fff; + border: 1px solid #9333ea; + color: #9554ff; + font-size: 14px; + padding: 4px 12px; + border-radius: 4px; + box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.05); + transition: all 75ms ease-in-out; +} + +.config_panel__action__container .viz-button:hover { + background-color: #fafafa; + color: #9333ea; +} + +.secondary-btn { + color: #535253; + font-size: 14px; + font-weight: 500; + padding: 4px 12px 4px 12px; + border: 1px solid #e5e7eb; + background-color: #fff; + border-radius: 4px; + box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.05); + transition: all 75ms ease-in-out; +} + +.secondary-btn:hover { + color: #535253; + border: 1px solid #c0c2c6; + + /* text-decoration: underline; */ +} + +.filter__container { + margin: 0px !important; + background-color: #fff !important; + border: 0px !important; + border-bottom: 1px solid #e5e7eb !important; + padding-bottom: 4px !important; +} + +.query__builder { + margin-left: 0px !important; +} + +.status-chip-FINISHED { + background-color: #10b981; + color: white; + padding: 2px 4px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; +} + +.status-chip-CREATED { + background-color: rgb(0, 114, 76); + color: white; + padding: 2px 4px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; +} + +.status-chip-RUNNING { + background-color: #b96a10; + color: white; + padding: 2px 4px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; +} + +.status-chip-FAILED { + background-color: #b91010; + color: white; + padding: 2px 4px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; +} + +.pbRunSummary { + flex-direction: row; + justify-content: space-between; + align-items: center; + margin: 10px; + border: 1px solid #8080804a; + background-color: white; + padding: 10px; + border-radius: 10px; +} + +.pbRunLogsTitle { + font-size: 14px; + font-weight: 800; + color: #6b7280; + margin: 10px; +} + +.pbRunLog { + flex-direction: row; + justify-content: space-between; + align-items: center; + margin: 10px; + border: 1px solid #8080804a; + background-color: white; + padding: 10px; + border-radius: 10px; +} + +.pbRunLogMetrics { + flex-direction: row; + justify-content: space-between; + align-items: center; + margin: 10px; + border: 1px solid #8080804a; + background-color: #d3d3d366; + padding: 10px; + border-radius: 10px; +} + +.pbRunLogDesc { + font-size: 15px; + font-weight: 400; + color: black; +} + +.container .eventTypeSelectionSection { + display: flex; + flex-direction: row; + gap: 10px; + margin-bottom: 10px; + min-width: 200px; +} + +.container .eventTypeConditionsSection { +} + +.container .content { + font-style: normal; + font-weight: 400; + font-size: 12px; + line-height: 150%; + color: #6b7280; + padding-top: 4px; + min-width: 100px; +} + +.container .content-centre { + font-style: normal; + font-weight: 400; + font-size: 12px; + line-height: 150%; + color: #6b7280; + padding-top: 4px; + min-width: 100px; + text-align: center; +} + +.container { + padding-left: 8px; + padding-top: 12px; + background-color: #fff; + margin: 12px; + border-width: 1px; + border-color: hsl(240deg 21% 87%); + border-style: solid; + border-radius: 5px; +} + +.container .heading { + padding-bottom: 12px; + display: flex; + flex-direction: row; + gap: 10px; +} + +.container .subHeading { + font-size: 11px; + padding-top: 4px; + color: #6b7280; +} + +.addConditionStyle { + cursor: pointer; + margin-bottom: 10px; +} + +.crossIcon { + cursor: pointer; +} + +.container .content { + font-style: normal; + font-weight: 400; + font-size: 12px; + line-height: 150%; + color: #6b7280; + padding-top: 4px; + min-width: 100px; +} + +.textValueContainer { + background: #fff; + border: 1px solid #d3d3d3; + border-radius: 5px; + color: #1f2937; + padding: 4px 8px; + margin-right: 10px; + outline: none; + width: -webkit-fill-available; + font-size: 12px; + height: 500px; +} + +.pb-explore-container { + display: flex; + flex-direction: row; + height: 100vh; +} + +.pb-explore-title { + margin: 5px 10px -5px 10px; + padding: 5px 5px 5px 5px; + font-size: 16px; + font-weight: 600; + color: #58595b; +} + +.alert-search-field { + margin: 10px 5px 10px 5px; + background: #fff; +} + +.alert-loading { + display: flex; + justify-content: center; + align-items: center; + height: 100%; +} + +.il-output-loading { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + padding: 20px; +} + +.il-output { + display: flex; + flex-direction: column; + margin: 10px 5px 5px 5px; + padding: 5px 5px 5px 5px; + border: 1px solid #d3d3d3; + border-radius: 5px; + background: #f8fafc; +} + +.alert-cards-pane { + width: 25%; + margin: 10px 5px 10px 10px; + border: 1px solid #d3d3d3; + border-radius: 5px; + background: #fff; + overflow-y: auto; +} + +.alert-actions-pane { + width: 75%; + margin: 10px 10px 10px 5px; + border: 1px solid #d3d3d3; + border-radius: 5px; + background: #fff; + overflow-y: auto; +} + +.alert-card { + margin: 5px 5px 5px 5px; + padding: 5px 5px 5px 5px; + border: 1px solid #d3d3d3; + border-radius: 5px; + background: #fff; + cursor: pointer; +} + +.alert-card-selected { + margin: 5px 5px 5px 5px; + padding: 5px 5px 5px 5px; + border: 1px solid #d3d3d3; + border-radius: 5px; + background: #9553fe2e; + cursor: pointer; +} + +.alert-title { + font-size: 16px; + font-weight: 600; + color: #58595b; + text-wrap: pretty; + width: 100%; +} + +.alert-type { + font-size: 14px; + font-weight: 400; + color: #6b7280; +} + +.alert-channel { + font-size: 12px; + font-weight: 400; + color: #6b7280; +} + +.alert-timestamp { + font-size: 12px; + font-weight: 400; + color: #8c8585; +} + +.alert-not-selected { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + margin: 10px; + font-size: 16px; + font-weight: 600; + color: #58595b; +} + +.il-alert-details { + margin: 5px 5px 5px 5px; + padding: 5px 5px 5px 5px; + background: #fff; +} + +.il-card { + display: flex; + flex-direction: column; + margin: 5px 0px 5px 0px; + padding: 5px 5px 5px 5px; + border: 1px solid #d3d3d3; + border-radius: 5px; + background: #fff; +} + +.il-step-section { + display: flex; + flex-direction: row; +} + +.il-output-section { + display: flex; + flex-direction: column; +} + +.il-message { + font-size: 14px; + font-weight: 400; + color: #6b7280; + align-items: center; + display: flex; +} + +.il-step-info { + display: flex; + flex-direction: row; + justify-content: space-between; + border-left: 1px solid #babdc4; + margin-left: 20px; + padding-left: 20px; + border-right: 1px solid #babdc4; + margin-right: 20px; + padding-right: 20px; +} + +.il-step-name { + min-width: 60px; + display: flex; + padding-left: 5px; +} + +.alert-tags { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 5px; +} + +.alert-tag { + padding: 1px 5px 0px 5px; + background-color: #9553fe2e; + color: #58595b; + border-radius: 5px; + font-size: 14px; + margin-bottom: 5px; +} + +.add-step { + width: 110px; + height: 32px; + margin-top: 5px; + font-size: 14px; +} + +.graph-ts { + font-size: 12px; + text-align: center; + margin-top: 10px; + color: darkgray; + text-decoration: italic; +} + +.graph-title { + font-size: 13px; + text-align: center; + color: black; + font-weight: 800; +} + +.graph-box { + border: 1px solid #d3d3d3; + max-width: 100%; + width: 100%; + min-width: fit-content; + border-radius: 5px; + margin-bottom: 5px; + padding: 10px; + display: flex; + flex-direction: column; + align-items: stretch; +} + +.graph-error { + font-size: 14px; + text-align: center; + margin-top: 20px; + margin-bottom: 20px; + color: red; + font-weight: 800; +} + +.graph-ts-error { + font-size: 12px; + text-align: center; + color: darkgray; + text-decoration: italic; +} + +.tableData { + font-size: 12px; + min-width: 50px; +} + +.tableDataMsg { + font-size: 12px; + min-width: 100px; + width: 100px; +} + +.tableLogDataTitle { + font-size: 12px; + min-width: 50px; + width: 50px; +} diff --git a/web/src/constants/api/index.ts b/web/src/constants/api/index.ts index 809729aa3..2c95e14c8 100644 --- a/web/src/constants/api/index.ts +++ b/web/src/constants/api/index.ts @@ -11,3 +11,4 @@ export * from "./templates.ts"; export * from "./management.ts"; export * from "./search.ts"; export * from "./dynamicAlerts"; +export * from "./secrets.ts"; diff --git a/web/src/constants/api/secrets.ts b/web/src/constants/api/secrets.ts new file mode 100644 index 000000000..182a9caf4 --- /dev/null +++ b/web/src/constants/api/secrets.ts @@ -0,0 +1,6 @@ +// Secrets management +export const SECRETS_LIST = "/executor/secrets/list/"; +export const SECRET_GET = "/executor/secrets/get/"; +export const SECRET_CREATE = "/executor/secrets/create/"; +export const SECRET_UPDATE = "/executor/secrets/update/"; +export const SECRET_DELETE = "/executor/secrets/update/"; diff --git a/web/src/pageKeys.ts b/web/src/pageKeys.ts index 2cf50fadc..63d098164 100644 --- a/web/src/pageKeys.ts +++ b/web/src/pageKeys.ts @@ -30,4 +30,6 @@ export enum PageKeys { DYNAMIC_ALERTS = "DYNAMIC_ALERTS", CREATE_DYNAMIC_ALERTS = "CREATE_DYNAMIC_ALERTS", DYNAMIC_ALERT_VIEW = "DYNAMIC_ALERT", + + SECRET_MANAGEMENT = "SECRET_MANAGEMENT", } diff --git a/web/src/pages/secret-management/hooks/index.ts b/web/src/pages/secret-management/hooks/index.ts new file mode 100644 index 000000000..176a1ffa5 --- /dev/null +++ b/web/src/pages/secret-management/hooks/index.ts @@ -0,0 +1 @@ +export * from "./useSecretsData"; diff --git a/web/src/pages/secret-management/hooks/useSecretsData.tsx b/web/src/pages/secret-management/hooks/useSecretsData.tsx new file mode 100644 index 000000000..97960cb4e --- /dev/null +++ b/web/src/pages/secret-management/hooks/useSecretsData.tsx @@ -0,0 +1,60 @@ +import { renderTimestamp } from "../../../utils/common/dateUtils"; +import { DeleteRounded } from "@mui/icons-material"; +import useToggle from "../../../hooks/common/useToggle"; +import { useState } from "react"; + +export const useSecretsData = (data: any[]) => { + const { isOpen: isActionOpen, toggle } = useToggle(); + const { isOpen: isConfigOpen, toggle: toggleConfig } = useToggle(); + const [selectedSecret, setSelectedSecret] = useState({}); + const [selectedId, setSelectedId] = useState(""); + + const handleDeleteSecret = (secret: any) => { + setSelectedSecret(secret); + toggle(); + }; + + const handleUpdateVariable = (secret: any) => { + setSelectedId(secret.id); + toggleConfig(); + }; + + const actions = [ + { + icon: , + label: "Delete", + action: (item: any) => handleDeleteSecret(item), + tooltip: "Remove this Secret", + }, + ]; + + const rows = data?.map((item: any) => ({ + ...item, + name: ( +

handleUpdateVariable(item)} + className={ + "text-violet-500 dark:text-purple-400 hover:text-violet-800 dark:hover:text-purple-300" + }> + {item.key} +

+ ), + createdAt: ( +

+ {renderTimestamp(item.created_at)} +

+ ), + createdBy:

{item.created_by}

, + })); + + return { + rows, + actions, + selectedSecret, + isActionOpen, + toggle, + isConfigOpen, + toggleConfig, + selectedId, + }; +}; diff --git a/web/src/pages/secret-management/index.tsx b/web/src/pages/secret-management/index.tsx new file mode 100644 index 000000000..11f7ade57 --- /dev/null +++ b/web/src/pages/secret-management/index.tsx @@ -0,0 +1,84 @@ +import Heading from "../../components/Heading"; +import { useState } from "react"; +import useDebounce from "../../hooks/common/useDebounce"; +import { useGetSecretsListQuery } from "../../store/features/secrets/api"; +import usePaginationComponent from "../../hooks/common/usePaginationComponent"; +import CustomInput from "../../components/Inputs/CustomInput"; +import { InputTypes } from "../../types"; +import SuspenseLoader from "../../components/Skeleton/SuspenseLoader"; +import TableSkeleton from "../../components/Skeleton/TableLoader"; +import PaginatedTable from "../../components/common/Table/PaginatedTable"; +import { secretsColumns } from "./utils"; +import { useSecretsData } from "./hooks"; +import CreateSecretButton from "../../components/secret-management/create/CreateSecretButton"; +import SecretCreateOverlay from "../../components/secret-management/create/SecretCreateOverlay"; +import SecretActionOverlay from "../../components/secret-management/SecretActionOverlay"; + +function SecretManagement() { + const [query, setQuery] = useState(""); + const debouncedQuery = useDebounce(query, 500); + const { data, isFetching, refetch } = useGetSecretsListQuery({ + key: debouncedQuery, + }); + const secretsList = data?.secrets ?? []; + const total = data?.meta?.total_count ?? 0; + const { + rows, + actions, + isActionOpen, + selectedSecret, + toggle, + isConfigOpen, + toggleConfig, + selectedId, + } = useSecretsData(secretsList ?? []); + usePaginationComponent(refetch); + + return ( + <> + +
+
+ +
+ + }> + + +
+ + {isActionOpen && ( + + )} + {isConfigOpen && ( + + )} + + ); +} + +export default SecretManagement; diff --git a/web/src/pages/secret-management/utils/index.ts b/web/src/pages/secret-management/utils/index.ts new file mode 100644 index 000000000..8ef0e3cb6 --- /dev/null +++ b/web/src/pages/secret-management/utils/index.ts @@ -0,0 +1 @@ +export * from "./secretsColumns"; diff --git a/web/src/pages/secret-management/utils/secretsColumns.ts b/web/src/pages/secret-management/utils/secretsColumns.ts new file mode 100644 index 000000000..a183cf773 --- /dev/null +++ b/web/src/pages/secret-management/utils/secretsColumns.ts @@ -0,0 +1,5 @@ +export const secretsColumns = [ + { header: "Name", key: "name", isMain: true }, + { header: "Created At", key: "createdAt" }, + { header: "Created By", key: "createdBy" }, +]; diff --git a/web/src/routes.ts b/web/src/routes.ts index 7eab6f37d..36012994b 100644 --- a/web/src/routes.ts +++ b/web/src/routes.ts @@ -31,5 +31,6 @@ export const routes = { [PageKeys.INVITE_TEAM]: "/settings/invite-team", [PageKeys.SUPPORT]: "/support", [PageKeys.DYNAMIC_ALERT_VIEW]: "/dynamic-alerts/:alert_id", + [PageKeys.SECRET_MANAGEMENT]: "/secret-management", [PageKeys.NOT_FOUND]: "*", }; diff --git a/web/src/store/app/apiSlice.ts b/web/src/store/app/apiSlice.ts index 26d65a320..e1adbadff 100644 --- a/web/src/store/app/apiSlice.ts +++ b/web/src/store/app/apiSlice.ts @@ -12,5 +12,6 @@ export const apiSlice = createApi({ "Workflows", "Templates", "SiteURL", + "Secrets", ], }); diff --git a/web/src/store/features/secrets/actions/index.ts b/web/src/store/features/secrets/actions/index.ts new file mode 100644 index 000000000..24a8771c1 --- /dev/null +++ b/web/src/store/features/secrets/actions/index.ts @@ -0,0 +1,2 @@ +export * from "./resetSecretStateAction"; +export * from "./setSecretKeyAction"; diff --git a/web/src/store/features/secrets/actions/resetSecretStateAction.ts b/web/src/store/features/secrets/actions/resetSecretStateAction.ts new file mode 100644 index 000000000..f47824a91 --- /dev/null +++ b/web/src/store/features/secrets/actions/resetSecretStateAction.ts @@ -0,0 +1,5 @@ +import { secretsInitialState, SecretsInitialState } from "../initialState"; + +export const resetSecretStateAction = (state: SecretsInitialState) => { + Object.assign(state, secretsInitialState); +}; diff --git a/web/src/store/features/secrets/actions/setSecretKeyAction.ts b/web/src/store/features/secrets/actions/setSecretKeyAction.ts new file mode 100644 index 000000000..87c3abb00 --- /dev/null +++ b/web/src/store/features/secrets/actions/setSecretKeyAction.ts @@ -0,0 +1,16 @@ +import { PayloadAction } from "@reduxjs/toolkit"; +import { secretsInitialState, SecretsInitialState } from "../initialState"; + +type PayloadType = { + key: keyof SecretsInitialState; + value: (typeof secretsInitialState)[keyof SecretsInitialState]; +}; + +export const setSecretKeyAction = ( + state: SecretsInitialState, + { payload }: PayloadAction, +) => { + const { key, value } = payload; + + state[key] = value as any; +}; diff --git a/web/src/store/features/secrets/api/createSecretApi.ts b/web/src/store/features/secrets/api/createSecretApi.ts new file mode 100644 index 000000000..7c921f9e9 --- /dev/null +++ b/web/src/store/features/secrets/api/createSecretApi.ts @@ -0,0 +1,18 @@ +import { SECRET_CREATE } from "../../../../constants/index.ts"; +import { apiSlice } from "../../../app/apiSlice.ts"; +import { SecretsInitialState } from "../initialState.ts"; + +export const createSecretApi = apiSlice.injectEndpoints({ + endpoints: (builder) => ({ + createSecret: builder.mutation>({ + query: (body) => ({ + url: SECRET_CREATE, + body, + method: "POST", + }), + invalidatesTags: ["Secrets"], + }), + }), +}); + +export const { useCreateSecretMutation } = createSecretApi; diff --git a/web/src/store/features/secrets/api/deleteSecretApi.ts b/web/src/store/features/secrets/api/deleteSecretApi.ts new file mode 100644 index 000000000..9ff7fa5de --- /dev/null +++ b/web/src/store/features/secrets/api/deleteSecretApi.ts @@ -0,0 +1,27 @@ +import { SECRET_DELETE } from "../../../../constants/index.ts"; +import { apiSlice } from "../../../app/apiSlice.ts"; + +export const deleteSecretApi = apiSlice.injectEndpoints({ + endpoints: (builder) => ({ + deleteSecret: builder.mutation({ + query: ({ id }) => ({ + url: SECRET_DELETE, + method: "POST", + body: { + update_secret_ops: [ + { + secret_id: id, + op: "UPDATE_SECRET_STATUS", + update_secret_status: { + is_active: false, + }, + }, + ], + }, + }), + invalidatesTags: ["Secrets"], + }), + }), +}); + +export const { useDeleteSecretMutation } = deleteSecretApi; diff --git a/web/src/store/features/secrets/api/getSecretApi.ts b/web/src/store/features/secrets/api/getSecretApi.ts new file mode 100644 index 000000000..d2c7d76ad --- /dev/null +++ b/web/src/store/features/secrets/api/getSecretApi.ts @@ -0,0 +1,49 @@ +import { SECRETS_LIST } from "../../../../constants/index.ts"; +import { apiSlice } from "../../../app/apiSlice.ts"; +import { setSecretKey } from "../secretsSlice.ts"; + +export const getSecretApi = apiSlice.injectEndpoints({ + endpoints: (builder) => ({ + getSecret: builder.query({ + query: ({ id }) => ({ + url: SECRETS_LIST, + body: { + secret_ids: [id], + }, + method: "POST", + }), + transformResponse: (response) => { + const secret = response?.secrets?.[0] ?? {}; + return secret; + }, + onQueryStarted: async (_, { queryFulfilled, dispatch }) => { + try { + const { data } = await queryFulfilled; + dispatch( + setSecretKey({ + key: "key", + value: data?.key ?? "", + }), + ); + dispatch( + setSecretKey({ + key: "description", + value: data?.description ?? "", + }), + ); + dispatch( + setSecretKey({ + key: "value", + value: (data?.value ?? []).join(", "), + }), + ); + } catch (error) { + console.log(error); + } + }, + providesTags: ["Secrets"], + }), + }), +}); + +export const { useGetSecretQuery } = getSecretApi; diff --git a/web/src/store/features/secrets/api/getSecretsListApi.ts b/web/src/store/features/secrets/api/getSecretsListApi.ts new file mode 100644 index 000000000..a10bca02b --- /dev/null +++ b/web/src/store/features/secrets/api/getSecretsListApi.ts @@ -0,0 +1,25 @@ +import { SECRETS_LIST } from "../../../../constants/index.ts"; +import { apiSlice } from "../../../app/apiSlice.ts"; + +export const getSecretsListApi = apiSlice.injectEndpoints({ + endpoints: (builder) => ({ + getSecretsList: builder.query< + any, + { + meta?: any; + key?: string; + } + >({ + query: ({key}) => ({ + url: SECRETS_LIST, + method: "POST", + body: { + key + }, + }), + providesTags: ["Secrets"], + }), + }), +}); + +export const { useGetSecretsListQuery } = getSecretsListApi; diff --git a/web/src/store/features/secrets/api/index.ts b/web/src/store/features/secrets/api/index.ts new file mode 100644 index 000000000..14d166936 --- /dev/null +++ b/web/src/store/features/secrets/api/index.ts @@ -0,0 +1,5 @@ +export * from "./getSecretsListApi"; +export * from "./createSecretApi"; +export * from "./deleteSecretApi"; +export * from "./updateSecretApi"; +export * from "./getSecretApi"; diff --git a/web/src/store/features/secrets/api/updateSecretApi.ts b/web/src/store/features/secrets/api/updateSecretApi.ts new file mode 100644 index 000000000..6939484fe --- /dev/null +++ b/web/src/store/features/secrets/api/updateSecretApi.ts @@ -0,0 +1,29 @@ +import { SECRET_UPDATE } from "../../../../constants/index.ts"; +import { apiSlice } from "../../../app/apiSlice.ts"; +import { SecretsInitialState } from "../initialState.ts"; + +export const updateSecretApi = apiSlice.injectEndpoints({ + endpoints: (builder) => ({ + updateSecret: builder.mutation({ + query: (secret) => ({ + url: SECRET_UPDATE, + body: { + update_secret_ops: [ + { + op: "UPDATE_SECRET", + secret_id: secret.id, + update_secret: { + value: secret.value, + description: secret.description, + }, + }, + ], + }, + method: "POST", + }), + invalidatesTags: ["Secrets"], + }), + }), +}); + +export const { useUpdateSecretMutation } = updateSecretApi; diff --git a/web/src/store/features/secrets/initialState.ts b/web/src/store/features/secrets/initialState.ts new file mode 100644 index 000000000..251441fb1 --- /dev/null +++ b/web/src/store/features/secrets/initialState.ts @@ -0,0 +1,12 @@ +export type SecretsInitialState = { + id?: string; + key: string; + value: string; + description: string; +}; + +export const secretsInitialState: SecretsInitialState = { + key: "", + description: "", + value: "", +}; diff --git a/web/src/store/features/secrets/secretsSlice.ts b/web/src/store/features/secrets/secretsSlice.ts new file mode 100644 index 000000000..9d02b595a --- /dev/null +++ b/web/src/store/features/secrets/secretsSlice.ts @@ -0,0 +1,18 @@ +import { createSlice } from "@reduxjs/toolkit"; +import { secretsInitialState } from "./initialState"; +import * as Actions from "./actions"; + +export * from "./selectors"; + +const secretsSlice = createSlice({ + name: "secrets", + initialState: secretsInitialState, + reducers: { + resetSecretState: Actions.resetSecretStateAction, + setSecretKey: Actions.setSecretKeyAction, + }, +}); + +export const { resetSecretState, setSecretKey } = secretsSlice.actions; + +export default secretsSlice.reducer; diff --git a/web/src/store/features/secrets/selectors/index.ts b/web/src/store/features/secrets/selectors/index.ts new file mode 100644 index 000000000..b876e4388 --- /dev/null +++ b/web/src/store/features/secrets/selectors/index.ts @@ -0,0 +1 @@ +export * from "./secretsSelector"; diff --git a/web/src/store/features/secrets/selectors/secretsSelector.ts b/web/src/store/features/secrets/selectors/secretsSelector.ts new file mode 100644 index 000000000..09eb561eb --- /dev/null +++ b/web/src/store/features/secrets/selectors/secretsSelector.ts @@ -0,0 +1,3 @@ +import { RootState } from "../../.."; + +export const secretsSelector = (state: RootState) => state.secerts; diff --git a/web/src/store/index.ts b/web/src/store/index.ts index 7525a1bf6..f6a3d65f6 100644 --- a/web/src/store/index.ts +++ b/web/src/store/index.ts @@ -14,6 +14,7 @@ import paginationSlice from "./features/pagination/paginationSlice.ts"; import commonSlice from "./features/common/commonSlice.ts"; import dynamicAlertsSlice from "./features/dynamicAlerts/dynamicAlertsSlice.ts"; import sidebarSlice from "./features/sidebar/sidebarSlice.ts"; +import secretsSlice from "./features/secrets/secretsSlice.ts"; export const store = configureStore({ reducer: { @@ -31,6 +32,7 @@ export const store = configureStore({ common: commonSlice, dynamicAlerts: dynamicAlertsSlice, sidebar: sidebarSlice, + secerts: secretsSlice, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(apiSlice.middleware), diff --git a/web/src/types/inputs/inputTypes.ts b/web/src/types/inputs/inputTypes.ts index aa0719e63..db7b063ea 100644 --- a/web/src/types/inputs/inputTypes.ts +++ b/web/src/types/inputs/inputTypes.ts @@ -14,6 +14,7 @@ export const InputTypes = { TEXT_BUTTON: "TEXT_BUTTON_FT", CRON: "CRON_FT", CHECKBOX: "CHECKBOX_FT", + SEARCH: "SEARCH_FT", } as const; export type InputType = (typeof InputTypes)[keyof typeof InputTypes];