//! Shared database model types used across pm-web and pm-worker. //! //! These match the database schema defined in migrations/. use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::FromRow; use uuid::Uuid; // ============================================================ // Enumerations (matching PostgreSQL ENUM types) // ============================================================ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] #[serde(rename_all = "lowercase")] #[sqlx(type_name = "host_health_status", rename_all = "lowercase")] pub enum HostHealthStatus { Pending, Healthy, Degraded, Unreachable, } impl std::fmt::Display for HostHealthStatus { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Pending => write!(f, "pending"), Self::Healthy => write!(f, "healthy"), Self::Degraded => write!(f, "degraded"), Self::Unreachable => write!(f, "unreachable"), } } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] #[serde(rename_all = "lowercase")] #[sqlx(type_name = "user_role", rename_all = "lowercase")] pub enum UserRole { Admin, Operator, Reporter, } impl std::fmt::Display for UserRole { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Admin => write!(f, "admin"), Self::Operator => write!(f, "operator"), Self::Reporter => write!(f, "reporter"), } } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] #[serde(rename_all = "snake_case")] #[sqlx(type_name = "auth_provider", rename_all = "snake_case")] pub enum AuthProvider { Local, #[sqlx(rename = "azure_sso")] AzureSso, Keycloak, Oidc, } impl std::fmt::Display for AuthProvider { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Local => write!(f, "local"), Self::AzureSso => write!(f, "azure_sso"), Self::Keycloak => write!(f, "keycloak"), Self::Oidc => write!(f, "oidc"), } } } // ============================================================ // Host // ============================================================ #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct Host { pub id: Uuid, pub fqdn: String, pub ip_address: String, // stored as INET, returned as text pub display_name: String, pub os_family: Option, pub os_name: Option, pub arch: Option, pub agent_version: Option, pub health_status: HostHealthStatus, pub last_health_at: Option>, pub last_patch_at: Option>, pub agent_port: i32, pub notes: String, pub registered_at: DateTime, pub updated_at: DateTime, } /// Payload for registering a new host. #[derive(Debug, Deserialize)] pub struct CreateHostRequest { /// FQDN or IP address of the managed host pub fqdn: String, pub display_name: Option, pub agent_port: Option, pub notes: Option, pub group_ids: Option>, } /// Payload for updating an existing host. #[derive(Debug, Deserialize)] pub struct UpdateHostRequest { pub fqdn: Option, pub ip_address: Option, pub display_name: Option, } /// Host list item (lighter projection for list views) #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct HostSummary { pub id: Uuid, pub fqdn: String, pub ip_address: String, pub display_name: String, pub os_family: Option, pub os_name: Option, pub health_status: HostHealthStatus, pub agent_version: Option, pub patches_missing: i32, pub health_check_status: Option, pub registered_at: DateTime, } // ============================================================ // Host Enrollment // ============================================================ #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct EnrollmentRequest { pub id: Uuid, pub machine_id: String, pub fqdn: String, pub ip_address: String, pub os_details: serde_json::Value, pub polling_token: String, /// Short hostname provided during enrollment (optional). pub hostname: Option, pub created_at: DateTime, pub expires_at: DateTime, } /// Payload for initial host enrollment request. #[derive(Debug, Deserialize, Serialize)] pub struct CreateEnrollmentRequest { pub machine_id: String, pub fqdn: String, pub ip_address: String, pub os_details: serde_json::Value, /// Short hostname (from /etc/hostname, optional). pub hostname: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "status", rename_all = "lowercase")] pub enum EnrollmentStatusResponse { Pending, Approved { ca_crt: String, server_crt: String, server_key: String, }, Denied, NotFound, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PkiBundle { /// PEM-encoded CA certificate (leaf-most cert in the chain). /// For root mode, this is the self-signed root CA. /// For sub-CA mode, this is the intermediate CA cert. pub ca_crt: String, /// PEM-encoded full CA certificate chain (concatenated intermediates + root). /// For root mode, this contains just the root CA cert (same as ca_crt). /// For sub-CA mode, this contains the intermediate cert followed by the /// external root cert, enabling the agent to verify the full chain up to /// the trust anchor. /// /// This field was added for CRL support (issue #7): the agent needs the /// full chain to verify CRL signatures that chain up to the root CA. #[serde(default)] pub ca_chain: String, /// PEM-encoded agent server certificate. pub server_crt: String, /// PEM-encoded agent server private key (PKCS#8). pub server_key: String, /// PEM-encoded Certificate Revocation List (CRL) signed by the CA. /// The agent uses this to reject revoked client certificates during mTLS /// handshakes. If CRL generation fails during enrollment, this field will /// be an empty string and the agent should fall back to WebPKI-only /// verification (degraded mode). /// /// Added for CRL support (issue #7). #[serde(default)] pub crl_pem: String, } /// Time-to-live for approved enrollment PKI bundles (10 minutes). /// /// After approval, the agent has this duration to retrieve its PKI bundle /// via the polling endpoint. Once retrieved (single-use) or expired, /// the bundle is permanently removed from the in-memory cache. /// /// This TTL balances security (limiting private key exposure in memory) /// against reliability (giving agents enough time to poll after approval). pub const ENROLLMENT_BUNDLE_TTL_SECS: u32 = 600; // 10 minutes /// An approved enrollment PKI bundle awaiting single-use retrieval. /// /// Stored in the in-memory cache between admin approval and agent pickup. /// The entry is removed atomically on first retrieval and expires after /// the configured TTL, whichever comes first. #[derive(Debug, Clone)] pub struct ApprovedEntry { pub pki: PkiBundle, pub approved_at: chrono::DateTime, pub ttl: chrono::Duration, } impl ApprovedEntry { /// Create a new entry with the current timestamp and default TTL. pub fn new(pki: PkiBundle) -> Self { Self { pki, approved_at: Utc::now(), ttl: chrono::Duration::seconds(ENROLLMENT_BUNDLE_TTL_SECS as i64), } } /// Returns true if this entry has exceeded its TTL. pub fn is_expired(&self) -> bool { Utc::now() > self.approved_at + self.ttl } } // ============================================================ // Health Checks // ============================================================ #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct HealthCheck { pub id: Uuid, pub host_id: Uuid, pub name: String, pub check_type: String, // "service" or "http" pub enabled: bool, // Service check fields pub service_name: Option, // HTTP check fields pub url: Option, pub expected_body: Option, pub ignore_cert_errors: bool, pub basic_auth_user: Option, pub target_host_id: Option, // basic_auth_pass_encrypted and nonce NOT exposed in API responses pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HealthCheckWithResult { #[serde(flatten)] pub check: HealthCheck, pub last_result: Option, } #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct HealthCheckResult { pub id: Uuid, pub check_id: Uuid, pub healthy: bool, pub detail: Option, pub latency_ms: Option, pub checked_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateHealthCheckRequest { pub name: String, pub check_type: String, // "service" or "http" pub service_name: Option, pub url: Option, pub expected_body: Option, #[serde(default = "default_true")] pub ignore_cert_errors: bool, pub basic_auth_user: Option, pub basic_auth_pass: Option, // plaintext in request, encrypted before storage pub target_host_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpdateHealthCheckRequest { pub name: Option, pub enabled: Option, pub service_name: Option, pub url: Option, pub expected_body: Option, pub ignore_cert_errors: Option, pub basic_auth_user: Option, pub basic_auth_pass: Option, // if provided, re-encrypt pub target_host_id: Option, } fn default_true() -> bool { true } // ============================================================ // Group // ============================================================ #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct Group { pub id: Uuid, pub name: String, pub description: String, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Deserialize)] pub struct CreateGroupRequest { pub name: String, pub description: Option, } #[derive(Debug, Deserialize)] pub struct UpdateGroupRequest { pub name: Option, pub description: Option, } // ============================================================ // User // ============================================================ #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct User { pub id: Uuid, pub username: String, pub display_name: String, pub email: String, pub role: UserRole, pub auth_provider: AuthProvider, pub mfa_enabled: bool, pub is_active: bool, pub force_password_reset: bool, pub last_login_at: Option>, pub created_at: DateTime, pub updated_at: DateTime, } /// User create payload (admin-only) #[derive(Debug, Deserialize)] pub struct CreateUserRequest { pub username: String, pub display_name: Option, pub email: String, pub role: String, pub password: String, } /// User update payload #[derive(Debug, Deserialize)] pub struct UpdateUserRequest { pub display_name: Option, pub email: Option, pub role: Option, pub is_active: Option, pub force_password_reset: Option, } /// Self-service password change payload #[derive(Debug, Deserialize)] pub struct ChangePasswordRequest { pub current_password: String, pub new_password: String, } /// Admin password reset payload #[derive(Debug, Deserialize)] pub struct AdminResetPasswordRequest { pub new_password: String, #[serde(default)] pub force_password_reset: bool, } // ============================================================ // Discovery // ============================================================ /// Request body for CIDR auto-discovery scan. #[derive(Debug, Deserialize)] pub struct DiscoveryCidrRequest { /// CIDR range to scan (e.g. "10.0.0.0/24") pub cidr: String, /// Agent port to probe (default 12443) pub agent_port: Option, } /// A single discovered host result. #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct DiscoveryResult { pub id: Uuid, pub scan_id: Uuid, pub ip_address: String, pub fqdn: Option, pub agent_version: Option, pub os_name: Option, pub agent_port: i32, pub discovered_at: DateTime, pub registered: bool, } /// Payload for registering a host from a discovery result. #[derive(Debug, Deserialize)] pub struct RegisterDiscoveredRequest { pub discovery_id: Uuid, pub display_name: Option, pub group_ids: Option>, } // ============================================================ // Patch Jobs // ============================================================ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] #[serde(rename_all = "lowercase")] #[sqlx(type_name = "job_status", rename_all = "lowercase")] pub enum JobStatus { Queued, Pending, Running, Succeeded, Failed, Cancelled, } impl std::fmt::Display for JobStatus { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Queued => write!(f, "queued"), Self::Pending => write!(f, "pending"), Self::Running => write!(f, "running"), Self::Succeeded => write!(f, "succeeded"), Self::Failed => write!(f, "failed"), Self::Cancelled => write!(f, "cancelled"), } } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] #[serde(rename_all = "snake_case")] #[sqlx(type_name = "job_kind", rename_all = "snake_case")] pub enum JobKind { #[sqlx(rename = "patch_apply")] PatchApply, #[sqlx(rename = "patch_remove")] PatchRemove, Reboot, Rollback, } /// Full `patch_jobs` row. #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct PatchJob { pub id: Uuid, pub kind: JobKind, pub status: JobStatus, pub created_by_user_id: Option, pub parent_job_id: Option, pub maintenance_window_id: Option, pub immediate: bool, pub patch_selection: serde_json::Value, pub notes: String, pub created_at: DateTime, pub started_at: Option>, pub completed_at: Option>, } /// Full `patch_job_hosts` row (includes columns added in migration 003). #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct PatchJobHost { pub id: Uuid, pub job_id: Uuid, pub host_id: Uuid, pub status: JobStatus, pub agent_job_id: Option, pub retry_count: i32, pub output: String, pub error_message: Option, pub retry_next_at: Option>, pub last_error: Option, pub started_at: Option>, pub completed_at: Option>, } /// Request payload for creating a patch job via `POST /api/v1/jobs`. #[derive(Debug, Deserialize)] pub struct CreateJobRequest { /// Host IDs to patch. pub host_ids: Vec, /// Package names to apply (empty = all available patches). pub packages: Vec, /// If true: apply immediately. If false: queue for next maintenance window. pub immediate: bool, /// Optional maintenance window to bind to. pub maintenance_window_id: Option, /// Allow reboot if required by patches. pub allow_reboot: Option, /// Optional operator notes. pub notes: Option, } /// Summary row for job list view (aggregates per-host counts). #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct PatchJobSummary { pub id: Uuid, pub kind: JobKind, pub status: JobStatus, pub immediate: bool, pub host_count: i64, /// Display names of hosts targeted by this job (falls back to fqdn). #[serde(default)] #[sqlx(skip)] pub host_names: Vec, pub succeeded_count: i64, pub failed_count: i64, pub notes: String, pub created_at: DateTime, pub started_at: Option>, pub completed_at: Option>, } // ============================================================ // Maintenance Windows // ============================================================ /// Recurrence type for a maintenance window. /// Mirrors the `window_recurrence` PostgreSQL ENUM. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] #[serde(rename_all = "lowercase")] #[sqlx(type_name = "window_recurrence", rename_all = "lowercase")] pub enum WindowRecurrence { /// Single one-time window (at `start_at` for `duration_minutes` minutes). Once, /// Repeats every day at the time portion of `start_at`. Daily, /// Repeats on the day-of-week in `recurrence_day` (0 = Sunday). Weekly, /// Repeats on the day-of-month in `recurrence_day` (1-31). Monthly, } impl std::fmt::Display for WindowRecurrence { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Once => write!(f, "once"), Self::Daily => write!(f, "daily"), Self::Weekly => write!(f, "weekly"), Self::Monthly => write!(f, "monthly"), } } } /// Full row from `maintenance_windows`. #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct MaintenanceWindow { pub id: Uuid, pub host_id: Uuid, pub label: String, pub recurrence: WindowRecurrence, /// Absolute start time (one-time) or time-of-day reference (recurring). pub start_at: DateTime, /// Duration of the window in minutes. pub duration_minutes: i32, /// Day-of-week (0=Sun, weekly) or day-of-month (1-31, monthly); NULL for once/daily. pub recurrence_day: Option, pub enabled: bool, pub auto_apply: bool, pub created_at: DateTime, pub updated_at: DateTime, } /// Payload for `POST /api/v1/hosts/{id}/maintenance-windows`. #[derive(Debug, Deserialize)] pub struct CreateMaintenanceWindowRequest { pub label: String, pub recurrence: WindowRecurrence, /// RFC 3339 / ISO 8601 timestamp (UTC recommended). pub start_at: DateTime, /// How many minutes the window is open (default 60). pub duration_minutes: Option, /// Required for `weekly` (0-6) and `monthly` (1-31). pub recurrence_day: Option, /// Whether the window is active (default true). pub enabled: Option, /// Whether to auto-create a patch_apply job when this window opens and patches are pending (default true). pub auto_apply: Option, } /// Payload for `PUT /api/v1/hosts/{id}/maintenance-windows/{window_id}`. #[derive(Debug, Deserialize)] pub struct UpdateMaintenanceWindowRequest { pub label: Option, pub recurrence: Option, pub start_at: Option>, pub duration_minutes: Option, pub recurrence_day: Option, pub enabled: Option, pub auto_apply: Option, }