Skip to main content

tablo_core/
audit.rs

1//! The audit log's rows: the [`AuditEntry`] each write of a panel with
2//! [`Panel::audit_log`](crate::Panel::audit_log) stores, and the [`AuditChange`]s it names.
3
4use jiff::Timestamp;
5use uuid::Uuid;
6
7use crate::TenantId;
8
9/// Shipped record of one thing a panel user did: who, on which panel and for which tenant, to
10/// which resource and record, and which fields it changed.
11///
12/// A panel with [`Panel::audit_log`](crate::Panel::audit_log) writes one per record of each
13/// write, inside the write's transaction, and [`AuditEvent::record`](crate::AuditEvent::record)
14/// writes the app's own. Nothing updates or deletes one but
15/// [`PanelHandle::prune_audit_log`](crate::PanelHandle::prune_audit_log).
16#[derive(Debug, Clone, toasty::Model)]
17#[index(panel, created_at)]
18pub struct AuditEntry {
19    #[key]
20    #[auto]
21    pub id: Uuid,
22    /// The prefix of the panel it happened on, e.g. `/admin`.
23    pub panel: String,
24    /// The tenant the user acted for; `None` outside every tenant.
25    pub tenant: Option<TenantId>,
26    /// [`PanelUser::user_id`](crate::PanelUser::user_id) of the user who did it; `None` for a
27    /// request without one.
28    pub actor_id: Option<String>,
29    /// The user's [`display_name`](crate::PanelUser::display_name) when it happened, kept so the
30    /// entry still names a user who is gone.
31    pub actor: String,
32    /// What happened: `create`, `update`, `delete`, `restore`, `force_delete`, `attach`,
33    /// `detach`, `action:<name>`, `header_action:<name>`, or the app's own event.
34    pub event: String,
35    /// The slug of the resource it happened to, as its URLs spell it; `None` for an app event on
36    /// no resource.
37    pub resource: Option<String>,
38    /// The record's key, as its URLs spell it; `None` for an event on no record.
39    pub record: Option<String>,
40    /// The record's detail page when it happened; `None` when the resource served none.
41    pub url: Option<String>,
42    /// The fields it changed, as JSON, so a field a later release adds to [`AuditChange`] needs
43    /// no migration.
44    #[column(type = text)]
45    pub changes: toasty::Json<Vec<AuditChange>>,
46    pub created_at: Timestamp,
47}
48
49// By hand, naming no tenant column: the log keeps every tenant's entries and its resource narrows
50// them to the reader's own, so mounting it must neither mark the panel as acting for tenants nor
51// ask it for a tenancy. Its resource declares its own table and detail page.
52impl crate::ModelColumns for AuditEntry {
53    const TENANT_COLUMNS: &'static [&'static str] = &[];
54}
55
56/// One field an [`AuditEntry`] changed: its name, its label in the writer's locale, and its
57/// spelling before and after, `None` where it held nothing.
58#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
59pub struct AuditChange {
60    /// The column's name, the model field it reads: `title`.
61    pub field: String,
62    /// The column's label as the writer read it: `Title`.
63    pub label: String,
64    pub before: Option<String>,
65    pub after: Option<String>,
66}
67
68/// The app's own event for the audit log of a panel with
69/// [`Panel::audit_log`](crate::Panel::audit_log): a sign-in, an export, a payment, beside what the
70/// panel's writes record.
71///
72/// ```rust,no_run
73/// # use tablo_core::AuditEvent;
74/// # use topcoat::context::Cx;
75/// # async fn exported(cx: &Cx, ex: &mut dyn toasty::Executor) -> topcoat::Result<()> {
76/// AuditEvent::new("export")
77///     .resource("orders")
78///     .record(cx, ex)
79///     .await?;
80/// # Ok(())
81/// # }
82/// ```
83#[derive(Debug, Clone)]
84pub struct AuditEvent {
85    pub(crate) event: String,
86    pub(crate) resource: Option<String>,
87    pub(crate) record: Option<String>,
88    pub(crate) url: Option<String>,
89    pub(crate) changes: Vec<AuditChange>,
90}
91
92impl AuditEvent {
93    /// An event named `event`, on no resource: the log shows the name as it is.
94    pub fn new(event: impl Into<String>) -> Self {
95        Self {
96            event: event.into(),
97            resource: None,
98            record: None,
99            url: None,
100            changes: Vec::new(),
101        }
102    }
103
104    /// The slug of the resource it happened to: only readers who may list that resource see it.
105    #[must_use]
106    pub fn resource(mut self, slug: impl Into<String>) -> Self {
107        self.resource = Some(slug.into());
108        self
109    }
110
111    /// The key of the record it happened to, and its page when it has one.
112    #[must_use]
113    pub fn key(mut self, key: impl Into<String>, url: Option<String>) -> Self {
114        self.record = Some(key.into());
115        self.url = url;
116        self
117    }
118
119    /// A field it changed, from `before` to `after`.
120    #[must_use]
121    pub fn change(
122        mut self,
123        field: impl Into<String>,
124        label: impl Into<String>,
125        before: Option<String>,
126        after: Option<String>,
127    ) -> Self {
128        self.changes.push(AuditChange {
129            field: field.into(),
130            label: label.into(),
131            before,
132            after,
133        });
134        self
135    }
136
137    /// Stores the event on `panel` for `tenant`, done by `actor`, its id and display name, through
138    /// `ex`: what [`record`](Self::record) and auth's own events share.
139    pub(crate) async fn write(
140        self,
141        ex: &mut dyn toasty::Executor,
142        panel: String,
143        tenant: Option<TenantId>,
144        actor: Option<(String, String)>,
145    ) -> toasty::Result<AuditEntry> {
146        let (actor_id, actor) = actor.map_or((None, String::new()), |(id, name)| (Some(id), name));
147        toasty::create!(AuditEntry {
148            panel,
149            tenant,
150            actor_id,
151            actor,
152            event: self.event,
153            resource: self.resource,
154            record: self.record,
155            url: self.url,
156            changes: toasty::Json(self.changes),
157            created_at: Timestamp::now(),
158        })
159        .exec(ex)
160        .await
161    }
162}