Skip to main content

cts_common/
workspace.rs

1use crate::{AsCrn, Crn, Region};
2use arrayvec::ArrayString;
3#[cfg(feature = "server")]
4use http::HeaderValue;
5use miette::Diagnostic;
6use serde::{Deserialize, Deserializer, Serialize};
7use std::{fmt::Display, str::FromStr};
8use thiserror::Error;
9use utoipa::ToSchema;
10use vitaminc::{Aad, Generatable, IntoAad, SafeRand};
11
12const WORKSPACE_ID_BYTE_LEN: usize = 10;
13const WORKSPACE_ID_ENCODED_LEN: usize = 16;
14const ALPHABET: base32::Alphabet = base32::Alphabet::Rfc4648 { padding: false };
15
16type WorkspaceIdArrayString = ArrayString<WORKSPACE_ID_ENCODED_LEN>;
17
18#[derive(Error, Debug, Diagnostic)]
19#[error("Invalid workspace ID: {0}")]
20#[diagnostic(help = "Workspace IDs are 10-byte random strings formatted in base32.")]
21pub struct InvalidWorkspaceId(String);
22
23#[derive(Error, Debug)]
24#[error("Failed to generate workspace ID")]
25pub struct WorkspaceIdGenerationError(#[from] vitaminc::RandomError);
26
27/// Defines a workspace.
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29pub struct Workspace {
30    id: WorkspaceId,
31    region: Region,
32    #[serde(default = "default_workspace_name")]
33    #[serde(deserialize_with = "deserialize_workspace_name")]
34    name: String,
35}
36
37impl AsCrn for Workspace {
38    fn as_crn(&self) -> crate::Crn {
39        Crn::new(self.region, self.id)
40    }
41}
42
43fn deserialize_workspace_name<'d, D>(deserializer: D) -> Result<String, D::Error>
44where
45    D: Deserializer<'d>,
46{
47    let opt = Option::deserialize(deserializer)?;
48    Ok(opt.unwrap_or("unnamed workspace".to_string()))
49}
50
51impl Workspace {
52    pub fn new(id: WorkspaceId, region: Region, name: impl Into<String>) -> Self {
53        Self {
54            id,
55            region,
56            name: name.into(),
57        }
58    }
59
60    /// The unique identifier of the workspace.
61    /// See [WorkspaceId] for more information.
62    pub fn id(&self) -> WorkspaceId {
63        self.id
64    }
65
66    pub fn crn(&self) -> Crn {
67        Crn::new(self.region, self.id)
68    }
69
70    pub fn name(&self) -> &str {
71        self.name.as_str()
72    }
73
74    pub fn region(&self) -> Region {
75        self.region
76    }
77}
78
79fn default_workspace_name() -> String {
80    "Default".to_string()
81}
82
83/// A unique identifier for a workspace.
84/// Workspace IDs are 10-byte random strings formatted in base32.
85///
86/// Internally, the workspace ID is stored as an [ArrayString] with a maximum length of 20 characters.
87/// This means that values work entirely on the stack and implement the `Copy` trait.
88///
89/// # Example
90///
91/// ```
92/// use cts_common::WorkspaceId;
93///
94/// let workspace_id = WorkspaceId::generate().unwrap();
95/// println!("Workspace ID: {}", workspace_id);
96/// ```
97///
98/// A [WorkspaceId] can be converted from a string but will fail if the string is not a valid workspace ID.
99///
100/// ```
101/// use cts_common::WorkspaceId;
102/// let workspace_id = WorkspaceId::try_from("JBSWY3DPEHPK3PXP").unwrap();
103///
104/// // This will fail because the string is not a valid workspace ID
105/// let workspace_id = WorkspaceId::try_from("invalid-id").unwrap_err();
106/// ```
107///
108/// ## Comparison
109///
110/// Workspace IDs can be compared to strings.
111///
112/// ```
113/// use cts_common::WorkspaceId;
114/// let workspace_id = WorkspaceId::try_from("E4UMRN47WJNSMAKR").unwrap();
115/// assert_eq!(workspace_id, "E4UMRN47WJNSMAKR");
116/// ```
117///
118/// ## Use with Diesel
119///
120/// When the `server` feature is enabled, [WorkspaceId] can be used with Diesel in models and queries.
121/// The underlying data type is a `Text` column in the database.
122///
123#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize, ToSchema)]
124#[serde(transparent)]
125#[cfg_attr(
126    feature = "server",
127    derive(diesel::expression::AsExpression, diesel::deserialize::FromSqlRow)
128)]
129#[schema(value_type = String, example = "JBSWY3DPEHPK3PXP")]
130#[cfg_attr(feature = "server", diesel(sql_type = diesel::sql_types::Text))]
131pub struct WorkspaceId(WorkspaceIdArrayString);
132
133impl WorkspaceId {
134    /// Generate a new workspace ID with an entropy source.
135    /// To use a [SafeRand] instance, use the [`Generatable::random`] method instead.
136    pub fn generate() -> Result<Self, WorkspaceIdGenerationError> {
137        let mut rng = SafeRand::from_entropy()?;
138        Ok(Self::random(&mut rng)?)
139    }
140
141    pub fn as_str(&self) -> &str {
142        self.0.as_str()
143    }
144}
145
146/// Allows `WorkspaceId` to be used directly as additional authenticated data (AAD) in
147/// AES-256-GCM-SIV encryption via the [`vitaminc`] crate.
148///
149/// This is used by the refresh token envelope to bind the workspace_id to the ciphertext
150/// so that tampering with the workspace_id causes decryption to fail. Because `WorkspaceId`
151/// is `Copy` (stack-allocated `ArrayString`), it can be passed by value into composite AAD
152/// tuples — e.g. `(extra_aad, workspace_id)` — without allocation or lifetime concerns.
153impl<'a> IntoAad<'a> for WorkspaceId {
154    fn into_aad(self) -> Aad<'a> {
155        Aad::new_owned(self.as_str().bytes())
156    }
157}
158
159impl PartialEq<&str> for WorkspaceId {
160    fn eq(&self, other: &&str) -> bool {
161        self.0.as_str() == *other
162    }
163}
164
165impl PartialEq<String> for WorkspaceId {
166    fn eq(&self, other: &String) -> bool {
167        self.0.as_str() == other.as_str()
168    }
169}
170
171impl TryFrom<String> for WorkspaceId {
172    type Error = InvalidWorkspaceId;
173
174    fn try_from(value: String) -> Result<Self, Self::Error> {
175        value.as_str().try_into()
176    }
177}
178
179impl TryFrom<&str> for WorkspaceId {
180    type Error = InvalidWorkspaceId;
181
182    fn try_from(value: &str) -> Result<Self, Self::Error> {
183        if is_valid_workspace_id(value) {
184            let mut array_str = WorkspaceIdArrayString::new();
185            array_str.push_str(value);
186            Ok(Self(array_str))
187        } else {
188            Err(InvalidWorkspaceId(value.to_string()))
189        }
190    }
191}
192
193impl FromStr for WorkspaceId {
194    type Err = InvalidWorkspaceId;
195
196    fn from_str(value: &str) -> Result<Self, Self::Err> {
197        Self::try_from(value)
198    }
199}
200
201impl From<WorkspaceId> for String {
202    fn from(value: WorkspaceId) -> Self {
203        value.0.to_string()
204    }
205}
206
207impl Generatable for WorkspaceId {
208    fn random(rng: &mut vitaminc::SafeRand) -> Result<Self, vitaminc::RandomError> {
209        let buf: [u8; WORKSPACE_ID_BYTE_LEN] = Generatable::random(rng)?;
210        let id = base32::encode(ALPHABET, &buf);
211        let mut array_str = WorkspaceIdArrayString::new();
212        array_str.push_str(&id);
213        Ok(Self(array_str))
214    }
215}
216
217impl Display for WorkspaceId {
218    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219        write!(f, "{}", self.0)
220    }
221}
222
223/// Workspace IDs can be converted into HTTP header values.
224#[cfg(feature = "server")]
225impl TryInto<HeaderValue> for WorkspaceId {
226    type Error = http::header::InvalidHeaderValue;
227
228    fn try_into(self) -> Result<HeaderValue, Self::Error> {
229        HeaderValue::from_str(self.0.as_str())
230    }
231}
232
233/// Check if a workspace ID is valid.
234/// A valid workspace ID is a base32 encoded string with a length of 10 bytes.
235fn is_valid_workspace_id(workspace_id: &str) -> bool {
236    if let Some(bytes) = base32::decode(ALPHABET, workspace_id) {
237        bytes.len() == WORKSPACE_ID_BYTE_LEN
238    } else {
239        false
240    }
241}
242
243#[cfg(feature = "test_utils")]
244mod testing {
245    use super::*;
246    use fake::Faker;
247    use rand::Rng;
248
249    impl fake::Dummy<Faker> for WorkspaceId {
250        fn dummy_with_rng<R: Rng + ?Sized>(_: &Faker, _: &mut R) -> Self {
251            WorkspaceId::generate().unwrap()
252        }
253    }
254}
255
256#[cfg(feature = "server")]
257mod sql_types {
258    use super::WorkspaceId;
259    use diesel::{
260        backend::Backend,
261        deserialize::{self, FromSql},
262        serialize::{self, Output, ToSql},
263        sql_types::Text,
264    };
265
266    impl<DB> ToSql<Text, DB> for WorkspaceId
267    where
268        DB: Backend,
269        str: ToSql<Text, DB>,
270    {
271        fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, DB>) -> serialize::Result {
272            self.0.to_sql(out)
273        }
274    }
275
276    impl<DB> FromSql<Text, DB> for WorkspaceId
277    where
278        DB: Backend,
279        String: FromSql<Text, DB>,
280    {
281        fn from_sql(bytes: DB::RawValue<'_>) -> deserialize::Result<Self> {
282            let raw = String::from_sql(bytes)?;
283            let workspace_id = WorkspaceId::try_from(raw)?;
284
285            Ok(workspace_id)
286        }
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    mod workspace_id {
295        use super::*;
296
297        #[test]
298        fn generation_is_valid() {
299            let mut rng = vitaminc::SafeRand::from_entropy().unwrap();
300            let id = WorkspaceId::random(&mut rng).unwrap();
301            assert!(WorkspaceId::try_from(id.to_string()).is_ok());
302        }
303
304        #[test]
305        fn invalid_id() {
306            assert!(WorkspaceId::try_from("invalid-id").is_err());
307        }
308    }
309
310    mod workspace {
311        use super::*;
312
313        #[test]
314        fn serialize() -> anyhow::Result<()> {
315            let workspace = Workspace {
316                id: WorkspaceId::generate()?,
317                region: Region::new("us-west-1.aws")?,
318                name: "test-workspace".to_string(),
319            };
320
321            let serialized = serde_json::to_string(&workspace)?;
322            assert_eq!(
323                serialized,
324                format!(
325                    "{{\"id\":\"{}\",\"region\":\"us-west-1.aws\",\"name\":\"test-workspace\"}}",
326                    workspace.id
327                )
328            );
329
330            Ok(())
331        }
332
333        #[test]
334        fn desirialise_with_null_workspace_name() {
335            let mut rng = vitaminc::SafeRand::from_entropy().unwrap();
336            let id = WorkspaceId::random(&mut rng).unwrap();
337            let serialised =
338                format!("{{\"id\":\"{id}\",\"region\":\"us-west-1.aws\",\"name\":null}}",);
339
340            let deserialized: Workspace = serde_json::from_str(&serialised).unwrap();
341            assert_eq!("unnamed workspace".to_string(), deserialized.name,);
342        }
343    }
344}