zlayer_types/storage.rs
1//! Storage `Stored*` wire types.
2//!
3//! These are the serde-friendly DTOs persisted by the daemon's `SqlxStorage`
4//! backends and surfaced over the REST API. They live here (not in
5//! `zlayer-api`) so SDK consumers can deserialize them without pulling in
6//! axum/sqlx/tokio.
7//!
8//! Convenience constructors that allocate fresh UUIDs, plus the
9//! database-bound traits and concrete sqlx implementations, remain in
10//! `zlayer-api::storage` — that's where the `uuid` dependency lives. This
11//! crate only carries the wire shapes (structs, enums, and pure-data
12//! `Display` impls).
13
14use std::collections::HashMap;
15
16use chrono::{DateTime, Utc};
17use serde::{Deserialize, Serialize};
18use uuid::Uuid;
19
20use crate::spec::DeploymentSpec;
21
22// =========================================================================
23// Deployments
24// =========================================================================
25
26/// A stored deployment with metadata.
27#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
28pub struct StoredDeployment {
29 /// Deployment name (unique identifier)
30 pub name: String,
31
32 /// The deployment specification (complex nested structure, see spec docs)
33 #[schema(value_type = Object)]
34 pub spec: DeploymentSpec,
35
36 /// Current deployment status
37 pub status: DeploymentStatus,
38
39 /// When the deployment was created
40 #[schema(value_type = String, example = "2025-01-27T12:00:00Z")]
41 pub created_at: DateTime<Utc>,
42
43 /// When the deployment was last updated
44 #[schema(value_type = String, example = "2025-01-27T12:00:00Z")]
45 pub updated_at: DateTime<Utc>,
46}
47
48impl StoredDeployment {
49 /// Create a new stored deployment from a spec.
50 #[must_use]
51 pub fn new(spec: DeploymentSpec) -> Self {
52 let now = Utc::now();
53 Self {
54 name: spec.deployment.clone(),
55 spec,
56 status: DeploymentStatus::Pending,
57 created_at: now,
58 updated_at: now,
59 }
60 }
61
62 /// Update the deployment spec and timestamp.
63 pub fn update_spec(&mut self, spec: DeploymentSpec) {
64 self.spec = spec;
65 self.updated_at = Utc::now();
66 }
67
68 /// Update the deployment status and timestamp.
69 pub fn update_status(&mut self, status: DeploymentStatus) {
70 self.status = status;
71 self.updated_at = Utc::now();
72 }
73}
74
75/// Deployment lifecycle status.
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
77#[serde(tag = "state", rename_all = "snake_case")]
78pub enum DeploymentStatus {
79 /// Deployment created but not yet started
80 Pending,
81
82 /// Deployment is being rolled out
83 Deploying,
84
85 /// All services are running
86 Running,
87
88 /// Deployment failed with an error message
89 Failed {
90 /// Error message describing the failure
91 message: String,
92 },
93
94 /// Deployment has been stopped
95 Stopped,
96}
97
98impl std::fmt::Display for DeploymentStatus {
99 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100 match self {
101 DeploymentStatus::Pending => write!(f, "pending"),
102 DeploymentStatus::Deploying => write!(f, "deploying"),
103 DeploymentStatus::Running => write!(f, "running"),
104 DeploymentStatus::Failed { message } => write!(f, "failed: {message}"),
105 DeploymentStatus::Stopped => write!(f, "stopped"),
106 }
107 }
108}
109
110// =========================================================================
111// Users
112// =========================================================================
113
114/// A stored user account.
115///
116/// The password hash lives in `zlayer-secrets::CredentialStore` keyed by the
117/// email address — NOT in this record. This type only carries user metadata.
118#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
119pub struct StoredUser {
120 /// Opaque user ID (`UUIDv4` string).
121 pub id: String,
122
123 /// Primary login identifier. Stored lower-cased.
124 pub email: String,
125
126 /// Human-readable display name.
127 pub display_name: String,
128
129 /// Role — "admin" or "user".
130 pub role: UserRole,
131
132 /// Whether the user can log in.
133 pub is_active: bool,
134
135 /// When the user was created.
136 #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
137 pub created_at: DateTime<Utc>,
138
139 /// When the user was last updated.
140 #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
141 pub updated_at: DateTime<Utc>,
142
143 /// When the user last logged in (if ever).
144 #[schema(value_type = Option<String>, example = "2026-04-15T12:00:00Z")]
145 pub last_login_at: Option<DateTime<Utc>>,
146}
147
148impl StoredUser {
149 /// Create a new user record with a fresh UUID and `is_active = true`.
150 #[must_use]
151 pub fn new(email: impl Into<String>, display_name: impl Into<String>, role: UserRole) -> Self {
152 let now = Utc::now();
153 Self {
154 id: Uuid::new_v4().to_string(),
155 email: email.into().to_lowercase(),
156 display_name: display_name.into(),
157 role,
158 is_active: true,
159 created_at: now,
160 updated_at: now,
161 last_login_at: None,
162 }
163 }
164
165 /// Record a successful login, advancing `last_login_at` and `updated_at`.
166 pub fn touch_login(&mut self) {
167 let now = Utc::now();
168 self.last_login_at = Some(now);
169 self.updated_at = now;
170 }
171}
172
173/// User role. Admins can do everything; regular users are constrained by
174/// per-resource permissions (added in a later phase).
175#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
176#[serde(rename_all = "snake_case")]
177pub enum UserRole {
178 /// Full administrative access.
179 Admin,
180 /// Standard user constrained by per-resource permissions.
181 User,
182}
183
184impl UserRole {
185 /// Stable string form of this role.
186 #[must_use]
187 pub fn as_str(self) -> &'static str {
188 match self {
189 UserRole::Admin => "admin",
190 UserRole::User => "user",
191 }
192 }
193}
194
195impl std::fmt::Display for UserRole {
196 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197 f.write_str(self.as_str())
198 }
199}
200
201// =========================================================================
202// Environments
203// =========================================================================
204
205/// A deployment/runtime environment (e.g. "dev", "staging", "prod").
206///
207/// Each environment is an isolated namespace for secrets and, later,
208/// deployments. Optionally belongs to a `Project` (added in Phase 5) — when
209/// `project_id` is `None`, the environment is global.
210#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
211pub struct StoredEnvironment {
212 /// UUID identifier.
213 pub id: String,
214
215 /// Display name (e.g. "dev"). Unique within a given `project_id`.
216 pub name: String,
217
218 /// Project id this environment belongs to. `None` = global.
219 pub project_id: Option<String>,
220
221 /// Free-form description shown in the UI.
222 pub description: Option<String>,
223
224 /// When the environment was created.
225 #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
226 pub created_at: DateTime<Utc>,
227
228 /// When the environment was last updated.
229 #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
230 pub updated_at: DateTime<Utc>,
231}
232
233impl StoredEnvironment {
234 /// Create a new environment record with a fresh UUID.
235 #[must_use]
236 pub fn new(name: impl Into<String>, project_id: Option<String>) -> Self {
237 let now = Utc::now();
238 Self {
239 id: Uuid::new_v4().to_string(),
240 name: name.into(),
241 project_id,
242 description: None,
243 created_at: now,
244 updated_at: now,
245 }
246 }
247}
248
249// =========================================================================
250// Projects
251// =========================================================================
252
253/// A project bundles a git source, build configuration, registry credential
254/// reference, linked deployments, and a default environment.
255#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
256pub struct StoredProject {
257 /// UUID identifier.
258 pub id: String,
259
260 /// Project name (globally unique).
261 pub name: String,
262
263 /// Free-form description shown in the UI.
264 pub description: Option<String>,
265
266 /// Git repository URL (e.g. `"https://github.com/user/repo"`).
267 pub git_url: Option<String>,
268
269 /// Git branch to build from (default: `"main"`).
270 pub git_branch: Option<String>,
271
272 /// Reference to a `GitCredential` (Phase 5.2).
273 pub git_credential_id: Option<String>,
274
275 /// How the project is built.
276 pub build_kind: Option<BuildKind>,
277
278 /// Relative path within the repo (e.g. `"./Dockerfile"`).
279 pub build_path: Option<String>,
280
281 /// Relative path (inside the cloned repo) to a `DeploymentSpec` YAML that
282 /// the workflow `DeployProject` action should apply.
283 ///
284 /// When `None`, the workflow `DeployProject` action fails with a clear
285 /// "no deploy spec configured" error rather than silently succeeding —
286 /// callers are expected to set this explicitly via `project edit`.
287 #[serde(default)]
288 pub deploy_spec_path: Option<String>,
289
290 /// Reference to a `RegistryCredential` (Phase 5.2).
291 pub registry_credential_id: Option<String>,
292
293 /// Reference to the default environment for this project.
294 pub default_environment_id: Option<String>,
295
296 /// Reference to the owning user.
297 pub owner_id: Option<String>,
298
299 /// Whether new commits on the tracked branch should automatically
300 /// trigger a build + deploy cycle.
301 #[serde(default)]
302 pub auto_deploy: bool,
303
304 /// If set, the daemon polls the remote for new commits every N seconds.
305 /// `None` disables polling (the project is only updated via manual pull
306 /// or webhook).
307 #[serde(default)]
308 pub poll_interval_secs: Option<u64>,
309
310 /// When the project was created.
311 #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
312 pub created_at: DateTime<Utc>,
313
314 /// When the project was last updated.
315 #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
316 pub updated_at: DateTime<Utc>,
317}
318
319impl StoredProject {
320 /// Create a new project record with a fresh UUID.
321 #[must_use]
322 pub fn new(name: impl Into<String>) -> Self {
323 let now = Utc::now();
324 Self {
325 id: Uuid::new_v4().to_string(),
326 name: name.into(),
327 description: None,
328 git_url: None,
329 git_branch: Some("main".to_string()),
330 git_credential_id: None,
331 build_kind: None,
332 build_path: None,
333 deploy_spec_path: None,
334 registry_credential_id: None,
335 default_environment_id: None,
336 owner_id: None,
337 auto_deploy: false,
338 poll_interval_secs: None,
339 created_at: now,
340 updated_at: now,
341 }
342 }
343}
344
345/// How a project is built.
346#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
347#[serde(rename_all = "snake_case")]
348pub enum BuildKind {
349 /// Standard Dockerfile build.
350 Dockerfile,
351 /// Docker Compose / Compose file.
352 Compose,
353 /// `ZLayer`-native `ZImagefile`.
354 ZImagefile,
355 /// `ZLayer` deployment spec.
356 Spec,
357}
358
359impl std::fmt::Display for BuildKind {
360 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
361 match self {
362 BuildKind::Dockerfile => f.write_str("dockerfile"),
363 BuildKind::Compose => f.write_str("compose"),
364 BuildKind::ZImagefile => f.write_str("zimagefile"),
365 BuildKind::Spec => f.write_str("spec"),
366 }
367 }
368}
369
370// =========================================================================
371// Variables
372// =========================================================================
373
374/// A stored variable — a plaintext key-value pair for template substitution
375/// in deployment specs. Variables are NOT encrypted (unlike secrets). They
376/// live in their own storage, separate from the encrypted secrets store.
377///
378/// Variables can be global (`scope = None`) or project-scoped
379/// (`scope = Some(project_id)`).
380#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
381pub struct StoredVariable {
382 /// UUID identifier.
383 pub id: String,
384
385 /// Variable name (e.g. `"APP_VERSION"`, `"LOG_LEVEL"`). Unique within a
386 /// given scope.
387 pub name: String,
388
389 /// Plaintext variable value.
390 pub value: String,
391
392 /// Scope: project id or `None` for global.
393 pub scope: Option<String>,
394
395 /// When the variable was created.
396 #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
397 pub created_at: DateTime<Utc>,
398
399 /// When the variable was last updated.
400 #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
401 pub updated_at: DateTime<Utc>,
402}
403
404impl StoredVariable {
405 /// Create a new variable record with a fresh UUID.
406 #[must_use]
407 pub fn new(name: impl Into<String>, value: impl Into<String>, scope: Option<String>) -> Self {
408 let now = Utc::now();
409 Self {
410 id: Uuid::new_v4().to_string(),
411 name: name.into(),
412 value: value.into(),
413 scope,
414 created_at: now,
415 updated_at: now,
416 }
417 }
418}
419
420// =========================================================================
421// Syncs
422// =========================================================================
423
424/// A stored sync resource (persistent record of a git-backed resource set).
425///
426/// A sync points at a directory within a project's checkout that contains
427/// `ZLayer` resource YAMLs. On diff/apply the directory is scanned, compared
428/// against current API state, and reconciled.
429#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
430pub struct StoredSync {
431 /// UUID identifier.
432 pub id: String,
433
434 /// Display name for this sync.
435 pub name: String,
436
437 /// Linked project id (the git checkout to scan).
438 pub project_id: Option<String>,
439
440 /// Path within the project's checkout to scan for resource YAMLs.
441 pub git_path: String,
442
443 /// Whether the sync should automatically apply on pull.
444 #[serde(default)]
445 pub auto_apply: bool,
446
447 /// Whether the sync apply should delete resources that are present on the
448 /// API but missing from the manifest directory. Defaults to `false` —
449 /// the safer behaviour, which skips deletes and only creates/updates.
450 #[serde(default)]
451 pub delete_missing: bool,
452
453 /// The commit SHA at which this sync was last applied.
454 pub last_applied_sha: Option<String>,
455
456 /// When the sync was created.
457 #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
458 pub created_at: DateTime<Utc>,
459
460 /// When the sync was last updated.
461 #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
462 pub updated_at: DateTime<Utc>,
463}
464
465impl StoredSync {
466 /// Create a new sync record with a fresh UUID.
467 #[must_use]
468 pub fn new(name: impl Into<String>, git_path: impl Into<String>) -> Self {
469 let now = Utc::now();
470 Self {
471 id: Uuid::new_v4().to_string(),
472 name: name.into(),
473 project_id: None,
474 git_path: git_path.into(),
475 auto_apply: false,
476 delete_missing: false,
477 last_applied_sha: None,
478 created_at: now,
479 updated_at: now,
480 }
481 }
482}
483
484// =========================================================================
485// Tasks
486// =========================================================================
487
488/// A stored task — a named runnable script that can be executed on demand.
489///
490/// Tasks can be global (`project_id = None`) or project-scoped
491/// (`project_id = Some(project_id)`).
492#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
493pub struct StoredTask {
494 /// UUID identifier.
495 pub id: String,
496
497 /// Task name.
498 pub name: String,
499
500 /// Script type.
501 pub kind: TaskKind,
502
503 /// The script/command body.
504 pub body: String,
505
506 /// Project id this task belongs to. `None` = global.
507 pub project_id: Option<String>,
508
509 /// When the task was created.
510 #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
511 pub created_at: DateTime<Utc>,
512
513 /// When the task was last updated.
514 #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
515 pub updated_at: DateTime<Utc>,
516}
517
518impl StoredTask {
519 /// Create a new task record with a fresh UUID.
520 #[must_use]
521 pub fn new(
522 name: impl Into<String>,
523 kind: TaskKind,
524 body: impl Into<String>,
525 project_id: Option<String>,
526 ) -> Self {
527 let now = Utc::now();
528 Self {
529 id: Uuid::new_v4().to_string(),
530 name: name.into(),
531 kind,
532 body: body.into(),
533 project_id,
534 created_at: now,
535 updated_at: now,
536 }
537 }
538}
539
540/// Script type for a task.
541#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
542#[serde(rename_all = "snake_case")]
543pub enum TaskKind {
544 /// A bash shell script executed via `sh -c`.
545 Bash,
546}
547
548impl std::fmt::Display for TaskKind {
549 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
550 match self {
551 TaskKind::Bash => f.write_str("bash"),
552 }
553 }
554}
555
556/// A recorded execution of a task.
557#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
558pub struct TaskRun {
559 /// UUID identifier of this run.
560 pub id: String,
561
562 /// The task that was executed.
563 pub task_id: String,
564
565 /// Process exit code (`None` if the task could not be started).
566 pub exit_code: Option<i32>,
567
568 /// Captured standard output.
569 pub stdout: String,
570
571 /// Captured standard error.
572 pub stderr: String,
573
574 /// When the run started.
575 #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
576 pub started_at: DateTime<Utc>,
577
578 /// When the run finished (`None` if it has not finished yet).
579 #[schema(value_type = Option<String>, example = "2026-04-15T12:00:01Z")]
580 pub finished_at: Option<DateTime<Utc>>,
581}
582
583// =========================================================================
584// Workflows
585// =========================================================================
586
587/// A stored workflow — a named sequence of steps forming a DAG that
588/// composes tasks, project builds, deploys, and sync applies.
589#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
590pub struct StoredWorkflow {
591 /// UUID identifier.
592 pub id: String,
593
594 /// Workflow name.
595 pub name: String,
596
597 /// Ordered list of steps to execute sequentially.
598 pub steps: Vec<WorkflowStep>,
599
600 /// Optional project scope.
601 pub project_id: Option<String>,
602
603 /// When the workflow was created.
604 #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
605 pub created_at: DateTime<Utc>,
606
607 /// When the workflow was last updated.
608 #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
609 pub updated_at: DateTime<Utc>,
610}
611
612impl StoredWorkflow {
613 /// Create a new workflow record with a fresh UUID.
614 #[must_use]
615 pub fn new(name: impl Into<String>, steps: Vec<WorkflowStep>) -> Self {
616 let now = Utc::now();
617 Self {
618 id: Uuid::new_v4().to_string(),
619 name: name.into(),
620 steps,
621 project_id: None,
622 created_at: now,
623 updated_at: now,
624 }
625 }
626}
627
628/// A single step in a workflow.
629#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
630pub struct WorkflowStep {
631 /// Step name (display label).
632 pub name: String,
633
634 /// The action to perform.
635 pub action: WorkflowAction,
636
637 /// Name of another step (or task id) to run on failure.
638 #[serde(default)]
639 pub on_failure: Option<String>,
640}
641
642/// The action a workflow step performs.
643#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
644#[serde(tag = "type", rename_all = "snake_case")]
645pub enum WorkflowAction {
646 /// Execute a task by id.
647 RunTask {
648 /// The task id to run.
649 task_id: String,
650 },
651 /// Build a project by id.
652 BuildProject {
653 /// The project id to build.
654 project_id: String,
655 },
656 /// Deploy a project by id.
657 DeployProject {
658 /// The project id to deploy.
659 project_id: String,
660 },
661 /// Apply a sync resource by id.
662 ApplySync {
663 /// The sync id to apply.
664 sync_id: String,
665 },
666}
667
668/// A recorded execution of a workflow.
669#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
670pub struct WorkflowRun {
671 /// UUID identifier of this run.
672 pub id: String,
673
674 /// The workflow that was executed.
675 pub workflow_id: String,
676
677 /// Overall run status.
678 pub status: WorkflowRunStatus,
679
680 /// Per-step results.
681 pub step_results: Vec<StepResult>,
682
683 /// When the run started.
684 #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
685 pub started_at: DateTime<Utc>,
686
687 /// When the run finished (`None` if still running).
688 #[schema(value_type = Option<String>, example = "2026-04-15T12:00:01Z")]
689 pub finished_at: Option<DateTime<Utc>>,
690}
691
692/// Overall status of a workflow run.
693#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
694#[serde(rename_all = "snake_case")]
695pub enum WorkflowRunStatus {
696 /// Not yet started.
697 Pending,
698 /// Currently executing.
699 Running,
700 /// All steps completed successfully.
701 Completed,
702 /// A step failed.
703 Failed,
704}
705
706impl std::fmt::Display for WorkflowRunStatus {
707 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
708 match self {
709 WorkflowRunStatus::Pending => f.write_str("pending"),
710 WorkflowRunStatus::Running => f.write_str("running"),
711 WorkflowRunStatus::Completed => f.write_str("completed"),
712 WorkflowRunStatus::Failed => f.write_str("failed"),
713 }
714 }
715}
716
717/// Result of executing a single step in a workflow run.
718#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
719pub struct StepResult {
720 /// The step name.
721 pub step_name: String,
722
723 /// Step outcome: `"ok"`, `"failed"`, or `"skipped"`.
724 pub status: String,
725
726 /// Optional output or error message.
727 pub output: Option<String>,
728}
729
730// =========================================================================
731// Notifiers
732// =========================================================================
733
734/// A stored notifier — a named notification channel that fires alerts to
735/// Slack, Discord, a generic webhook, or SMTP when triggered.
736#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
737pub struct StoredNotifier {
738 /// UUID identifier.
739 pub id: String,
740
741 /// Display name (e.g. `"deploy-alerts"`).
742 pub name: String,
743
744 /// Notification channel type.
745 pub kind: NotifierKind,
746
747 /// Channel-specific configuration (webhook URL, SMTP settings, etc.).
748 pub config: NotifierConfig,
749
750 /// Whether this notifier is active. Disabled notifiers are skipped.
751 pub enabled: bool,
752
753 /// When the notifier was created.
754 #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
755 pub created_at: DateTime<Utc>,
756
757 /// When the notifier was last updated.
758 #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
759 pub updated_at: DateTime<Utc>,
760}
761
762impl StoredNotifier {
763 /// Create a new notifier record with a fresh UUID and `enabled = true`.
764 #[must_use]
765 pub fn new(name: impl Into<String>, kind: NotifierKind, config: NotifierConfig) -> Self {
766 let now = Utc::now();
767 Self {
768 id: Uuid::new_v4().to_string(),
769 name: name.into(),
770 kind,
771 config,
772 enabled: true,
773 created_at: now,
774 updated_at: now,
775 }
776 }
777}
778
779/// Notification channel type.
780#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
781#[serde(rename_all = "snake_case")]
782pub enum NotifierKind {
783 /// Slack incoming webhook.
784 Slack,
785 /// Discord webhook.
786 Discord,
787 /// Generic HTTP webhook.
788 Webhook,
789 /// SMTP email.
790 Smtp,
791}
792
793impl std::fmt::Display for NotifierKind {
794 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
795 match self {
796 NotifierKind::Slack => f.write_str("slack"),
797 NotifierKind::Discord => f.write_str("discord"),
798 NotifierKind::Webhook => f.write_str("webhook"),
799 NotifierKind::Smtp => f.write_str("smtp"),
800 }
801 }
802}
803
804/// Channel-specific configuration for a notifier.
805#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
806#[serde(tag = "type", rename_all = "snake_case")]
807pub enum NotifierConfig {
808 /// Slack incoming webhook configuration.
809 Slack {
810 /// Slack webhook URL.
811 webhook_url: String,
812 },
813 /// Discord webhook configuration.
814 Discord {
815 /// Discord webhook URL.
816 webhook_url: String,
817 },
818 /// Generic HTTP webhook configuration.
819 Webhook {
820 /// Target URL.
821 url: String,
822 /// HTTP method (defaults to `"POST"`).
823 #[serde(default)]
824 method: Option<String>,
825 /// Extra headers to send with the request.
826 #[serde(default)]
827 headers: Option<HashMap<String, String>>,
828 },
829 /// SMTP email configuration.
830 Smtp {
831 /// SMTP server host.
832 host: String,
833 /// SMTP server port.
834 port: u16,
835 /// SMTP username.
836 username: String,
837 /// SMTP password.
838 password: String,
839 /// Sender email address.
840 from: String,
841 /// Recipient email addresses.
842 to: Vec<String>,
843 },
844}
845
846// =========================================================================
847// User groups
848// =========================================================================
849
850/// A stored user group for role-based access control.
851#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
852pub struct StoredUserGroup {
853 /// UUID identifier.
854 pub id: String,
855
856 /// Group name.
857 pub name: String,
858
859 /// Free-form description.
860 pub description: Option<String>,
861
862 /// When the group was created.
863 #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
864 pub created_at: DateTime<Utc>,
865
866 /// When the group was last updated.
867 #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
868 pub updated_at: DateTime<Utc>,
869}
870
871impl StoredUserGroup {
872 /// Create a new user group with a fresh UUID.
873 #[must_use]
874 pub fn new(name: impl Into<String>) -> Self {
875 let now = Utc::now();
876 Self {
877 id: Uuid::new_v4().to_string(),
878 name: name.into(),
879 description: None,
880 created_at: now,
881 updated_at: now,
882 }
883 }
884}
885
886// =========================================================================
887// Permissions
888// =========================================================================
889
890/// Whether a permission subject is a user or a group.
891#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
892#[serde(rename_all = "snake_case")]
893pub enum SubjectKind {
894 /// A single user.
895 User,
896 /// A user group.
897 Group,
898}
899
900impl std::fmt::Display for SubjectKind {
901 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
902 match self {
903 SubjectKind::User => f.write_str("user"),
904 SubjectKind::Group => f.write_str("group"),
905 }
906 }
907}
908
909/// Access level for a resource permission, ordered from least to most
910/// privilege.
911#[derive(
912 Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, utoipa::ToSchema,
913)]
914#[serde(rename_all = "snake_case")]
915pub enum PermissionLevel {
916 /// No access.
917 None,
918 /// Read-only access.
919 Read,
920 /// Execute (e.g. deploy, run tasks) in addition to read.
921 Execute,
922 /// Full read/write/execute access.
923 Write,
924}
925
926impl std::fmt::Display for PermissionLevel {
927 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
928 match self {
929 PermissionLevel::None => f.write_str("none"),
930 PermissionLevel::Read => f.write_str("read"),
931 PermissionLevel::Execute => f.write_str("execute"),
932 PermissionLevel::Write => f.write_str("write"),
933 }
934 }
935}
936
937/// A stored permission grant binding a subject (user or group) to a resource
938/// with a specific access level.
939///
940/// Canonical `resource_kind` strings (kept here for grep-ability):
941/// - `"environment"` — a `StoredEnvironment` row.
942/// - `"deployment"` — a `StoredDeployment` row.
943/// - `"project"` — a `StoredProject` row.
944/// - `"secret"` — a row in the secrets store (`{scope}:{name}` keyed).
945/// - `"node"` — a cluster member identified by `NodeIdentity::node_id`.
946#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
947pub struct StoredPermission {
948 /// UUID identifier of this permission grant.
949 pub id: String,
950
951 /// Whether the subject is a user or a group.
952 pub subject_kind: SubjectKind,
953
954 /// The user or group id.
955 pub subject_id: String,
956
957 /// The kind of resource (e.g. `"deployment"`, `"project"`, `"secret"`).
958 pub resource_kind: String,
959
960 /// A specific resource id, or `None` for a wildcard (all resources of
961 /// that kind).
962 pub resource_id: Option<String>,
963
964 /// The granted access level.
965 pub level: PermissionLevel,
966
967 /// When the permission was created.
968 #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
969 pub created_at: DateTime<Utc>,
970}
971
972impl StoredPermission {
973 /// Create a new permission grant with a fresh UUID.
974 #[must_use]
975 pub fn new(
976 subject_kind: SubjectKind,
977 subject_id: impl Into<String>,
978 resource_kind: impl Into<String>,
979 resource_id: Option<String>,
980 level: PermissionLevel,
981 ) -> Self {
982 Self {
983 id: Uuid::new_v4().to_string(),
984 subject_kind,
985 subject_id: subject_id.into(),
986 resource_kind: resource_kind.into(),
987 resource_id,
988 level,
989 created_at: Utc::now(),
990 }
991 }
992}
993
994// =========================================================================
995// OIDC identities
996// =========================================================================
997
998/// One OIDC identity link row.
999///
1000/// One row is inserted the first time a user signs in via a given provider;
1001/// subsequent sign-ins look up the same row and reuse the linked `user_id`.
1002/// The uniqueness constraint on `(provider, subject)` enforces the
1003/// one-subject-one-user invariant.
1004#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
1005pub struct OidcIdentity {
1006 /// Surrogate id (uuid).
1007 pub id: String,
1008 /// `ZLayer` user account this identity resolves to.
1009 pub user_id: String,
1010 /// Provider slug matching `OidcProviderConfig::name`.
1011 pub provider: String,
1012 /// The `sub` claim from the provider's ID token. Opaque.
1013 pub subject: String,
1014 /// Email returned by the provider at link time (informational only).
1015 pub email_at_link: Option<String>,
1016 #[schema(value_type = String, format = DateTime)]
1017 pub created_at: DateTime<Utc>,
1018 #[schema(value_type = String, format = DateTime)]
1019 pub updated_at: DateTime<Utc>,
1020}
1021
1022impl OidcIdentity {
1023 /// Convenience constructor — fills `id`, `created_at`, `updated_at`.
1024 #[must_use]
1025 pub fn new(
1026 user_id: impl Into<String>,
1027 provider: impl Into<String>,
1028 subject: impl Into<String>,
1029 email_at_link: Option<String>,
1030 ) -> Self {
1031 let now = Utc::now();
1032 Self {
1033 id: Uuid::new_v4().to_string(),
1034 user_id: user_id.into(),
1035 provider: provider.into(),
1036 subject: subject.into(),
1037 email_at_link,
1038 created_at: now,
1039 updated_at: now,
1040 }
1041 }
1042}
1043
1044// =========================================================================
1045// Audit log
1046//
1047// `AuditEntry` is intentionally NOT moved here: it carries an
1048// `Option<serde_json::Value>` field, and `zlayer-types` does not depend on
1049// `serde_json`. Once a sibling migration adds the dep (or restructures the
1050// `details` field to a string), `AuditEntry` can move alongside the other
1051// `Stored*` types — it lives in `zlayer-api::storage` for now.
1052// =========================================================================
1053
1054// =========================================================================
1055// Cluster-replicated secrets (Phase 1 — Raft + sealed-box DEK wrap)
1056// =========================================================================
1057
1058/// Per-node identity and key material.
1059///
1060/// Each node generates an X25519 keypair on first start and publishes the
1061/// pubkey via `RegisterNode` during cluster join. Lives in Raft state so
1062/// every node knows the recipient set when wrapping the cluster DEK.
1063#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
1064pub struct NodeIdentity {
1065 /// Cluster-wide UUID for this node (matches Raft's view of the node).
1066 pub node_id: String,
1067
1068 /// 32-byte X25519 pubkey used as the sealed-box recipient when the
1069 /// leader wraps the cluster DEK to this node.
1070 #[schema(value_type = String, format = "byte")]
1071 pub secrets_pubkey: [u8; 32],
1072
1073 /// `WireGuard` pubkey, kept here for reference — overlay/tunnelling
1074 /// already track this elsewhere; duplicating it in `NodeIdentity` lets
1075 /// callers correlate sealed-box identity with overlay identity without
1076 /// a second lookup.
1077 pub wg_pubkey: String,
1078
1079 /// When this node was registered with the cluster.
1080 #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
1081 pub joined_at: DateTime<Utc>,
1082
1083 /// Soft-revocation timestamp. When set, the node no longer receives
1084 /// new wraps and must be excluded from `RotateDek` after a quorum
1085 /// confirms it has been physically removed.
1086 #[schema(value_type = Option<String>, example = "2026-04-16T12:00:00Z")]
1087 pub revoked_at: Option<DateTime<Utc>>,
1088}
1089
1090/// The cluster data-encryption key (DEK), wrapped per-node so each member
1091/// can decrypt without ever holding a shared cluster-wide private key.
1092///
1093/// The DEK itself is never stored anywhere; only the per-node sealed-box
1094/// wraps live in Raft. A node decrypts its own wrap on startup using its
1095/// node X25519 private key, and holds the unwrapped DEK in zeroized memory.
1096///
1097/// Generation increments on every rotation (e.g. node revocation, scheduled
1098/// rotation, suspected compromise). Every `ReplicatedSecret` records the
1099/// `dek_generation` it was encrypted under so re-encrypts can be batched.
1100#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
1101pub struct WrappedDek {
1102 /// Monotonically increasing generation counter.
1103 pub dek_generation: u64,
1104
1105 /// Map from `node_id` to that node's sealed-box-wrapped copy of the DEK.
1106 /// A node missing from this map cannot decrypt any secret encrypted
1107 /// under this generation and must be re-wrapped via `RegisterNode` (or
1108 /// through a `RotateDek` that includes it).
1109 pub wraps: std::collections::HashMap<String, Vec<u8>>,
1110}
1111
1112/// A secret replicated through Raft. Every node has the same encrypted
1113/// blob; only nodes whose `secrets_pubkey` is in the current `WrappedDek`
1114/// for this generation can decrypt.
1115#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
1116pub struct ReplicatedSecret {
1117 /// `"{scope}:{name}"` — same key shape used by `PersistentSecretsStore`.
1118 pub storage_key: String,
1119
1120 /// XChaCha20-Poly1305 ciphertext of the plaintext value, encrypted
1121 /// under the cluster DEK at `dek_generation`. Nonce is prepended.
1122 pub ciphertext: Vec<u8>,
1123
1124 /// Which DEK generation produced `ciphertext`. After a rotation, the
1125 /// state machine batches re-encrypts of every row whose `dek_generation`
1126 /// is older than current.
1127 pub dek_generation: u64,
1128
1129 /// Standard secret metadata (name, version, timestamps).
1130 #[schema(value_type = Object)]
1131 pub metadata: crate::secrets::SecretMetadata,
1132
1133 /// Optional per-secret affinity. `None` = any node may host (the
1134 /// default). When set, only matching nodes are entitled to a wrap of
1135 /// this row's DEK material; the API gate also filters reads accordingly.
1136 #[serde(default, skip_serializing_if = "Option::is_none")]
1137 pub node_affinity: Option<NodeAffinity>,
1138}
1139
1140/// Constrains which nodes are allowed to host a given secret's
1141/// decryptable form. Used as the value of `ReplicatedSecret.node_affinity`.
1142/// `None` on a secret = unconstrained (any node may host); `Some(...)`
1143/// = only matching nodes receive a wrap of this row's DEK material,
1144/// and the API gate filters reads accordingly.
1145#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
1146#[serde(tag = "kind", rename_all = "snake_case")]
1147pub enum NodeAffinity {
1148 /// Explicit allow-list of `node_id`s.
1149 Nodes {
1150 /// The set of `node_id`s entitled to host this secret.
1151 node_ids: Vec<String>,
1152 },
1153
1154 /// Match by node labels — every entry in the map must be present on
1155 /// the node. Empty map matches every node.
1156 Labels {
1157 /// Required label key/value pairs.
1158 labels: std::collections::HashMap<String, String>,
1159 },
1160}