zopp_storage/
lib.rs

1//! Storage abstraction for zopp.
2//!
3//! Backend crates (e.g., zopp-store-sqlite, zopp-store-postgres) implement this trait so
4//! `zopp-core` doesn't depend on any specific database engine or schema details.
5
6use chrono::{DateTime, Utc};
7use thiserror::Error;
8use uuid::Uuid;
9
10/// Uniform error type for all storage backends.
11#[derive(Debug, Error)]
12pub enum StoreError {
13    #[error("not found")]
14    NotFound,
15    #[error("already exists")]
16    AlreadyExists,
17    #[error("conflict")]
18    Conflict,
19    #[error("backend error: {0}")]
20    Backend(String),
21}
22
23/// Strongly-typed identifiers & names (avoid mixing strings arbitrarily).
24#[derive(Clone, Debug, PartialEq, Eq, Hash)]
25pub struct UserId(pub Uuid);
26
27#[derive(Clone, Debug, PartialEq, Eq, Hash)]
28pub struct PrincipalId(pub Uuid);
29
30#[derive(Clone, Debug, PartialEq, Eq, Hash)]
31pub struct InviteId(pub Uuid);
32
33#[derive(Clone, Debug, PartialEq, Eq, Hash)]
34pub struct WorkspaceId(pub Uuid);
35
36#[derive(Clone, Debug, PartialEq, Eq, Hash)]
37pub struct ProjectId(pub Uuid);
38
39#[derive(Clone, Debug, PartialEq, Eq, Hash)]
40pub struct ProjectName(pub String);
41
42#[derive(Clone, Debug, PartialEq, Eq, Hash)]
43pub struct EnvironmentId(pub Uuid);
44
45#[derive(Clone, Debug, PartialEq, Eq, Hash)]
46pub struct EnvName(pub String);
47
48/// Encrypted secret row (nonce + ciphertext); no plaintext in storage.
49#[derive(Clone, Debug)]
50pub struct SecretRow {
51    pub nonce: Vec<u8>,      // 24 bytes (XChaCha20 nonce)
52    pub ciphertext: Vec<u8>, // AEAD ciphertext
53}
54
55/// Parameters for creating a user
56#[derive(Clone, Debug)]
57pub struct CreateUserParams {
58    pub email: String,
59    /// Optional principal to create atomically with the user
60    pub principal: Option<CreatePrincipalData>,
61    /// Workspaces to add this user to (user-level membership)
62    pub workspace_ids: Vec<WorkspaceId>,
63}
64
65/// Principal data for atomic user creation
66#[derive(Clone, Debug)]
67pub struct CreatePrincipalData {
68    pub name: String,
69    pub public_key: Vec<u8>,                // Ed25519 for authentication
70    pub x25519_public_key: Option<Vec<u8>>, // X25519 for encryption (ECDH)
71    pub is_service: bool,                   // Service principal (user_id will be NULL)
72}
73
74/// Parameters for creating a principal
75#[derive(Clone, Debug)]
76pub struct CreatePrincipalParams {
77    pub user_id: Option<UserId>, // None for service accounts
78    pub name: String,
79    pub public_key: Vec<u8>,                // Ed25519 for authentication
80    pub x25519_public_key: Option<Vec<u8>>, // X25519 for encryption (ECDH)
81}
82
83/// Parameters for creating a workspace
84#[derive(Clone, Debug)]
85pub struct CreateWorkspaceParams {
86    pub id: WorkspaceId, // Client-generated workspace ID
87    pub name: String,
88    pub owner_user_id: UserId,
89    pub kdf_salt: Vec<u8>, // >= 16 bytes
90    pub m_cost_kib: u32,   // memory cost (KiB)
91    pub t_cost: u32,       // iterations
92    pub p_cost: u32,       // parallelism
93}
94
95/// Parameters for creating an invite
96#[derive(Clone, Debug)]
97pub struct CreateInviteParams {
98    pub workspace_ids: Vec<WorkspaceId>,
99    pub token: String,                  // Hash of invite secret (for lookup)
100    pub kek_encrypted: Option<Vec<u8>>, // Workspace KEK encrypted with invite secret
101    pub kek_nonce: Option<Vec<u8>>,     // 24-byte nonce for KEK encryption
102    pub expires_at: DateTime<Utc>,
103    pub created_by_user_id: Option<UserId>, // None for server-created invites
104}
105
106/// Parameters for creating a project
107#[derive(Clone, Debug)]
108pub struct CreateProjectParams {
109    pub workspace_id: WorkspaceId,
110    pub name: String,
111}
112
113/// Parameters for creating an environment
114#[derive(Clone, Debug)]
115pub struct CreateEnvParams {
116    pub project_id: ProjectId,
117    pub name: String,
118    pub dek_wrapped: Vec<u8>, // wrapped DEK
119    pub dek_nonce: Vec<u8>,   // 24-byte nonce used in wrapping
120}
121
122/// User record
123#[derive(Clone, Debug)]
124pub struct User {
125    pub id: UserId,
126    pub email: String,
127    pub created_at: DateTime<Utc>,
128    pub updated_at: DateTime<Utc>,
129}
130
131/// Principal (device or service account) record
132#[derive(Clone, Debug)]
133pub struct Principal {
134    pub id: PrincipalId,
135    pub user_id: Option<UserId>, // None for service accounts
136    pub name: String,
137    pub public_key: Vec<u8>,                // Ed25519 for authentication
138    pub x25519_public_key: Option<Vec<u8>>, // X25519 for encryption (ECDH)
139    pub created_at: DateTime<Utc>,
140    pub updated_at: DateTime<Utc>,
141}
142
143/// Invite record
144#[derive(Clone, Debug)]
145pub struct Invite {
146    pub id: InviteId,
147    pub token: String,
148    pub workspace_ids: Vec<WorkspaceId>,
149    pub kek_encrypted: Option<Vec<u8>>, // Workspace KEK encrypted with invite secret
150    pub kek_nonce: Option<Vec<u8>>,     // 24-byte nonce for KEK encryption
151    pub created_at: DateTime<Utc>,
152    pub updated_at: DateTime<Utc>,
153    pub expires_at: DateTime<Utc>,
154    pub created_by_user_id: Option<UserId>, // None for server-created invites
155}
156
157/// Workspace record
158#[derive(Clone, Debug)]
159pub struct Workspace {
160    pub id: WorkspaceId,
161    pub name: String,
162    pub owner_user_id: UserId,
163    pub kdf_salt: Vec<u8>,
164    pub m_cost_kib: u32,
165    pub t_cost: u32,
166    pub p_cost: u32,
167    pub created_at: DateTime<Utc>,
168    pub updated_at: DateTime<Utc>,
169}
170
171/// Workspace-Principal junction with wrapped KEK
172#[derive(Clone, Debug)]
173pub struct WorkspacePrincipal {
174    pub workspace_id: WorkspaceId,
175    pub principal_id: PrincipalId,
176    pub ephemeral_pub: Vec<u8>, // Ephemeral X25519 public key for wrapping
177    pub kek_wrapped: Vec<u8>,   // Workspace KEK wrapped for this principal
178    pub kek_nonce: Vec<u8>,     // 24-byte nonce for wrapping
179    pub created_at: DateTime<Utc>,
180}
181
182/// Parameters for adding a principal to a workspace with wrapped KEK
183#[derive(Clone, Debug)]
184pub struct AddWorkspacePrincipalParams {
185    pub workspace_id: WorkspaceId,
186    pub principal_id: PrincipalId,
187    pub ephemeral_pub: Vec<u8>,
188    pub kek_wrapped: Vec<u8>,
189    pub kek_nonce: Vec<u8>,
190}
191
192/// Project record
193#[derive(Clone, Debug)]
194pub struct Project {
195    pub id: ProjectId,
196    pub workspace_id: WorkspaceId,
197    pub name: String,
198    pub created_at: DateTime<Utc>,
199    pub updated_at: DateTime<Utc>,
200}
201
202/// Environment record
203#[derive(Clone, Debug)]
204pub struct Environment {
205    pub id: EnvironmentId,
206    pub project_id: ProjectId,
207    pub name: String,
208    pub dek_wrapped: Vec<u8>,
209    pub dek_nonce: Vec<u8>,
210    pub version: i64, // Monotonic version counter for change tracking
211    pub created_at: DateTime<Utc>,
212    pub updated_at: DateTime<Utc>,
213}
214
215/// The storage trait `zopp-core` depends on.
216///
217/// All methods that act on project/env/secrets are **scoped by workspace**.
218#[async_trait::async_trait]
219pub trait Store {
220    // ───────────────────────────────────── Users ──────────────────────────────────────────
221
222    /// Create a new user (returns generated ID, and optional principal ID if principal was provided).
223    /// If params.principal is provided, atomically creates the user, principal, and adds principal to workspaces.
224    async fn create_user(
225        &self,
226        params: &CreateUserParams,
227    ) -> Result<(UserId, Option<PrincipalId>), StoreError>;
228
229    /// Get user by email.
230    async fn get_user_by_email(&self, email: &str) -> Result<User, StoreError>;
231
232    /// Get user by ID.
233    async fn get_user_by_id(&self, user_id: &UserId) -> Result<User, StoreError>;
234
235    // ───────────────────────────────────── Principals ─────────────────────────────────────
236
237    /// Create a new principal (device) for a user.
238    async fn create_principal(
239        &self,
240        params: &CreatePrincipalParams,
241    ) -> Result<PrincipalId, StoreError>;
242
243    /// Get principal by ID.
244    async fn get_principal(&self, principal_id: &PrincipalId) -> Result<Principal, StoreError>;
245
246    /// Rename a principal.
247    async fn rename_principal(
248        &self,
249        principal_id: &PrincipalId,
250        new_name: &str,
251    ) -> Result<(), StoreError>;
252
253    /// List all principals for a user.
254    async fn list_principals(&self, user_id: &UserId) -> Result<Vec<Principal>, StoreError>;
255
256    // ───────────────────────────────────── Invites ────────────────────────────────────────
257
258    /// Create an invite token (returns generated ID and token).
259    async fn create_invite(&self, params: &CreateInviteParams) -> Result<Invite, StoreError>;
260
261    /// Get invite by token.
262    async fn get_invite_by_token(&self, token: &str) -> Result<Invite, StoreError>;
263
264    /// List all active invites for a user (None = server invites).
265    async fn list_invites(&self, user_id: Option<&UserId>) -> Result<Vec<Invite>, StoreError>;
266
267    /// Revoke an invite.
268    async fn revoke_invite(&self, invite_id: &InviteId) -> Result<(), StoreError>;
269
270    // ───────────────────────────────────── Workspaces ─────────────────────────────────────
271
272    /// Create a new workspace (returns its generated ID).
273    async fn create_workspace(
274        &self,
275        params: &CreateWorkspaceParams,
276    ) -> Result<WorkspaceId, StoreError>;
277
278    /// List all workspaces for a user (via their principals).
279    async fn list_workspaces(&self, user_id: &UserId) -> Result<Vec<Workspace>, StoreError>;
280
281    /// Get workspace by ID.
282    async fn get_workspace(&self, ws: &WorkspaceId) -> Result<Workspace, StoreError>;
283
284    /// Get workspace by name for a user (user must have access).
285    async fn get_workspace_by_name(
286        &self,
287        user_id: &UserId,
288        name: &str,
289    ) -> Result<Workspace, StoreError>;
290
291    /// Get workspace by name for a principal (principal must have access).
292    async fn get_workspace_by_name_for_principal(
293        &self,
294        principal_id: &PrincipalId,
295        name: &str,
296    ) -> Result<Workspace, StoreError>;
297
298    /// Add a principal to a workspace with wrapped KEK.
299    async fn add_workspace_principal(
300        &self,
301        params: &AddWorkspacePrincipalParams,
302    ) -> Result<(), StoreError>;
303
304    /// Get workspace principal (to access wrapped KEK).
305    async fn get_workspace_principal(
306        &self,
307        workspace_id: &WorkspaceId,
308        principal_id: &PrincipalId,
309    ) -> Result<WorkspacePrincipal, StoreError>;
310
311    /// List all principals in a workspace (with their wrapped KEKs).
312    async fn list_workspace_principals(
313        &self,
314        workspace_id: &WorkspaceId,
315    ) -> Result<Vec<WorkspacePrincipal>, StoreError>;
316
317    /// Add a user to a workspace (user-level membership).
318    async fn add_user_to_workspace(
319        &self,
320        workspace_id: &WorkspaceId,
321        user_id: &UserId,
322    ) -> Result<(), StoreError>;
323
324    // ───────────────────────────────────── Projects ───────────────────────────────────────
325
326    /// Create a project within a workspace (returns generated ID).
327    async fn create_project(&self, params: &CreateProjectParams) -> Result<ProjectId, StoreError>;
328
329    /// List all projects in a workspace.
330    async fn list_projects(&self, workspace_id: &WorkspaceId) -> Result<Vec<Project>, StoreError>;
331
332    /// Get a project by ID.
333    async fn get_project(&self, project_id: &ProjectId) -> Result<Project, StoreError>;
334
335    /// Get a project by name within a workspace.
336    async fn get_project_by_name(
337        &self,
338        workspace_id: &WorkspaceId,
339        name: &str,
340    ) -> Result<Project, StoreError>;
341
342    /// Delete a project (and all its environments and secrets).
343    async fn delete_project(&self, project_id: &ProjectId) -> Result<(), StoreError>;
344
345    // ─────────────────────────────────────── Environments ─────────────────────────────────────
346
347    /// Create an environment within a project (returns generated ID).
348    async fn create_env(&self, params: &CreateEnvParams) -> Result<EnvironmentId, StoreError>;
349
350    /// List all environments in a project.
351    async fn list_environments(
352        &self,
353        project_id: &ProjectId,
354    ) -> Result<Vec<Environment>, StoreError>;
355
356    /// Get an environment by ID.
357    async fn get_environment(&self, env_id: &EnvironmentId) -> Result<Environment, StoreError>;
358
359    /// Get an environment by name within a project.
360    async fn get_environment_by_name(
361        &self,
362        project_id: &ProjectId,
363        name: &str,
364    ) -> Result<Environment, StoreError>;
365
366    /// Delete an environment (and all its secrets).
367    async fn delete_environment(&self, env_id: &EnvironmentId) -> Result<(), StoreError>;
368
369    // ────────────────────────────────────── Secrets ───────────────────────────────────────
370
371    /// Upsert a secret value (AEAD ciphertext + nonce) in an environment.
372    /// Returns the new environment version after the update.
373    async fn upsert_secret(
374        &self,
375        env_id: &EnvironmentId,
376        key: &str,
377        nonce: &[u8],      // per-value 24B nonce
378        ciphertext: &[u8], // AEAD ciphertext under DEK
379    ) -> Result<i64, StoreError>;
380
381    /// Fetch a secret row (nonce + ciphertext).
382    async fn get_secret(&self, env_id: &EnvironmentId, key: &str) -> Result<SecretRow, StoreError>;
383
384    /// List all secret keys in an environment.
385    async fn list_secret_keys(&self, env_id: &EnvironmentId) -> Result<Vec<String>, StoreError>;
386
387    /// Delete a secret from an environment.
388    /// Returns the new environment version after the deletion.
389    async fn delete_secret(&self, env_id: &EnvironmentId, key: &str) -> Result<i64, StoreError>;
390
391    /// Fetch the (wrapped_dek, dek_nonce) pair for an environment so core can unwrap it (legacy name-based).
392    async fn get_env_wrap(
393        &self,
394        ws: &WorkspaceId,
395        project: &ProjectName,
396        env: &EnvName,
397    ) -> Result<(Vec<u8>, Vec<u8>), StoreError>;
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    // Tiny compile-time smoke test for trait object usage.
405    struct NoopStore;
406    #[async_trait::async_trait]
407    impl Store for NoopStore {
408        async fn create_user(
409            &self,
410            _params: &CreateUserParams,
411        ) -> Result<(UserId, Option<PrincipalId>), StoreError> {
412            let user_id = UserId(Uuid::new_v4());
413            let principal_id = _params
414                .principal
415                .as_ref()
416                .map(|_| PrincipalId(Uuid::new_v4()));
417            Ok((user_id, principal_id))
418        }
419
420        async fn get_user_by_email(&self, _email: &str) -> Result<User, StoreError> {
421            Err(StoreError::NotFound)
422        }
423
424        async fn get_user_by_id(&self, _user_id: &UserId) -> Result<User, StoreError> {
425            Err(StoreError::NotFound)
426        }
427
428        async fn create_principal(
429            &self,
430            _params: &CreatePrincipalParams,
431        ) -> Result<PrincipalId, StoreError> {
432            Ok(PrincipalId(Uuid::new_v4()))
433        }
434
435        async fn get_principal(
436            &self,
437            _principal_id: &PrincipalId,
438        ) -> Result<Principal, StoreError> {
439            Err(StoreError::NotFound)
440        }
441
442        async fn rename_principal(
443            &self,
444            _principal_id: &PrincipalId,
445            _new_name: &str,
446        ) -> Result<(), StoreError> {
447            Ok(())
448        }
449
450        async fn list_principals(&self, _user_id: &UserId) -> Result<Vec<Principal>, StoreError> {
451            Ok(vec![])
452        }
453
454        async fn create_invite(&self, _params: &CreateInviteParams) -> Result<Invite, StoreError> {
455            Ok(Invite {
456                id: InviteId(Uuid::new_v4()),
457                token: "test-token".to_string(),
458                workspace_ids: vec![],
459                kek_encrypted: None,
460                kek_nonce: None,
461                created_at: Utc::now(),
462                updated_at: Utc::now(),
463                expires_at: Utc::now(),
464                created_by_user_id: _params.created_by_user_id.clone(),
465            })
466        }
467
468        async fn get_invite_by_token(&self, _token: &str) -> Result<Invite, StoreError> {
469            Err(StoreError::NotFound)
470        }
471
472        async fn list_invites(&self, _user_id: Option<&UserId>) -> Result<Vec<Invite>, StoreError> {
473            Ok(vec![])
474        }
475
476        async fn revoke_invite(&self, _invite_id: &InviteId) -> Result<(), StoreError> {
477            Ok(())
478        }
479
480        async fn create_workspace(
481            &self,
482            _params: &CreateWorkspaceParams,
483        ) -> Result<WorkspaceId, StoreError> {
484            Ok(WorkspaceId(Uuid::new_v4()))
485        }
486
487        async fn list_workspaces(&self, _user_id: &UserId) -> Result<Vec<Workspace>, StoreError> {
488            Ok(vec![])
489        }
490
491        async fn get_workspace(&self, _ws: &WorkspaceId) -> Result<Workspace, StoreError> {
492            Err(StoreError::NotFound)
493        }
494
495        async fn get_workspace_by_name(
496            &self,
497            _user_id: &UserId,
498            _name: &str,
499        ) -> Result<Workspace, StoreError> {
500            Err(StoreError::NotFound)
501        }
502
503        async fn get_workspace_by_name_for_principal(
504            &self,
505            _principal_id: &PrincipalId,
506            _name: &str,
507        ) -> Result<Workspace, StoreError> {
508            Err(StoreError::NotFound)
509        }
510
511        async fn add_workspace_principal(
512            &self,
513            _params: &AddWorkspacePrincipalParams,
514        ) -> Result<(), StoreError> {
515            Ok(())
516        }
517
518        async fn get_workspace_principal(
519            &self,
520            _workspace_id: &WorkspaceId,
521            _principal_id: &PrincipalId,
522        ) -> Result<WorkspacePrincipal, StoreError> {
523            Err(StoreError::NotFound)
524        }
525
526        async fn list_workspace_principals(
527            &self,
528            _workspace_id: &WorkspaceId,
529        ) -> Result<Vec<WorkspacePrincipal>, StoreError> {
530            Ok(vec![])
531        }
532
533        async fn add_user_to_workspace(
534            &self,
535            _workspace_id: &WorkspaceId,
536            _user_id: &UserId,
537        ) -> Result<(), StoreError> {
538            Ok(())
539        }
540
541        async fn create_project(
542            &self,
543            _params: &CreateProjectParams,
544        ) -> Result<ProjectId, StoreError> {
545            Ok(ProjectId(Uuid::new_v4()))
546        }
547
548        async fn list_projects(
549            &self,
550            _workspace_id: &WorkspaceId,
551        ) -> Result<Vec<Project>, StoreError> {
552            Ok(vec![])
553        }
554
555        async fn get_project(&self, _project_id: &ProjectId) -> Result<Project, StoreError> {
556            Err(StoreError::NotFound)
557        }
558
559        async fn get_project_by_name(
560            &self,
561            _workspace_id: &WorkspaceId,
562            _name: &str,
563        ) -> Result<Project, StoreError> {
564            Err(StoreError::NotFound)
565        }
566
567        async fn delete_project(&self, _project_id: &ProjectId) -> Result<(), StoreError> {
568            Ok(())
569        }
570
571        async fn create_env(&self, _params: &CreateEnvParams) -> Result<EnvironmentId, StoreError> {
572            Ok(EnvironmentId(Uuid::new_v4()))
573        }
574
575        async fn list_environments(
576            &self,
577            _project_id: &ProjectId,
578        ) -> Result<Vec<Environment>, StoreError> {
579            Ok(vec![])
580        }
581
582        async fn get_environment(
583            &self,
584            _env_id: &EnvironmentId,
585        ) -> Result<Environment, StoreError> {
586            Err(StoreError::NotFound)
587        }
588
589        async fn get_environment_by_name(
590            &self,
591            _project_id: &ProjectId,
592            _name: &str,
593        ) -> Result<Environment, StoreError> {
594            Err(StoreError::NotFound)
595        }
596
597        async fn delete_environment(&self, _env_id: &EnvironmentId) -> Result<(), StoreError> {
598            Ok(())
599        }
600
601        async fn get_env_wrap(
602            &self,
603            _ws: &WorkspaceId,
604            _project: &ProjectName,
605            _env: &EnvName,
606        ) -> Result<(Vec<u8>, Vec<u8>), StoreError> {
607            Err(StoreError::NotFound)
608        }
609
610        async fn upsert_secret(
611            &self,
612            _env_id: &EnvironmentId,
613            _key: &str,
614            _nonce: &[u8],
615            _ciphertext: &[u8],
616        ) -> Result<i64, StoreError> {
617            Ok(1)
618        }
619
620        async fn get_secret(
621            &self,
622            _env_id: &EnvironmentId,
623            _key: &str,
624        ) -> Result<SecretRow, StoreError> {
625            Err(StoreError::NotFound)
626        }
627
628        async fn list_secret_keys(
629            &self,
630            _env_id: &EnvironmentId,
631        ) -> Result<Vec<String>, StoreError> {
632            Ok(vec![])
633        }
634
635        async fn delete_secret(
636            &self,
637            _env_id: &EnvironmentId,
638            _key: &str,
639        ) -> Result<i64, StoreError> {
640            Ok(1)
641        }
642    }
643
644    #[tokio::test]
645    async fn trait_smoke() {
646        let s = NoopStore;
647
648        let (user_id, _) = s
649            .create_user(&CreateUserParams {
650                email: "test@example.com".to_string(),
651                principal: None,
652                workspace_ids: vec![],
653            })
654            .await
655            .unwrap();
656
657        let ws = s
658            .create_workspace(&CreateWorkspaceParams {
659                id: WorkspaceId(uuid::Uuid::now_v7()),
660                name: "test-workspace".to_string(),
661                owner_user_id: user_id.clone(),
662                kdf_salt: b"0123456789abcdef".to_vec(),
663                m_cost_kib: 64 * 1024,
664                t_cost: 3,
665                p_cost: 1,
666            })
667            .await
668            .unwrap();
669
670        // We can call workspace-scoped methods without compile errors.
671        let project_id = s
672            .create_project(&CreateProjectParams {
673                workspace_id: ws.clone(),
674                name: "p1".to_string(),
675            })
676            .await
677            .unwrap();
678
679        let _ = s.list_workspaces(&user_id).await.unwrap();
680        let _ = s.list_projects(&ws).await.unwrap();
681        let _ = s.get_project(&project_id).await;
682    }
683}