1use sqlx::{postgres::PgPoolOptions, PgPool};
2use uuid::Uuid;
3use zopp_storage::{
4 AddWorkspacePrincipalParams, CreateEnvParams, CreateInviteParams, CreatePrincipalParams,
5 CreateProjectParams, CreateUserParams, CreateWorkspaceParams, EnvName, Environment,
6 EnvironmentId, Invite, InviteId, Principal, PrincipalId, ProjectName, SecretRow, Store,
7 StoreError, User, UserId, Workspace, WorkspaceId, WorkspacePrincipal,
8};
9
10static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
11
12pub struct PostgresStore {
13 pool: PgPool,
14}
15
16impl PostgresStore {
17 pub async fn open(url: &str) -> Result<Self, StoreError> {
18 let pool = PgPoolOptions::new()
19 .max_connections(10)
20 .connect(url)
21 .await
22 .map_err(|e| StoreError::Backend(e.to_string()))?;
23
24 MIGRATOR
25 .run(&pool)
26 .await
27 .map_err(|e| StoreError::Backend(e.to_string()))?;
28
29 Ok(Self { pool })
30 }
31}
32
33#[async_trait::async_trait]
34impl Store for PostgresStore {
35 async fn create_user(
38 &self,
39 params: &CreateUserParams,
40 ) -> Result<(UserId, Option<PrincipalId>), StoreError> {
41 let needs_tx = params.principal.is_some() || !params.workspace_ids.is_empty();
42
43 if needs_tx {
44 let mut tx = self
45 .pool
46 .begin()
47 .await
48 .map_err(|e| StoreError::Backend(e.to_string()))?;
49
50 let existing_user = sqlx::query!("SELECT id FROM users WHERE email = $1", params.email)
51 .fetch_optional(&mut *tx)
52 .await
53 .map_err(|e| StoreError::Backend(e.to_string()))?;
54
55 let actual_user_id = if let Some(existing) = existing_user {
56 existing.id
57 } else {
58 let user_id = Uuid::now_v7();
59 sqlx::query!(
60 "INSERT INTO users(id, email) VALUES($1, $2)",
61 user_id,
62 params.email
63 )
64 .execute(&mut *tx)
65 .await
66 .map_err(|e| StoreError::Backend(e.to_string()))?;
67 user_id
68 };
69
70 let principal_id = if let Some(principal_data) = ¶ms.principal {
71 let principal_id = Uuid::now_v7();
72
73 let user_id_for_principal = if principal_data.is_service {
74 None
75 } else {
76 Some(actual_user_id)
77 };
78
79 sqlx::query!(
80 "INSERT INTO principals(id, user_id, name, public_key, x25519_public_key) VALUES($1, $2, $3, $4, $5)",
81 principal_id,
82 user_id_for_principal,
83 principal_data.name,
84 principal_data.public_key,
85 principal_data.x25519_public_key
86 )
87 .execute(&mut *tx)
88 .await
89 .map_err(|e| StoreError::Backend(e.to_string()))?;
90
91 Some(PrincipalId(principal_id))
92 } else {
93 None
94 };
95
96 for workspace_id in ¶ms.workspace_ids {
97 sqlx::query!(
98 "INSERT INTO workspace_members(workspace_id, user_id) VALUES($1, $2)",
99 workspace_id.0,
100 actual_user_id
101 )
102 .execute(&mut *tx)
103 .await
104 .map_err(|e| StoreError::Backend(e.to_string()))?;
105 }
106
107 tx.commit()
108 .await
109 .map_err(|e| StoreError::Backend(e.to_string()))?;
110
111 Ok((UserId(actual_user_id), principal_id))
112 } else {
113 let user_id = Uuid::now_v7();
114
115 sqlx::query!(
116 "INSERT INTO users(id, email) VALUES($1, $2)",
117 user_id,
118 params.email
119 )
120 .execute(&self.pool)
121 .await
122 .map_err(|e| {
123 let s = e.to_string();
124 if s.contains("duplicate key") || s.contains("unique constraint") {
125 StoreError::AlreadyExists
126 } else {
127 StoreError::Backend(s)
128 }
129 })?;
130
131 Ok((UserId(user_id), None))
132 }
133 }
134
135 async fn get_user_by_email(&self, email: &str) -> Result<User, StoreError> {
136 let row = sqlx::query!(
137 r#"SELECT id, email, created_at, updated_at FROM users WHERE email = $1"#,
138 email
139 )
140 .fetch_optional(&self.pool)
141 .await
142 .map_err(|e| StoreError::Backend(e.to_string()))?
143 .ok_or(StoreError::NotFound)?;
144
145 Ok(User {
146 id: UserId(row.id),
147 email: row.email,
148 created_at: row.created_at,
149 updated_at: row.updated_at,
150 })
151 }
152
153 async fn get_user_by_id(&self, user_id: &UserId) -> Result<User, StoreError> {
154 let row = sqlx::query!(
155 r#"SELECT id, email, created_at, updated_at FROM users WHERE id = $1"#,
156 user_id.0
157 )
158 .fetch_optional(&self.pool)
159 .await
160 .map_err(|e| StoreError::Backend(e.to_string()))?
161 .ok_or(StoreError::NotFound)?;
162
163 Ok(User {
164 id: UserId(row.id),
165 email: row.email,
166 created_at: row.created_at,
167 updated_at: row.updated_at,
168 })
169 }
170
171 async fn create_principal(
174 &self,
175 params: &CreatePrincipalParams,
176 ) -> Result<PrincipalId, StoreError> {
177 let principal_id = Uuid::now_v7();
178 let user_id = params.user_id.as_ref().map(|id| id.0);
179
180 sqlx::query!(
181 "INSERT INTO principals(id, user_id, name, public_key, x25519_public_key) VALUES($1, $2, $3, $4, $5)",
182 principal_id,
183 user_id,
184 params.name,
185 params.public_key,
186 params.x25519_public_key
187 )
188 .execute(&self.pool)
189 .await
190 .map_err(|e| {
191 let s = e.to_string();
192 if s.contains("duplicate key") || s.contains("unique constraint") {
193 StoreError::AlreadyExists
194 } else {
195 StoreError::Backend(s)
196 }
197 })?;
198 Ok(PrincipalId(principal_id))
199 }
200
201 async fn get_principal(&self, principal_id: &PrincipalId) -> Result<Principal, StoreError> {
202 let row = sqlx::query!(
203 r#"SELECT id, user_id, name, public_key, x25519_public_key, created_at, updated_at
204 FROM principals WHERE id = $1"#,
205 principal_id.0
206 )
207 .fetch_optional(&self.pool)
208 .await
209 .map_err(|e| StoreError::Backend(e.to_string()))?
210 .ok_or(StoreError::NotFound)?;
211
212 Ok(Principal {
213 id: PrincipalId(row.id),
214 user_id: row.user_id.map(UserId),
215 name: row.name,
216 public_key: row.public_key,
217 x25519_public_key: row.x25519_public_key,
218 created_at: row.created_at,
219 updated_at: row.updated_at,
220 })
221 }
222
223 async fn rename_principal(
224 &self,
225 principal_id: &PrincipalId,
226 new_name: &str,
227 ) -> Result<(), StoreError> {
228 let result = sqlx::query!(
229 "UPDATE principals SET name = $1 WHERE id = $2",
230 new_name,
231 principal_id.0
232 )
233 .execute(&self.pool)
234 .await
235 .map_err(|e| StoreError::Backend(e.to_string()))?;
236
237 if result.rows_affected() == 0 {
238 Err(StoreError::NotFound)
239 } else {
240 Ok(())
241 }
242 }
243
244 async fn list_principals(&self, user_id: &UserId) -> Result<Vec<Principal>, StoreError> {
245 let rows = sqlx::query!(
246 r#"SELECT id, user_id, name, public_key, x25519_public_key, created_at, updated_at
247 FROM principals WHERE user_id = $1"#,
248 user_id.0
249 )
250 .fetch_all(&self.pool)
251 .await
252 .map_err(|e| StoreError::Backend(e.to_string()))?;
253
254 Ok(rows
255 .into_iter()
256 .map(|row| Principal {
257 id: PrincipalId(row.id),
258 user_id: row.user_id.map(UserId),
259 name: row.name,
260 public_key: row.public_key,
261 x25519_public_key: row.x25519_public_key,
262 created_at: row.created_at,
263 updated_at: row.updated_at,
264 })
265 .collect())
266 }
267
268 async fn create_invite(&self, params: &CreateInviteParams) -> Result<Invite, StoreError> {
271 let invite_id = Uuid::now_v7();
272 let created_by_user_id = params.created_by_user_id.as_ref().map(|id| id.0);
273
274 let row = sqlx::query!(
275 r#"INSERT INTO invites(id, token, expires_at, created_by_user_id, kek_encrypted, kek_nonce)
276 VALUES($1, $2, $3, $4, $5, $6)
277 RETURNING created_at, updated_at"#,
278 invite_id,
279 params.token,
280 params.expires_at,
281 created_by_user_id,
282 params.kek_encrypted,
283 params.kek_nonce
284 )
285 .fetch_one(&self.pool)
286 .await
287 .map_err(|e| StoreError::Backend(e.to_string()))?;
288
289 for ws_id in ¶ms.workspace_ids {
290 sqlx::query!(
291 "INSERT INTO invite_workspaces(invite_id, workspace_id) VALUES($1, $2)",
292 invite_id,
293 ws_id.0
294 )
295 .execute(&self.pool)
296 .await
297 .map_err(|e| StoreError::Backend(e.to_string()))?;
298 }
299
300 Ok(Invite {
301 id: InviteId(invite_id),
302 token: params.token.clone(),
303 workspace_ids: params.workspace_ids.clone(),
304 kek_encrypted: params.kek_encrypted.clone(),
305 kek_nonce: params.kek_nonce.clone(),
306 created_at: row.created_at,
307 updated_at: row.updated_at,
308 expires_at: params.expires_at,
309 created_by_user_id: params.created_by_user_id.clone(),
310 })
311 }
312
313 async fn get_invite_by_token(&self, token: &str) -> Result<Invite, StoreError> {
314 let row = sqlx::query!(
315 r#"SELECT id, token, created_at, updated_at, expires_at, created_by_user_id, revoked, kek_encrypted, kek_nonce
316 FROM invites WHERE token = $1"#,
317 token
318 )
319 .fetch_optional(&self.pool)
320 .await
321 .map_err(|e| StoreError::Backend(e.to_string()))?
322 .ok_or(StoreError::NotFound)?;
323
324 if row.revoked {
325 return Err(StoreError::NotFound);
326 }
327
328 let ws_rows = sqlx::query!(
329 "SELECT workspace_id FROM invite_workspaces WHERE invite_id = $1",
330 row.id
331 )
332 .fetch_all(&self.pool)
333 .await
334 .map_err(|e| StoreError::Backend(e.to_string()))?;
335
336 let workspace_ids = ws_rows
337 .into_iter()
338 .map(|r| WorkspaceId(r.workspace_id))
339 .collect();
340
341 Ok(Invite {
342 id: InviteId(row.id),
343 token: row.token,
344 workspace_ids,
345 kek_encrypted: row.kek_encrypted,
346 kek_nonce: row.kek_nonce,
347 created_at: row.created_at,
348 updated_at: row.updated_at,
349 expires_at: row.expires_at,
350 created_by_user_id: row.created_by_user_id.map(UserId),
351 })
352 }
353
354 async fn list_invites(&self, user_id: Option<&UserId>) -> Result<Vec<Invite>, StoreError> {
355 let user_id_opt = user_id.map(|id| id.0);
356
357 let rows = sqlx::query!(
358 r#"SELECT id, token, created_at, updated_at, expires_at, created_by_user_id, revoked, kek_encrypted, kek_nonce
359 FROM invites
360 WHERE revoked = FALSE AND (
361 ($1::UUID IS NOT NULL AND created_by_user_id = $1) OR
362 ($1::UUID IS NULL AND created_by_user_id IS NULL)
363 )"#,
364 user_id_opt
365 )
366 .fetch_all(&self.pool)
367 .await
368 .map_err(|e| StoreError::Backend(e.to_string()))?;
369
370 let mut invites = Vec::new();
371 for row in rows {
372 let ws_rows = sqlx::query!(
373 "SELECT workspace_id FROM invite_workspaces WHERE invite_id = $1",
374 row.id
375 )
376 .fetch_all(&self.pool)
377 .await
378 .map_err(|e| StoreError::Backend(e.to_string()))?;
379
380 let workspace_ids = ws_rows
381 .into_iter()
382 .map(|r| WorkspaceId(r.workspace_id))
383 .collect();
384
385 invites.push(Invite {
386 id: InviteId(row.id),
387 token: row.token,
388 workspace_ids,
389 kek_encrypted: row.kek_encrypted,
390 kek_nonce: row.kek_nonce,
391 created_at: row.created_at,
392 updated_at: row.updated_at,
393 expires_at: row.expires_at,
394 created_by_user_id: row.created_by_user_id.map(UserId),
395 });
396 }
397 Ok(invites)
398 }
399
400 async fn revoke_invite(&self, invite_id: &InviteId) -> Result<(), StoreError> {
401 let result = sqlx::query!(
402 "UPDATE invites SET revoked = TRUE WHERE id = $1",
403 invite_id.0
404 )
405 .execute(&self.pool)
406 .await
407 .map_err(|e| StoreError::Backend(e.to_string()))?;
408
409 if result.rows_affected() == 0 {
410 Err(StoreError::NotFound)
411 } else {
412 Ok(())
413 }
414 }
415
416 async fn create_workspace(
419 &self,
420 params: &CreateWorkspaceParams,
421 ) -> Result<WorkspaceId, StoreError> {
422 let m_cost = params.m_cost_kib as i32;
423 let t_cost = params.t_cost as i32;
424 let p_cost = params.p_cost as i32;
425
426 sqlx::query!(
427 "INSERT INTO workspaces(id, name, owner_user_id, kdf_salt, kdf_m_cost_kib, kdf_t_cost, kdf_p_cost)
428 VALUES($1, $2, $3, $4, $5, $6, $7)",
429 params.id.0,
430 params.name,
431 params.owner_user_id.0,
432 params.kdf_salt,
433 m_cost,
434 t_cost,
435 p_cost
436 )
437 .execute(&self.pool)
438 .await
439 .map_err(|e| StoreError::Backend(e.to_string()))?;
440 Ok(params.id.clone())
441 }
442
443 async fn list_workspaces(&self, user_id: &UserId) -> Result<Vec<Workspace>, StoreError> {
444 let rows = sqlx::query!(
445 r#"SELECT w.id, w.name, w.owner_user_id, w.kdf_salt, w.kdf_m_cost_kib, w.kdf_t_cost, w.kdf_p_cost,
446 w.created_at, w.updated_at
447 FROM workspaces w
448 JOIN workspace_members wm ON w.id = wm.workspace_id
449 WHERE wm.user_id = $1"#,
450 user_id.0
451 )
452 .fetch_all(&self.pool)
453 .await
454 .map_err(|e| StoreError::Backend(e.to_string()))?;
455
456 Ok(rows
457 .into_iter()
458 .map(|row| Workspace {
459 id: WorkspaceId(row.id),
460 name: row.name,
461 owner_user_id: UserId(row.owner_user_id),
462 kdf_salt: row.kdf_salt,
463 m_cost_kib: row.kdf_m_cost_kib as u32,
464 t_cost: row.kdf_t_cost as u32,
465 p_cost: row.kdf_p_cost as u32,
466 created_at: row.created_at,
467 updated_at: row.updated_at,
468 })
469 .collect())
470 }
471
472 async fn get_workspace(&self, ws: &WorkspaceId) -> Result<Workspace, StoreError> {
473 let row = sqlx::query!(
474 r#"SELECT id, name, owner_user_id, kdf_salt, kdf_m_cost_kib, kdf_t_cost, kdf_p_cost,
475 created_at, updated_at
476 FROM workspaces WHERE id = $1"#,
477 ws.0
478 )
479 .fetch_optional(&self.pool)
480 .await
481 .map_err(|e| StoreError::Backend(e.to_string()))?
482 .ok_or(StoreError::NotFound)?;
483
484 Ok(Workspace {
485 id: WorkspaceId(row.id),
486 name: row.name,
487 owner_user_id: UserId(row.owner_user_id),
488 kdf_salt: row.kdf_salt,
489 m_cost_kib: row.kdf_m_cost_kib as u32,
490 t_cost: row.kdf_t_cost as u32,
491 p_cost: row.kdf_p_cost as u32,
492 created_at: row.created_at,
493 updated_at: row.updated_at,
494 })
495 }
496
497 async fn get_workspace_by_name(
498 &self,
499 user_id: &UserId,
500 name: &str,
501 ) -> Result<Workspace, StoreError> {
502 let row = sqlx::query!(
503 r#"SELECT w.id, w.name, w.owner_user_id, w.kdf_salt, w.kdf_m_cost_kib, w.kdf_t_cost, w.kdf_p_cost,
504 w.created_at, w.updated_at
505 FROM workspaces w
506 LEFT JOIN workspace_members wm ON w.id = wm.workspace_id
507 WHERE w.name = $1 AND (w.owner_user_id = $2 OR wm.user_id = $2)
508 LIMIT 1"#,
509 name,
510 user_id.0
511 )
512 .fetch_optional(&self.pool)
513 .await
514 .map_err(|e| StoreError::Backend(e.to_string()))?
515 .ok_or(StoreError::NotFound)?;
516
517 Ok(Workspace {
518 id: WorkspaceId(row.id),
519 name: row.name,
520 owner_user_id: UserId(row.owner_user_id),
521 kdf_salt: row.kdf_salt,
522 m_cost_kib: row.kdf_m_cost_kib as u32,
523 t_cost: row.kdf_t_cost as u32,
524 p_cost: row.kdf_p_cost as u32,
525 created_at: row.created_at,
526 updated_at: row.updated_at,
527 })
528 }
529
530 async fn get_workspace_by_name_for_principal(
531 &self,
532 principal_id: &PrincipalId,
533 name: &str,
534 ) -> Result<Workspace, StoreError> {
535 let row = sqlx::query!(
536 r#"SELECT w.id, w.name, w.owner_user_id, w.kdf_salt, w.kdf_m_cost_kib, w.kdf_t_cost, w.kdf_p_cost,
537 w.created_at, w.updated_at
538 FROM workspaces w
539 INNER JOIN workspace_principals wp ON w.id = wp.workspace_id
540 WHERE w.name = $1 AND wp.principal_id = $2
541 LIMIT 1"#,
542 name,
543 principal_id.0
544 )
545 .fetch_optional(&self.pool)
546 .await
547 .map_err(|e| StoreError::Backend(e.to_string()))?
548 .ok_or(StoreError::NotFound)?;
549
550 Ok(Workspace {
551 id: WorkspaceId(row.id),
552 name: row.name,
553 owner_user_id: UserId(row.owner_user_id),
554 kdf_salt: row.kdf_salt,
555 m_cost_kib: row.kdf_m_cost_kib as u32,
556 t_cost: row.kdf_t_cost as u32,
557 p_cost: row.kdf_p_cost as u32,
558 created_at: row.created_at,
559 updated_at: row.updated_at,
560 })
561 }
562
563 async fn add_workspace_principal(
564 &self,
565 params: &AddWorkspacePrincipalParams,
566 ) -> Result<(), StoreError> {
567 sqlx::query!(
568 "INSERT INTO workspace_principals(workspace_id, principal_id, ephemeral_pub, kek_wrapped, kek_nonce) VALUES($1, $2, $3, $4, $5)",
569 params.workspace_id.0,
570 params.principal_id.0,
571 params.ephemeral_pub,
572 params.kek_wrapped,
573 params.kek_nonce
574 )
575 .execute(&self.pool)
576 .await
577 .map_err(|e| {
578 let s = e.to_string();
579 if s.contains("duplicate key") || s.contains("unique constraint") {
580 StoreError::AlreadyExists
581 } else {
582 StoreError::Backend(s)
583 }
584 })?;
585 Ok(())
586 }
587
588 async fn get_workspace_principal(
589 &self,
590 workspace_id: &WorkspaceId,
591 principal_id: &PrincipalId,
592 ) -> Result<WorkspacePrincipal, StoreError> {
593 let row = sqlx::query!(
594 r#"SELECT workspace_id, principal_id, ephemeral_pub, kek_wrapped, kek_nonce, created_at
595 FROM workspace_principals WHERE workspace_id = $1 AND principal_id = $2"#,
596 workspace_id.0,
597 principal_id.0
598 )
599 .fetch_optional(&self.pool)
600 .await
601 .map_err(|e| StoreError::Backend(e.to_string()))?
602 .ok_or(StoreError::NotFound)?;
603
604 Ok(WorkspacePrincipal {
605 workspace_id: WorkspaceId(row.workspace_id),
606 principal_id: PrincipalId(row.principal_id),
607 ephemeral_pub: row.ephemeral_pub,
608 kek_wrapped: row.kek_wrapped,
609 kek_nonce: row.kek_nonce,
610 created_at: row.created_at,
611 })
612 }
613
614 async fn list_workspace_principals(
615 &self,
616 workspace_id: &WorkspaceId,
617 ) -> Result<Vec<WorkspacePrincipal>, StoreError> {
618 let rows = sqlx::query!(
619 r#"SELECT workspace_id, principal_id, ephemeral_pub, kek_wrapped, kek_nonce, created_at
620 FROM workspace_principals WHERE workspace_id = $1"#,
621 workspace_id.0
622 )
623 .fetch_all(&self.pool)
624 .await
625 .map_err(|e| StoreError::Backend(e.to_string()))?;
626
627 Ok(rows
628 .into_iter()
629 .map(|row| WorkspacePrincipal {
630 workspace_id: WorkspaceId(row.workspace_id),
631 principal_id: PrincipalId(row.principal_id),
632 ephemeral_pub: row.ephemeral_pub,
633 kek_wrapped: row.kek_wrapped,
634 kek_nonce: row.kek_nonce,
635 created_at: row.created_at,
636 })
637 .collect())
638 }
639
640 async fn add_user_to_workspace(
641 &self,
642 workspace_id: &WorkspaceId,
643 user_id: &UserId,
644 ) -> Result<(), StoreError> {
645 sqlx::query!(
646 "INSERT INTO workspace_members(workspace_id, user_id) VALUES($1, $2)",
647 workspace_id.0,
648 user_id.0
649 )
650 .execute(&self.pool)
651 .await
652 .map_err(|e| {
653 let s = e.to_string();
654 if s.contains("duplicate key") || s.contains("unique constraint") {
655 StoreError::AlreadyExists
656 } else {
657 StoreError::Backend(s)
658 }
659 })?;
660 Ok(())
661 }
662
663 async fn create_project(
666 &self,
667 params: &CreateProjectParams,
668 ) -> Result<zopp_storage::ProjectId, StoreError> {
669 let id = Uuid::now_v7();
670
671 sqlx::query!(
672 "INSERT INTO projects(id, workspace_id, name) VALUES($1, $2, $3)",
673 id,
674 params.workspace_id.0,
675 params.name
676 )
677 .execute(&self.pool)
678 .await
679 .map_err(|e| {
680 let s = e.to_string();
681 if s.contains("duplicate key") || s.contains("unique constraint") {
682 StoreError::AlreadyExists
683 } else {
684 StoreError::Backend(s)
685 }
686 })?;
687 Ok(zopp_storage::ProjectId(id))
688 }
689
690 async fn list_projects(
691 &self,
692 workspace_id: &WorkspaceId,
693 ) -> Result<Vec<zopp_storage::Project>, StoreError> {
694 let rows = sqlx::query!(
695 r#"SELECT id, workspace_id, name, created_at, updated_at
696 FROM projects WHERE workspace_id = $1 ORDER BY created_at DESC"#,
697 workspace_id.0
698 )
699 .fetch_all(&self.pool)
700 .await
701 .map_err(|e| StoreError::Backend(e.to_string()))?;
702
703 Ok(rows
704 .into_iter()
705 .map(|row| zopp_storage::Project {
706 id: zopp_storage::ProjectId(row.id),
707 workspace_id: WorkspaceId(row.workspace_id),
708 name: row.name,
709 created_at: row.created_at,
710 updated_at: row.updated_at,
711 })
712 .collect())
713 }
714
715 async fn get_project(
716 &self,
717 project_id: &zopp_storage::ProjectId,
718 ) -> Result<zopp_storage::Project, StoreError> {
719 let row = sqlx::query!(
720 r#"SELECT id, workspace_id, name, created_at, updated_at
721 FROM projects WHERE id = $1"#,
722 project_id.0
723 )
724 .fetch_optional(&self.pool)
725 .await
726 .map_err(|e| StoreError::Backend(e.to_string()))?
727 .ok_or(StoreError::NotFound)?;
728
729 Ok(zopp_storage::Project {
730 id: zopp_storage::ProjectId(row.id),
731 workspace_id: WorkspaceId(row.workspace_id),
732 name: row.name,
733 created_at: row.created_at,
734 updated_at: row.updated_at,
735 })
736 }
737
738 async fn get_project_by_name(
739 &self,
740 workspace_id: &WorkspaceId,
741 name: &str,
742 ) -> Result<zopp_storage::Project, StoreError> {
743 let row = sqlx::query!(
744 r#"SELECT id, workspace_id, name, created_at, updated_at
745 FROM projects WHERE workspace_id = $1 AND name = $2"#,
746 workspace_id.0,
747 name
748 )
749 .fetch_optional(&self.pool)
750 .await
751 .map_err(|e| StoreError::Backend(e.to_string()))?
752 .ok_or(StoreError::NotFound)?;
753
754 Ok(zopp_storage::Project {
755 id: zopp_storage::ProjectId(row.id),
756 workspace_id: WorkspaceId(row.workspace_id),
757 name: row.name,
758 created_at: row.created_at,
759 updated_at: row.updated_at,
760 })
761 }
762
763 async fn delete_project(&self, project_id: &zopp_storage::ProjectId) -> Result<(), StoreError> {
764 let result = sqlx::query!("DELETE FROM projects WHERE id = $1", project_id.0)
765 .execute(&self.pool)
766 .await
767 .map_err(|e| StoreError::Backend(e.to_string()))?;
768
769 if result.rows_affected() == 0 {
770 return Err(StoreError::NotFound);
771 }
772
773 Ok(())
774 }
775
776 async fn create_env(&self, params: &CreateEnvParams) -> Result<EnvironmentId, StoreError> {
779 let env_id = Uuid::now_v7();
780
781 let proj_row = sqlx::query!(
782 "SELECT workspace_id FROM projects WHERE id = $1",
783 params.project_id.0
784 )
785 .fetch_optional(&self.pool)
786 .await
787 .map_err(|e| StoreError::Backend(e.to_string()))?
788 .ok_or(StoreError::NotFound)?;
789
790 sqlx::query!(
791 "INSERT INTO environments(id, workspace_id, project_id, name, dek_wrapped, dek_nonce)
792 VALUES($1, $2, $3, $4, $5, $6)",
793 env_id,
794 proj_row.workspace_id,
795 params.project_id.0,
796 params.name,
797 params.dek_wrapped,
798 params.dek_nonce
799 )
800 .execute(&self.pool)
801 .await
802 .map_err(|e| {
803 let s = e.to_string();
804 if s.contains("duplicate key") || s.contains("unique constraint") {
805 StoreError::AlreadyExists
806 } else {
807 StoreError::Backend(s)
808 }
809 })?;
810
811 Ok(EnvironmentId(env_id))
812 }
813
814 async fn list_environments(
815 &self,
816 project_id: &zopp_storage::ProjectId,
817 ) -> Result<Vec<Environment>, StoreError> {
818 let rows = sqlx::query!(
819 r#"SELECT id, project_id, name, dek_wrapped, dek_nonce, version, created_at, updated_at
820 FROM environments WHERE project_id = $1 ORDER BY created_at DESC"#,
821 project_id.0
822 )
823 .fetch_all(&self.pool)
824 .await
825 .map_err(|e| StoreError::Backend(e.to_string()))?;
826
827 Ok(rows
828 .into_iter()
829 .map(|row| Environment {
830 id: EnvironmentId(row.id),
831 project_id: zopp_storage::ProjectId(row.project_id),
832 name: row.name,
833 dek_wrapped: row.dek_wrapped,
834 dek_nonce: row.dek_nonce,
835 version: row.version,
836 created_at: row.created_at,
837 updated_at: row.updated_at,
838 })
839 .collect())
840 }
841
842 async fn get_environment(&self, env_id: &EnvironmentId) -> Result<Environment, StoreError> {
843 let row = sqlx::query!(
844 r#"SELECT id, project_id, name, dek_wrapped, dek_nonce, version, created_at, updated_at
845 FROM environments WHERE id = $1"#,
846 env_id.0
847 )
848 .fetch_optional(&self.pool)
849 .await
850 .map_err(|e| StoreError::Backend(e.to_string()))?
851 .ok_or(StoreError::NotFound)?;
852
853 Ok(Environment {
854 id: EnvironmentId(row.id),
855 project_id: zopp_storage::ProjectId(row.project_id),
856 name: row.name,
857 dek_wrapped: row.dek_wrapped,
858 dek_nonce: row.dek_nonce,
859 version: row.version,
860 created_at: row.created_at,
861 updated_at: row.updated_at,
862 })
863 }
864
865 async fn get_environment_by_name(
866 &self,
867 project_id: &zopp_storage::ProjectId,
868 name: &str,
869 ) -> Result<Environment, StoreError> {
870 let row = sqlx::query!(
871 r#"SELECT id, project_id, name, dek_wrapped, dek_nonce, version, created_at, updated_at
872 FROM environments WHERE project_id = $1 AND name = $2"#,
873 project_id.0,
874 name
875 )
876 .fetch_optional(&self.pool)
877 .await
878 .map_err(|e| StoreError::Backend(e.to_string()))?
879 .ok_or(StoreError::NotFound)?;
880
881 Ok(Environment {
882 id: EnvironmentId(row.id),
883 project_id: zopp_storage::ProjectId(row.project_id),
884 name: row.name,
885 dek_wrapped: row.dek_wrapped,
886 dek_nonce: row.dek_nonce,
887 version: row.version,
888 created_at: row.created_at,
889 updated_at: row.updated_at,
890 })
891 }
892
893 async fn delete_environment(&self, env_id: &EnvironmentId) -> Result<(), StoreError> {
894 let result = sqlx::query!("DELETE FROM environments WHERE id = $1", env_id.0)
895 .execute(&self.pool)
896 .await
897 .map_err(|e| StoreError::Backend(e.to_string()))?;
898
899 if result.rows_affected() == 0 {
900 return Err(StoreError::NotFound);
901 }
902
903 Ok(())
904 }
905
906 async fn get_env_wrap(
907 &self,
908 ws: &WorkspaceId,
909 project: &ProjectName,
910 env: &EnvName,
911 ) -> Result<(Vec<u8>, Vec<u8>), StoreError> {
912 let row = sqlx::query!(
913 "SELECT e.dek_wrapped, e.dek_nonce
914 FROM environments e
915 JOIN projects p ON p.id = e.project_id
916 WHERE e.workspace_id = $1 AND p.workspace_id = $1 AND p.name = $2 AND e.name = $3",
917 ws.0,
918 project.0,
919 env.0
920 )
921 .fetch_optional(&self.pool)
922 .await
923 .map_err(|e| StoreError::Backend(e.to_string()))?
924 .ok_or(StoreError::NotFound)?;
925
926 Ok((row.dek_wrapped, row.dek_nonce))
927 }
928
929 async fn upsert_secret(
932 &self,
933 env_id: &EnvironmentId,
934 key: &str,
935 nonce: &[u8],
936 ciphertext: &[u8],
937 ) -> Result<i64, StoreError> {
938 let mut tx = self
939 .pool
940 .begin()
941 .await
942 .map_err(|e| StoreError::Backend(e.to_string()))?;
943
944 let env_row = sqlx::query!(
945 "SELECT workspace_id FROM environments WHERE id = $1",
946 env_id.0
947 )
948 .fetch_optional(&mut *tx)
949 .await
950 .map_err(|e| StoreError::Backend(e.to_string()))?
951 .ok_or(StoreError::NotFound)?;
952
953 let secret_id = Uuid::now_v7();
954
955 sqlx::query!(
956 "INSERT INTO secrets(id, workspace_id, env_id, key_name, nonce, ciphertext)
957 VALUES($1, $2, $3, $4, $5, $6)
958 ON CONFLICT(workspace_id, env_id, key_name)
959 DO UPDATE SET nonce = EXCLUDED.nonce, ciphertext = EXCLUDED.ciphertext",
960 secret_id,
961 env_row.workspace_id,
962 env_id.0,
963 key,
964 nonce,
965 ciphertext
966 )
967 .execute(&mut *tx)
968 .await
969 .map_err(|e| StoreError::Backend(e.to_string()))?;
970
971 let result = sqlx::query!(
972 "UPDATE environments SET version = version + 1 WHERE id = $1 RETURNING version",
973 env_id.0
974 )
975 .fetch_one(&mut *tx)
976 .await
977 .map_err(|e| StoreError::Backend(e.to_string()))?;
978
979 tx.commit()
980 .await
981 .map_err(|e| StoreError::Backend(e.to_string()))?;
982
983 Ok(result.version)
984 }
985
986 async fn get_secret(&self, env_id: &EnvironmentId, key: &str) -> Result<SecretRow, StoreError> {
987 let row = sqlx::query!(
988 "SELECT nonce, ciphertext FROM secrets WHERE env_id = $1 AND key_name = $2",
989 env_id.0,
990 key
991 )
992 .fetch_optional(&self.pool)
993 .await
994 .map_err(|e| StoreError::Backend(e.to_string()))?
995 .ok_or(StoreError::NotFound)?;
996
997 Ok(SecretRow {
998 nonce: row.nonce,
999 ciphertext: row.ciphertext,
1000 })
1001 }
1002
1003 async fn list_secret_keys(&self, env_id: &EnvironmentId) -> Result<Vec<String>, StoreError> {
1004 let rows = sqlx::query!(
1005 "SELECT key_name FROM secrets WHERE env_id = $1 ORDER BY key_name",
1006 env_id.0
1007 )
1008 .fetch_all(&self.pool)
1009 .await
1010 .map_err(|e| StoreError::Backend(e.to_string()))?;
1011
1012 Ok(rows.into_iter().map(|row| row.key_name).collect())
1013 }
1014
1015 async fn delete_secret(&self, env_id: &EnvironmentId, key: &str) -> Result<i64, StoreError> {
1016 let mut tx = self
1017 .pool
1018 .begin()
1019 .await
1020 .map_err(|e| StoreError::Backend(e.to_string()))?;
1021
1022 let result = sqlx::query!(
1023 "DELETE FROM secrets WHERE env_id = $1 AND key_name = $2",
1024 env_id.0,
1025 key
1026 )
1027 .execute(&mut *tx)
1028 .await
1029 .map_err(|e| StoreError::Backend(e.to_string()))?;
1030
1031 if result.rows_affected() == 0 {
1032 return Err(StoreError::NotFound);
1033 }
1034
1035 let version_result = sqlx::query!(
1036 "UPDATE environments SET version = version + 1 WHERE id = $1 RETURNING version",
1037 env_id.0
1038 )
1039 .fetch_one(&mut *tx)
1040 .await
1041 .map_err(|e| StoreError::Backend(e.to_string()))?;
1042
1043 tx.commit()
1044 .await
1045 .map_err(|e| StoreError::Backend(e.to_string()))?;
1046
1047 Ok(version_result.version)
1048 }
1049}
1050
1051#[cfg(test)]
1052mod tests;