use anyhow::{Context, Result}; use uuid::Uuid; use crate::models::license::{License, LicenseCheckInResponse}; use crate::services::encryption; /// License validation and lifecycle management. /// /// Handles license key validation, activation (binding a key to a server), /// periodic check-ins from the Rust plugin, and license lookups. /// All license state is stored in PostgreSQL. pub struct LicenseService { db: sqlx::PgPool, } impl LicenseService { pub fn new(db: sqlx::PgPool) -> Self { Self { db } } /// Validate a license key and return its current status. /// /// Checks: key exists, not expired, not revoked, modules enabled. pub async fn validate_license(&self, license_key: &str) -> Result> { let license: Option = sqlx::query_as( "SELECT * FROM licenses WHERE license_key = $1 AND status = 'active'", ) .bind(license_key) .fetch_optional(&self.db) .await .context("Failed to query license")?; if let Some(ref lic) = license { // Check expiration if let Some(expires_at) = lic.expires_at { if expires_at < chrono::Utc::now() { return Ok(None); } } } Ok(license) } /// Activate a license key: bind it to a server name and subdomain. /// /// Called during initial setup when a user first registers. pub async fn activate_license( &self, license_key: &str, server_name: &str, subdomain: &str, ) -> Result { // Check license exists and is pending let existing: Option = sqlx::query_as( "SELECT * FROM licenses WHERE license_key = $1 AND status = 'pending'", ) .bind(license_key) .fetch_optional(&self.db) .await .context("Failed to query license for activation")?; if existing.is_none() { anyhow::bail!("License key not found or already activated"); } // Check subdomain availability let subdomain_exists: Option<(bool,)> = sqlx::query_as( "SELECT EXISTS(SELECT 1 FROM licenses WHERE subdomain = $1) as exists", ) .bind(subdomain) .fetch_optional(&self.db) .await .context("Failed to check subdomain availability")?; if subdomain_exists.map_or(false, |(exists,)| exists) { anyhow::bail!("Subdomain already in use"); } // Activate license let license: License = sqlx::query_as( "UPDATE licenses SET status = 'active', server_name = $2, subdomain = $3, updated_at = NOW() WHERE license_key = $1 RETURNING *", ) .bind(license_key) .bind(server_name) .bind(subdomain) .fetch_one(&self.db) .await .context("Failed to activate license")?; // Create default system roles for this license self.create_default_roles(license.id).await?; tracing::info!( "License activated: {} (subdomain: {}, server: {})", license_key, subdomain, server_name ); Ok(license) } /// Create default system roles for a newly activated license. async fn create_default_roles(&self, license_id: Uuid) -> Result<()> { // Owner role (full permissions) sqlx::query( "INSERT INTO roles (license_id, role_name, is_system_default, permissions) VALUES ($1, 'Owner', true, $2)", ) .bind(license_id) .bind(serde_json::json!({ "wipes": ["read", "write", "execute"], "maps": ["read", "write", "delete"], "plugins": ["read", "write", "configure"], "schedules": ["read", "write", "delete"], "team": ["read", "write", "delete"], "settings": ["read", "write"], "logs": ["read"] })) .execute(&self.db) .await .context("Failed to create Owner role")?; // Admin role (most permissions, no team deletion) sqlx::query( "INSERT INTO roles (license_id, role_name, is_system_default, permissions) VALUES ($1, 'Admin', true, $2)", ) .bind(license_id) .bind(serde_json::json!({ "wipes": ["read", "write", "execute"], "maps": ["read", "write"], "plugins": ["read", "write", "configure"], "schedules": ["read", "write"], "team": ["read", "write"], "settings": ["read"], "logs": ["read"] })) .execute(&self.db) .await .context("Failed to create Admin role")?; // Viewer role (read-only) sqlx::query( "INSERT INTO roles (license_id, role_name, is_system_default, permissions) VALUES ($1, 'Viewer', true, $2)", ) .bind(license_id) .bind(serde_json::json!({ "wipes": ["read"], "maps": ["read"], "plugins": ["read"], "schedules": ["read"], "team": ["read"], "settings": ["read"], "logs": ["read"] })) .execute(&self.db) .await .context("Failed to create Viewer role")?; Ok(()) } /// Process a check-in from the Rust server plugin. /// /// Updates last-seen timestamp and returns current license state /// including enabled modules and NATS connection token. pub async fn check_in( &self, license_key: &str, plugin_version: &str, ) -> Result { // Validate license let license = self.validate_license(license_key).await?; if license.is_none() { return Ok(LicenseCheckInResponse { valid: false, status: "invalid".to_string(), modules_enabled: vec![], nats_token: String::new(), }); } let license = license.unwrap(); // Update last-seen timestamp in server_connections sqlx::query( "INSERT INTO server_connections (license_id, plugin_version, plugin_last_seen) VALUES ($1, $2, NOW()) ON CONFLICT (license_id) DO UPDATE SET plugin_version = $2, plugin_last_seen = NOW()", ) .bind(license.id) .bind(plugin_version) .execute(&self.db) .await .context("Failed to update plugin check-in")?; // Generate NATS auth token (simple token based on license ID) let nats_token = encryption::generate_token(32); // Store token in DB for validation later sqlx::query( "UPDATE licenses SET nats_token = $2, updated_at = NOW() WHERE id = $1", ) .bind(license.id) .bind(&nats_token) .execute(&self.db) .await .context("Failed to store NATS token")?; tracing::debug!( "License check-in: {} (version: {})", license_key, plugin_version ); Ok(LicenseCheckInResponse { valid: true, status: license.status.clone(), modules_enabled: license.modules_enabled.unwrap_or_default(), nats_token, }) } /// Look up a license by its UUID. pub async fn get_license_by_id(&self, license_id: Uuid) -> Result> { let license: Option = sqlx::query_as("SELECT * FROM licenses WHERE id = $1") .bind(license_id) .fetch_optional(&self.db) .await .context("Failed to query license by ID")?; Ok(license) } /// Look up a license by its key. pub async fn get_license_by_key(&self, license_key: &str) -> Result> { let license: Option = sqlx::query_as("SELECT * FROM licenses WHERE license_key = $1") .bind(license_key) .fetch_optional(&self.db) .await .context("Failed to query license by key")?; Ok(license) } }