cts-common 0.34.1-alpha.3

Common types and traits used across the CipherStash ecosystem
Documentation
use crate::{AsCrn, Crn, Region};
use arrayvec::ArrayString;
#[cfg(feature = "server")]
use http::HeaderValue;
use miette::Diagnostic;
use serde::{Deserialize, Deserializer, Serialize};
use std::{fmt::Display, str::FromStr};
use thiserror::Error;
use utoipa::ToSchema;
use vitaminc::encrypt::{Aad, IntoAad};
use vitaminc::random::{Generatable, SafeRand};

const WORKSPACE_ID_BYTE_LEN: usize = 10;
const WORKSPACE_ID_ENCODED_LEN: usize = 16;
const ALPHABET: base32::Alphabet = base32::Alphabet::Rfc4648 { padding: false };

type WorkspaceIdArrayString = ArrayString<WORKSPACE_ID_ENCODED_LEN>;

#[derive(Error, Debug, Diagnostic)]
#[error("Invalid workspace ID: {0}")]
#[diagnostic(help = "Workspace IDs are 10-byte random strings formatted in base32.")]
pub struct InvalidWorkspaceId(String);

#[derive(Error, Debug)]
#[error("Failed to generate workspace ID")]
pub struct WorkspaceIdGenerationError(#[from] vitaminc::random::RandomError);

/// Defines a workspace.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Workspace {
    id: WorkspaceId,
    region: Region,
    #[serde(default = "default_workspace_name")]
    #[serde(deserialize_with = "deserialize_workspace_name")]
    name: String,
}

impl AsCrn for Workspace {
    fn as_crn(&self) -> crate::Crn {
        Crn::new(self.region, self.id)
    }
}

fn deserialize_workspace_name<'d, D>(deserializer: D) -> Result<String, D::Error>
where
    D: Deserializer<'d>,
{
    let opt = Option::deserialize(deserializer)?;
    Ok(opt.unwrap_or("unnamed workspace".to_string()))
}

impl Workspace {
    pub fn new(id: WorkspaceId, region: Region, name: impl Into<String>) -> Self {
        Self {
            id,
            region,
            name: name.into(),
        }
    }

    /// The unique identifier of the workspace.
    /// See [WorkspaceId] for more information.
    pub fn id(&self) -> WorkspaceId {
        self.id
    }

    pub fn crn(&self) -> Crn {
        Crn::new(self.region, self.id)
    }

    pub fn name(&self) -> &str {
        self.name.as_str()
    }

    pub fn region(&self) -> Region {
        self.region
    }
}

fn default_workspace_name() -> String {
    "Default".to_string()
}

/// A unique identifier for a workspace.
/// Workspace IDs are 10-byte random strings formatted in base32.
///
/// Internally, the workspace ID is stored as an [ArrayString] with a maximum length of 20 characters.
/// This means that values work entirely on the stack and implement the `Copy` trait.
///
/// # Example
///
/// ```
/// use cts_common::WorkspaceId;
///
/// let workspace_id = WorkspaceId::generate().unwrap();
/// println!("Workspace ID: {}", workspace_id);
/// ```
///
/// A [WorkspaceId] can be converted from a string but will fail if the string is not a valid workspace ID.
///
/// ```
/// use cts_common::WorkspaceId;
/// let workspace_id = WorkspaceId::try_from("JBSWY3DPEHPK3PXP").unwrap();
///
/// // This will fail because the string is not a valid workspace ID
/// let workspace_id = WorkspaceId::try_from("invalid-id").unwrap_err();
/// ```
///
/// ## Comparison
///
/// Workspace IDs can be compared to strings.
///
/// ```
/// use cts_common::WorkspaceId;
/// let workspace_id = WorkspaceId::try_from("E4UMRN47WJNSMAKR").unwrap();
/// assert_eq!(workspace_id, "E4UMRN47WJNSMAKR");
/// ```
///
/// ## Use with Diesel
///
/// When the `server` feature is enabled, [WorkspaceId] can be used with Diesel in models and queries.
/// The underlying data type is a `Text` column in the database.
///
#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize, ToSchema)]
#[serde(transparent)]
#[cfg_attr(
    feature = "server",
    derive(diesel::expression::AsExpression, diesel::deserialize::FromSqlRow)
)]
#[schema(value_type = String, example = "JBSWY3DPEHPK3PXP")]
#[cfg_attr(feature = "server", diesel(sql_type = diesel::sql_types::Text))]
pub struct WorkspaceId(WorkspaceIdArrayString);

impl WorkspaceId {
    /// Generate a new workspace ID with an entropy source.
    /// To use a [SafeRand] instance, use the [`Generatable::random`] method instead.
    pub fn generate() -> Result<Self, WorkspaceIdGenerationError> {
        let mut rng = SafeRand::from_entropy()?;
        Ok(Self::random(&mut rng)?)
    }

    pub fn as_str(&self) -> &str {
        self.0.as_str()
    }
}

/// Allows `WorkspaceId` to be used directly as additional authenticated data (AAD) in
/// AES-256-GCM-SIV encryption via the [`vitaminc`] crate.
///
/// This is used by the refresh token envelope to bind the workspace_id to the ciphertext
/// so that tampering with the workspace_id causes decryption to fail. Because `WorkspaceId`
/// is `Copy` (stack-allocated `ArrayString`), it can be passed by value into composite AAD
/// tuples — e.g. `(extra_aad, workspace_id)` — without allocation or lifetime concerns.
impl<'a> IntoAad<'a> for WorkspaceId {
    fn into_aad(self) -> Aad<'a> {
        Aad::new_owned(self.as_str().bytes())
    }
}

impl PartialEq<&str> for WorkspaceId {
    fn eq(&self, other: &&str) -> bool {
        self.0.as_str() == *other
    }
}

impl PartialEq<String> for WorkspaceId {
    fn eq(&self, other: &String) -> bool {
        self.0.as_str() == other.as_str()
    }
}

impl TryFrom<String> for WorkspaceId {
    type Error = InvalidWorkspaceId;

    fn try_from(value: String) -> Result<Self, Self::Error> {
        value.as_str().try_into()
    }
}

impl TryFrom<&str> for WorkspaceId {
    type Error = InvalidWorkspaceId;

    fn try_from(value: &str) -> Result<Self, Self::Error> {
        if is_valid_workspace_id(value) {
            let mut array_str = WorkspaceIdArrayString::new();
            array_str.push_str(value);
            Ok(Self(array_str))
        } else {
            Err(InvalidWorkspaceId(value.to_string()))
        }
    }
}

impl FromStr for WorkspaceId {
    type Err = InvalidWorkspaceId;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        Self::try_from(value)
    }
}

impl From<WorkspaceId> for String {
    fn from(value: WorkspaceId) -> Self {
        value.0.to_string()
    }
}

impl Generatable for WorkspaceId {
    fn random(rng: &mut vitaminc::random::SafeRand) -> Result<Self, vitaminc::random::RandomError> {
        let buf: [u8; WORKSPACE_ID_BYTE_LEN] = Generatable::random(rng)?;
        let id = base32::encode(ALPHABET, &buf);
        let mut array_str = WorkspaceIdArrayString::new();
        array_str.push_str(&id);
        Ok(Self(array_str))
    }
}

impl Display for WorkspaceId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0)
    }
}

/// Workspace IDs can be converted into HTTP header values.
#[cfg(feature = "server")]
impl TryInto<HeaderValue> for WorkspaceId {
    type Error = http::header::InvalidHeaderValue;

    fn try_into(self) -> Result<HeaderValue, Self::Error> {
        HeaderValue::from_str(self.0.as_str())
    }
}

/// Check if a workspace ID is valid.
/// A valid workspace ID is a base32 encoded string with a length of 10 bytes.
fn is_valid_workspace_id(workspace_id: &str) -> bool {
    if let Some(bytes) = base32::decode(ALPHABET, workspace_id) {
        bytes.len() == WORKSPACE_ID_BYTE_LEN
    } else {
        false
    }
}

#[cfg(feature = "test_utils")]
mod testing {
    use super::*;
    use fake::Faker;
    use rand::Rng;

    impl fake::Dummy<Faker> for WorkspaceId {
        fn dummy_with_rng<R: Rng + ?Sized>(_: &Faker, _: &mut R) -> Self {
            WorkspaceId::generate().unwrap()
        }
    }
}

#[cfg(feature = "server")]
mod sql_types {
    use super::WorkspaceId;
    use diesel::{
        backend::Backend,
        deserialize::{self, FromSql},
        serialize::{self, Output, ToSql},
        sql_types::Text,
    };

    impl<DB> ToSql<Text, DB> for WorkspaceId
    where
        DB: Backend,
        str: ToSql<Text, DB>,
    {
        fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, DB>) -> serialize::Result {
            self.0.to_sql(out)
        }
    }

    impl<DB> FromSql<Text, DB> for WorkspaceId
    where
        DB: Backend,
        String: FromSql<Text, DB>,
    {
        fn from_sql(bytes: DB::RawValue<'_>) -> deserialize::Result<Self> {
            let raw = String::from_sql(bytes)?;
            let workspace_id = WorkspaceId::try_from(raw)?;

            Ok(workspace_id)
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    mod workspace_id {
        use super::*;

        #[test]
        fn generation_is_valid() {
            let mut rng = vitaminc::random::SafeRand::from_entropy().unwrap();
            let id = WorkspaceId::random(&mut rng).unwrap();
            assert!(WorkspaceId::try_from(id.to_string()).is_ok());
        }

        #[test]
        fn invalid_id() {
            assert!(WorkspaceId::try_from("invalid-id").is_err());
        }
    }

    mod workspace {
        use super::*;

        #[test]
        fn serialize() -> anyhow::Result<()> {
            let workspace = Workspace {
                id: WorkspaceId::generate()?,
                region: Region::new("us-west-1.aws")?,
                name: "test-workspace".to_string(),
            };

            let serialized = serde_json::to_string(&workspace)?;
            assert_eq!(
                serialized,
                format!(
                    "{{\"id\":\"{}\",\"region\":\"us-west-1.aws\",\"name\":\"test-workspace\"}}",
                    workspace.id
                )
            );

            Ok(())
        }

        #[test]
        fn desirialise_with_null_workspace_name() {
            let mut rng = vitaminc::random::SafeRand::from_entropy().unwrap();
            let id = WorkspaceId::random(&mut rng).unwrap();
            let serialised =
                format!("{{\"id\":\"{id}\",\"region\":\"us-west-1.aws\",\"name\":null}}",);

            let deserialized: Workspace = serde_json::from_str(&serialised).unwrap();
            assert_eq!("unnamed workspace".to_string(), deserialized.name,);
        }
    }
}