Source code for smarter.apps.secret.models

"""Secret models."""

import logging
from typing import Optional

# 3rd party stuff
from cryptography.fernet import Fernet
from django.contrib.auth.models import User
from django.db import models
from django.urls import reverse
from django.utils import timezone

# smarter stuff
from smarter.apps.account.models.account import Account
from smarter.apps.account.models.metadata_with_ownership import (
    MetaDataWithOwnershipModel,
    MetaDataWithOwnershipModelManager,
)
from smarter.apps.account.models.user_profile import UserProfile
from smarter.apps.secret.signals import (
    secret_accessed,
    secret_created,
    secret_edited,
)
from smarter.common.conf import smarter_settings
from smarter.common.exceptions import SmarterConfigurationError, SmarterValueError
from smarter.common.helpers.console_helpers import formatted_text
from smarter.lib.django import waffle
from smarter.lib.django.waffle import SmarterWaffleSwitches
from smarter.lib.logging import WaffleSwitchedLoggerWrapper


# pylint: disable=W0613
def should_log(level):
    """Check if logging should be done based on the waffle switch."""
    return waffle.switch_is_active(SmarterWaffleSwitches.ACCOUNT_LOGGING)


base_logger = logging.getLogger(__name__)
logger = WaffleSwitchedLoggerWrapper(base_logger, should_log)


[docs] class Secret(MetaDataWithOwnershipModel): """ Secret model for securely storing and managing sensitive account-level information. Usage:: # Encrypt a secret value before saving it secret_value = Secret.encrypt("my-sensitive-api-key") # Create a new secret secret = Secret( name="API Key", user_profile=user_profile_instance, encrypted_value=secret_value ) secret.save() # Retrieve and decrypt a secret retrieved_secret = Secret.objects.get(id=secret.id) decrypted_value = retrieved_secret.get_secret() .. note:: The `value` field is transient and only used during runtime. It is not stored in the database to ensure sensitive data is only saved in encrypted form. """ # pylint: disable=missing-class-docstring class Meta: verbose_name = "Secret" verbose_name_plural = "Secrets" unique_together = ("user_profile", "name") objects: MetaDataWithOwnershipModelManager["Secret"] = MetaDataWithOwnershipModelManager() last_accessed = models.DateTimeField( blank=True, editable=False, null=True, help_text="Timestamp of the last time the secret was accessed." ) expires_at = models.DateTimeField( blank=True, null=True, help_text="Timestamp indicating when the secret expires. If null, the secret does not expire.", ) user_profile = models.ForeignKey( UserProfile, on_delete=models.CASCADE, related_name="secrets", help_text="Reference to the UserProfile associated with this secret.", ) encrypted_value = models.BinaryField(help_text="Read-only encrypted representation of the secret's value.") @property def manifest_url(self) -> Optional[str]: """ Returns the URL to the plugin's manifest. This property constructs the URL to the plugin's manifest based on its kind and RFC 1034-compliant name. The URL follows the pattern: ``/plugins/{kind}/{name}/manifest/``, where ``{kind}`` is the RFC 1034-compliant kind of the plugin, and ``{name}`` is the RFC 1034-compliant name of the plugin. **Example:** .. code-block:: python self.rfc1034_compliant_kind # 'static' self.rfc1034_compliant_name # 'example-plugin self.manifest_url # '/plugins/static/example-plugin/manifest/' """ # pylint: disable=C0415 from smarter.apps.secret.urls import SecretReverseNames return reverse( f"{SecretReverseNames.namespace}:{SecretReverseNames.detailview}", kwargs={"hashed_id": self.hashed_id}, # type: ignore ) @property def ready(self) -> bool: return super().ready and self.encrypted_value is not None
[docs] def save(self, *args, **kwargs): """ Encrypt and persist the secret value for this instance. This method encrypts the transient `value` field and stores the result in `encrypted_value`. It validates that both `name` and `encrypted_value` are present and that the value is a string. :param args: Positional arguments passed to the parent save method. :param kwargs: Keyword arguments passed to the parent save method. :raises: :class:`SmarterValueError` if `name` or `encrypted_value` is missing. .. important:: Only the encrypted value is stored in the database; the plaintext value is never persisted. .. note:: Emits a signal on creation or edit for audit and notification purposes. **Example usage**:: secret = Secret( name="apiKey", user_profile=user_profile, encrypted_value=Secret.encrypt("my-api-key") ) secret.save() """ is_new = self.pk is None if not self.name or not self.encrypted_value: raise SmarterValueError( f"Name and encrypted_value are required fields. Got name: {self.name}, encrypted_value: {self.encrypted_value}" ) self.user_profile = self.user_profile super().save(*args, **kwargs) if is_new: secret_created.send(sender=self.__class__, secret=self) else: secret_edited.send(sender=self.__class__, secret=self)
[docs] def get_secret(self, update_last_accessed=True) -> Optional[str]: """ Decrypt and return the original secret value. Optionally updates the `last_accessed` timestamp and emits an access signal. If decryption fails, raises a :class:`SmarterValueError`. :param update_last_accessed: Boolean. If True, updates the `last_accessed` timestamp. Defaults to True. :returns: Optional[str] The decrypted secret value, or None if not set. :raises: :class:`SmarterValueError` if decryption fails. .. note:: Accessing the secret updates its last accessed time for audit purposes. **Example usage**:: secret_value = secret.get_secret(update_last_accessed=True) """ try: if update_last_accessed: self.last_accessed = timezone.now() self.save(update_fields=["last_accessed"]) secret_accessed.send(sender=self.__class__, secret=self, user_profile=self.user_profile) fernet = self.get_fernet() if self.encrypted_value: return fernet.decrypt(self.encrypted_value).decode() return None except Exception as e: raise SmarterValueError(f"Failed to decrypt the secret: {str(e)}") from e
[docs] def is_expired(self) -> bool: """ Determine whether the secret has expired based on its `expires_at` timestamp. :returns: bool True if the current time is past the expiration timestamp; False otherwise. .. note:: If `expires_at` is not set, the secret is considered non-expiring. **Example usage**:: if secret.is_expired(): print("This secret is no longer valid.") """ if not self.expires_at: return False expiration = timezone.make_aware(self.expires_at) if timezone.is_naive(self.expires_at) else self.expires_at return timezone.now() > expiration
def __str__(self): return str(self.name) or "no name" + " - " + str(self.user_profile) or "no user profile"
[docs] @classmethod def encrypt(cls, value: str) -> bytes: """ Encrypt a string value using Fernet symmetric encryption. :param value: str The plaintext string to encrypt. :returns: bytes The encrypted value as bytes. :raises: :class:`SmarterValueError` If the input value is not a non-empty string. .. attention:: The original plaintext value is not stored or persisted; only the encrypted bytes are returned. .. caution:: Always clear or avoid storing the plaintext value after encryption to prevent accidental exposure. **Example usage**:: encrypted = Secret.encrypt("my-api-key") # Store `encrypted` in the database, never the plaintext .. seealso:: :meth:`get_fernet` -- Returns the Fernet encryption object. """ if not value or not isinstance(value, str): raise SmarterValueError("Value must be a non-empty string") fernet = cls.get_fernet() retval = fernet.encrypt(value.encode()) return retval
[docs] @classmethod def get_fernet(cls) -> Fernet: """ Return a Fernet encryption object for secure value encryption and decryption. :returns: :class:`cryptography.fernet.Fernet` A Fernet instance initialized with the configured encryption key. :raises: :class:`SmarterConfigurationError` If the encryption key is missing from settings. .. important:: The encryption key must be set in ``smarter.common.conf.settings.fernet_encryption_key``. Without a valid key, secrets cannot be encrypted or decrypted. **Example usage**:: fernet = Secret.get_fernet() encrypted = fernet.encrypt(b"my-value") decrypted = fernet.decrypt(encrypted) .. seealso:: :meth:`encrypt` -- Uses the Fernet object to encrypt values. """ encryption_key = smarter_settings.fernet_encryption_key.get_secret_value() if not encryption_key: raise SmarterConfigurationError( "Encryption key not found in settings. Please set smarter.common.conf.settings.fernet_encryption_key" ) fernet = Fernet(encryption_key) return fernet
# pylint: disable=W0221
[docs] @classmethod def get_cached_object( cls, *args, invalidate: Optional[bool] = False, pk: Optional[int] = None, name: Optional[str] = None, user: Optional[User] = None, user_profile: Optional[UserProfile] = None, account: Optional[Account] = None, **kwargs, ) -> Optional["Secret"]: """ Retrieve a model instance using caching to optimize performance. Examples of retrieval patterns: .. code-block:: python # By primary key instance = MyModel.get_cached_object(pk=123) # By name and user profile instance = MyModel.get_cached_object(name="Resource Name", user_profile=user_profile) # By name and account instance = MyModel.get_cached_object(name="Resource Name", account=account) :param pk: The primary key of the model instance to retrieve. :param name: The name of the model instance to retrieve. :param user: The user associated with the model instance. :param user_profile: The user profile associated with the model instance. :param account: The account associated with the model instance. :returns: The model instance if found, otherwise None. :rtype: Optional[Secret] """ logger_prefix = formatted_text(__name__ + "." + Secret.__name__ + ".get_cached_object()") logger.debug( "%s called with pk: %s, name: %s, user: %s, user_profile: %s, account: %s, invalidate: %s", logger_prefix, pk, name, user, user_profile, account, invalidate, ) retval = super().get_cached_object( *args, invalidate=invalidate, pk=pk, name=name, user=user, user_profile=user_profile, account=account, **kwargs, ) if isinstance(retval, Secret): return retval logger.debug( "%s super().get_cached_object() did not return a Secret instance. Got: %s. Returning None.", logger_prefix, type(retval), ) return None
__all__ = ["Secret"]