Skip to main content

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#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
940pub struct StoredPermission {
941    /// UUID identifier of this permission grant.
942    pub id: String,
943
944    /// Whether the subject is a user or a group.
945    pub subject_kind: SubjectKind,
946
947    /// The user or group id.
948    pub subject_id: String,
949
950    /// The kind of resource (e.g. `"deployment"`, `"project"`, `"secret"`).
951    pub resource_kind: String,
952
953    /// A specific resource id, or `None` for a wildcard (all resources of
954    /// that kind).
955    pub resource_id: Option<String>,
956
957    /// The granted access level.
958    pub level: PermissionLevel,
959
960    /// When the permission was created.
961    #[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
962    pub created_at: DateTime<Utc>,
963}
964
965impl StoredPermission {
966    /// Create a new permission grant with a fresh UUID.
967    #[must_use]
968    pub fn new(
969        subject_kind: SubjectKind,
970        subject_id: impl Into<String>,
971        resource_kind: impl Into<String>,
972        resource_id: Option<String>,
973        level: PermissionLevel,
974    ) -> Self {
975        Self {
976            id: Uuid::new_v4().to_string(),
977            subject_kind,
978            subject_id: subject_id.into(),
979            resource_kind: resource_kind.into(),
980            resource_id,
981            level,
982            created_at: Utc::now(),
983        }
984    }
985}
986
987// =========================================================================
988// OIDC identities
989// =========================================================================
990
991/// One OIDC identity link row.
992///
993/// One row is inserted the first time a user signs in via a given provider;
994/// subsequent sign-ins look up the same row and reuse the linked `user_id`.
995/// The uniqueness constraint on `(provider, subject)` enforces the
996/// one-subject-one-user invariant.
997#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
998pub struct OidcIdentity {
999    /// Surrogate id (uuid).
1000    pub id: String,
1001    /// `ZLayer` user account this identity resolves to.
1002    pub user_id: String,
1003    /// Provider slug matching `OidcProviderConfig::name`.
1004    pub provider: String,
1005    /// The `sub` claim from the provider's ID token. Opaque.
1006    pub subject: String,
1007    /// Email returned by the provider at link time (informational only).
1008    pub email_at_link: Option<String>,
1009    #[schema(value_type = String, format = DateTime)]
1010    pub created_at: DateTime<Utc>,
1011    #[schema(value_type = String, format = DateTime)]
1012    pub updated_at: DateTime<Utc>,
1013}
1014
1015impl OidcIdentity {
1016    /// Convenience constructor — fills `id`, `created_at`, `updated_at`.
1017    #[must_use]
1018    pub fn new(
1019        user_id: impl Into<String>,
1020        provider: impl Into<String>,
1021        subject: impl Into<String>,
1022        email_at_link: Option<String>,
1023    ) -> Self {
1024        let now = Utc::now();
1025        Self {
1026            id: Uuid::new_v4().to_string(),
1027            user_id: user_id.into(),
1028            provider: provider.into(),
1029            subject: subject.into(),
1030            email_at_link,
1031            created_at: now,
1032            updated_at: now,
1033        }
1034    }
1035}
1036
1037// =========================================================================
1038// Audit log
1039//
1040// `AuditEntry` is intentionally NOT moved here: it carries an
1041// `Option<serde_json::Value>` field, and `zlayer-types` does not depend on
1042// `serde_json`. Once a sibling migration adds the dep (or restructures the
1043// `details` field to a string), `AuditEntry` can move alongside the other
1044// `Stored*` types — it lives in `zlayer-api::storage` for now.
1045// =========================================================================