systemprompt-cloud 0.78.0

Cloud API client, credentials, OAuth, and tenant management for systemprompt.io AI governance deployments. Remote sync and multi-tenant orchestration for the MCP governance pipeline.
Documentation
//! Persistent map of [`super::StoredTenant`] records.
//!
//! Copyright (c) systemprompt.io — Business Source License 1.1.
//! See <https://systemprompt.io> for licensing details.

use std::fs;
use std::path::Path;

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use systemprompt_identifiers::TenantId;
use validator::Validate;

use super::StoredTenant;
use crate::api_client::CloudTenantInfo;
use crate::error::{CloudError, CloudResult};
use crate::private_dir::write_private_json;

#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct TenantStore {
    #[validate(nested)]
    pub tenants: Vec<StoredTenant>,

    pub synced_at: DateTime<Utc>,
}

impl TenantStore {
    #[must_use]
    pub fn new(tenants: Vec<StoredTenant>) -> Self {
        Self {
            tenants,
            synced_at: Utc::now(),
        }
    }

    #[must_use]
    pub fn from_tenant_infos(infos: &[CloudTenantInfo]) -> Self {
        let tenants = infos.iter().map(StoredTenant::from_tenant_info).collect();
        Self::new(tenants)
    }

    pub fn merge_control_plane(&mut self, infos: &[CloudTenantInfo]) {
        self.tenants.retain(|tenant| {
            !tenant.is_systemprompt_managed() || infos.iter().any(|info| info.id == tenant.id)
        });
        for info in infos {
            match self.tenants.iter_mut().find(|t| t.id == info.id) {
                Some(existing) if existing.is_systemprompt_managed() => {
                    existing.update_from_tenant_info(info);
                },
                Some(existing) => {
                    tracing::warn!(
                        tenant_id = %existing.id,
                        provider = %existing.provider,
                        "control plane lists a tenant id held by a self-managed record; keeping the local record"
                    );
                },
                None => self.tenants.push(StoredTenant::from_tenant_info(info)),
            }
        }
        self.synced_at = Utc::now();
    }

    pub fn insert_self_managed(&mut self, tenant: StoredTenant) -> CloudResult<()> {
        if tenant.is_systemprompt_managed() {
            return Err(CloudError::TenantNotSelfManaged {
                tenant_id: tenant.id,
            });
        }
        if self.find_tenant(&tenant.id).is_some() {
            return Err(CloudError::TenantAlreadyExists {
                tenant_id: tenant.id,
            });
        }
        self.tenants.push(tenant);
        Ok(())
    }

    pub fn load_from_path(path: &Path) -> CloudResult<Self> {
        if !path.exists() {
            return Err(CloudError::TenantsNotSynced);
        }

        let content = fs::read_to_string(path)?;

        let store: Self = serde_json::from_str(&content)
            .map_err(|e| CloudError::TenantsStoreCorrupted { source: e })?;

        store
            .validate()
            .map_err(|e| CloudError::TenantsStoreInvalid {
                source: Box::new(e),
            })?;

        Ok(store)
    }

    pub fn save_to_path(&self, path: &Path) -> CloudResult<()> {
        self.validate()
            .map_err(|e| CloudError::TenantsStoreInvalid {
                source: Box::new(e),
            })?;

        write_private_json(path, self)
    }

    #[must_use]
    pub fn find_tenant(&self, id: &TenantId) -> Option<&StoredTenant> {
        self.tenants.iter().find(|t| t.id == *id)
    }

    #[must_use]
    pub const fn is_empty(&self) -> bool {
        self.tenants.is_empty()
    }

    #[must_use]
    pub const fn len(&self) -> usize {
        self.tenants.len()
    }

    #[must_use]
    pub fn is_stale(&self, max_age: chrono::Duration) -> bool {
        let age = Utc::now() - self.synced_at;
        age > max_age
    }
}

impl Default for TenantStore {
    fn default() -> Self {
        Self {
            tenants: Vec::new(),
            synced_at: Utc::now(),
        }
    }
}