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}