invoance 0.2.0

Official Rust SDK for the Invoance compliance API
Documentation
//! Audit Logs resource — `client.audit()`.
//!
//! An append-only, per-tenant signed event ledger with end-customer orgs,
//! SIEM/webhook streams, hosted-viewer portal links, and async exports. Most
//! methods resolve to the server's raw JSON. For an offline signature check of
//! a returned event, see [`verify_audit_event`](crate::audit_verify::verify_audit_event).

use std::sync::Arc;

use serde_json::{json, Map, Value};
use sha2::{Digest, Sha256};

use crate::error::Error;
use crate::http::{HttpTransport, QueryParams};
use crate::models::audit::*;

/// Stable, compact JSON with all object keys sorted deeply (no null stripping).
fn stable_stringify(v: &Value, out: &mut String) {
    match v {
        Value::Object(o) => {
            let mut keys: Vec<&String> = o.keys().collect();
            keys.sort();
            out.push('{');
            for (i, k) in keys.iter().enumerate() {
                if i > 0 {
                    out.push(',');
                }
                out.push_str(&serde_json::to_string(k).expect("string key serializes"));
                out.push(':');
                stable_stringify(&o[*k], out);
            }
            out.push('}');
        }
        Value::Array(a) => {
            out.push('[');
            for (i, item) in a.iter().enumerate() {
                if i > 0 {
                    out.push(',');
                }
                stable_stringify(item, out);
            }
            out.push(']');
        }
        scalar => out.push_str(&serde_json::to_string(scalar).expect("scalar serializes")),
    }
}

/// Derive a stable `Idempotency-Key` from an event body (safe-retry helper):
/// `"idem_" + sha256hex(stable_stringify(body))`.
pub fn content_idempotency_key(body: &Value) -> String {
    let mut s = String::new();
    stable_stringify(body, &mut s);
    let mut hasher = Sha256::new();
    hasher.update(s.as_bytes());
    format!("idem_{}", hex::encode(hasher.finalize()))
}

/// Current time as an RFC3339 UTC string with `Z` suffix (whole seconds).
fn now_rfc3339() -> String {
    let secs = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0);
    let days = secs.div_euclid(86_400);
    let rem = secs.rem_euclid(86_400);
    let (y, m, d) = civil_from_days(days);
    let hh = rem / 3600;
    let mi = (rem % 3600) / 60;
    let ss = rem % 60;
    format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mi:02}:{ss:02}Z")
}

fn civil_from_days(z: i64) -> (i64, i64, i64) {
    let z = z + 719468;
    let era = if z >= 0 { z } else { z - 146096 } / 146097;
    let doe = z - era * 146097;
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
    let y = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    (if m <= 2 { y + 1 } else { y }, m, d)
}

// ── events ────────────────────────────────────────────────────

/// `client.audit().events` — the signed event ledger.
#[derive(Clone)]
pub struct AuditEventsResource {
    t: Arc<HttpTransport>,
}

impl AuditEventsResource {
    /// `POST /audit/events` — append one signed event.
    ///
    /// The ledger requires an `Idempotency-Key`; when the caller does not
    /// supply one, a content-stable key is derived automatically.
    pub async fn ingest(&self, params: IngestAuditEventParams) -> Result<Value, Error> {
        let mut body = Map::new();
        body.insert("organization_id".into(), json!(params.organization_id));
        body.insert("action".into(), json!(params.action));
        body.insert(
            "occurred_at".into(),
            json!(params.occurred_at.unwrap_or_else(now_rfc3339)),
        );
        body.insert("actor".into(), Value::Object(params.actor));
        body.insert(
            "targets".into(),
            Value::Array(params.targets.unwrap_or_default()),
        );
        if let Some(c) = params.context {
            body.insert("context".into(), Value::Object(c));
        }
        if let Some(m) = params.metadata {
            body.insert("metadata".into(), Value::Object(m));
        }
        let body = Value::Object(body);
        let idem = params
            .idempotency_key
            .unwrap_or_else(|| content_idempotency_key(&body));
        self.t.post("/audit/events", Some(&body), Some(&idem)).await
    }

    /// `GET /audit/events` — keyset-paginated listing.
    pub async fn list(
        &self,
        params: ListAuditEventsParams,
    ) -> Result<ListAuditEventsResponse, Error> {
        let q: QueryParams = vec![
            ("organization_id", params.organization_id),
            ("actions", params.actions),
            ("actor_id", params.actor_id),
            ("target_id", params.target_id),
            ("range_start", params.range_start),
            ("range_end", params.range_end),
            ("limit", params.limit.map(|v| v.to_string())),
            ("cursor", params.cursor),
        ];
        self.t.get("/audit/events", Some(q)).await
    }

    /// `GET /audit/events/{id}`.
    pub async fn get(&self, event_id: &str) -> Result<AuditEvent, Error> {
        self.t.get(&format!("/audit/events/{event_id}"), None).await
    }

    /// `GET /audit/events/{id}/verify` — server-side verify (pinned key).
    pub async fn verify(&self, event_id: &str) -> Result<Value, Error> {
        self.t
            .get(&format!("/audit/events/{event_id}/verify"), None)
            .await
    }
}

// ── orgs ──────────────────────────────────────────────────────

/// `client.audit().orgs` — end-customer organizations.
#[derive(Clone)]
pub struct AuditOrgsResource {
    t: Arc<HttpTransport>,
}

impl AuditOrgsResource {
    pub async fn create(&self, params: CreateAuditOrgParams) -> Result<Value, Error> {
        let mut body = Map::new();
        body.insert("organization_id".into(), json!(params.organization_id));
        if let Some(n) = &params.name {
            body.insert("name".into(), json!(n));
        }
        self.t
            .post("/audit/orgs", Some(&Value::Object(body)), None)
            .await
    }

    /// `GET /audit/orgs` — archived orgs are excluded unless
    /// `include_archived` is set.
    pub async fn list(&self, params: ListAuditOrgsParams) -> Result<Value, Error> {
        let q: QueryParams = vec![(
            "include_archived",
            params.include_archived.map(|v| v.to_string()),
        )];
        self.t.get("/audit/orgs", Some(q)).await
    }

    /// `PATCH /audit/orgs/{id}` — rename an org (`name: None` clears it).
    pub async fn update(
        &self,
        organization_id: &str,
        params: UpdateAuditOrgParams,
    ) -> Result<Value, Error> {
        let body = json!({ "name": params.name });
        self.t
            .patch(&format!("/audit/orgs/{organization_id}"), Some(&body))
            .await
    }

    /// `POST /audit/orgs/{id}/archive` — idempotent; freezes new activity,
    /// history stays verifiable.
    pub async fn archive(&self, organization_id: &str) -> Result<Value, Error> {
        self.t
            .post(
                &format!("/audit/orgs/{organization_id}/archive"),
                None,
                None,
            )
            .await
    }

    /// `POST /audit/orgs/{id}/unarchive` — idempotent.
    pub async fn unarchive(&self, organization_id: &str) -> Result<Value, Error> {
        self.t
            .post(
                &format!("/audit/orgs/{organization_id}/unarchive"),
                None,
                None,
            )
            .await
    }

    /// `DELETE /audit/orgs/{id}` — only when nothing signed would be destroyed
    /// (409 `org_not_deletable` otherwise).
    pub async fn delete(&self, organization_id: &str) -> Result<Value, Error> {
        self.t
            .delete(&format!("/audit/orgs/{organization_id}"))
            .await
    }

    pub async fn integrity(&self, organization_id: &str) -> Result<Value, Error> {
        self.t
            .get(&format!("/audit/orgs/{organization_id}/integrity"), None)
            .await
    }

    pub async fn set_retention(&self, organization_id: &str, days: i64) -> Result<Value, Error> {
        let body = json!({ "days": days });
        self.t
            .put(
                &format!("/audit/orgs/{organization_id}/retention"),
                Some(&body),
            )
            .await
    }
}

// ── streams ───────────────────────────────────────────────────

/// `client.audit().streams` — SIEM/webhook delivery streams.
#[derive(Clone)]
pub struct AuditStreamsResource {
    t: Arc<HttpTransport>,
}

impl AuditStreamsResource {
    /// Create a webhook stream; the signing secret is returned ONCE.
    pub async fn create(
        &self,
        organization_id: &str,
        params: CreateAuditStreamParams,
    ) -> Result<Value, Error> {
        let body = json!({
            "type": params.r#type.unwrap_or_else(|| "webhook".to_string()),
            "url": params.url,
        });
        self.t
            .post(
                &format!("/audit/orgs/{organization_id}/streams"),
                Some(&body),
                None,
            )
            .await
    }

    pub async fn list(&self, organization_id: &str) -> Result<Value, Error> {
        self.t
            .get(&format!("/audit/orgs/{organization_id}/streams"), None)
            .await
    }

    pub async fn delete(&self, organization_id: &str, stream_id: &str) -> Result<Value, Error> {
        self.t
            .delete(&format!(
                "/audit/orgs/{organization_id}/streams/{stream_id}"
            ))
            .await
    }

    pub async fn test(&self, organization_id: &str, stream_id: &str) -> Result<Value, Error> {
        self.t
            .post(
                &format!("/audit/orgs/{organization_id}/streams/{stream_id}/test"),
                None,
                None,
            )
            .await
    }
}

// ── portal sessions ───────────────────────────────────────────

/// `client.audit().portal_sessions` — hosted-viewer links.
#[derive(Clone)]
pub struct AuditPortalSessionsResource {
    t: Arc<HttpTransport>,
}

impl AuditPortalSessionsResource {
    /// Mint a one-time hosted-viewer link.
    pub async fn create(&self, params: CreatePortalSessionParams) -> Result<Value, Error> {
        let mut body = Map::new();
        body.insert("organization_id".into(), json!(params.organization_id));
        body.insert("intent".into(), json!(params.intent));
        if let Some(s) = params.session_duration_seconds {
            body.insert("session_duration_seconds".into(), json!(s));
        }
        if let Some(s) = params.link_duration_seconds {
            body.insert("link_duration_seconds".into(), json!(s));
        }
        self.t
            .post("/audit/portal_sessions", Some(&Value::Object(body)), None)
            .await
    }
}

// ── exports ───────────────────────────────────────────────────

/// `client.audit().exports` — async CSV/NDJSON export jobs.
#[derive(Clone)]
pub struct AuditExportsResource {
    t: Arc<HttpTransport>,
}

impl AuditExportsResource {
    /// Queue an async CSV/NDJSON export job.
    pub async fn create(&self, params: CreateAuditExportParams) -> Result<Value, Error> {
        let mut body = Map::new();
        body.insert("organization_id".into(), json!(params.organization_id));
        body.insert("format".into(), json!(params.format));
        if let Some(f) = params.filters {
            body.insert("filters".into(), Value::Object(f));
        }
        self.t
            .post("/audit/exports", Some(&Value::Object(body)), None)
            .await
    }

    /// Poll a job; when `status == "ready"` the response has `download_url`.
    pub async fn get(&self, export_id: &str) -> Result<Value, Error> {
        self.t
            .get(&format!("/audit/exports/{export_id}"), None)
            .await
    }
}

/// The audit-logs namespace reachable via `client.audit()`.
#[derive(Clone)]
pub struct AuditResource {
    /// The signed event ledger.
    pub events: AuditEventsResource,
    /// End-customer organizations.
    pub orgs: AuditOrgsResource,
    /// SIEM/webhook delivery streams.
    pub streams: AuditStreamsResource,
    /// Hosted-viewer portal links.
    pub portal_sessions: AuditPortalSessionsResource,
    /// Async export jobs.
    pub exports: AuditExportsResource,
}

impl AuditResource {
    pub(crate) fn new(t: Arc<HttpTransport>) -> Self {
        Self {
            events: AuditEventsResource { t: t.clone() },
            orgs: AuditOrgsResource { t: t.clone() },
            streams: AuditStreamsResource { t: t.clone() },
            portal_sessions: AuditPortalSessionsResource { t: t.clone() },
            exports: AuditExportsResource { t },
        }
    }
}