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