1use std::sync::Arc;
5
6use toasty::Executor;
7use topcoat::{Result, context::Cx};
8
9use super::{Panel, PanelChange, state::current};
10use crate::{
11 Ability, ChangedRecord, NoForm, Policy, Resource, ResourceDef, Tenancy, Text,
12 audit::{AuditEntry, AuditEvent},
13 resource::Change,
14};
15
16mod columns;
17
18pub(super) type CustomizeAuditLog =
20 Box<dyn FnOnce(ResourceDef<AuditLog>) -> ResourceDef<AuditLog> + Send>;
21
22pub struct AuditLog;
30
31impl Resource for AuditLog {
32 type Model = AuditEntry;
33 type Form = NoForm<AuditEntry>;
34
35 fn declare() -> ResourceDef<Self> {
36 ResourceDef::new()
37 .slug("audit-log")
38 .label(Text::message("tablo-audit-entry"))
39 .plural_label(Text::message("tablo-audit-log"))
40 .tenancy(Tenancy::none())
42 .table(columns::table())
43 .detail(columns::detail())
44 .query(scope)
45 }
46
47 async fn delete_record(
50 _cx: &Cx,
51 _record: &AuditEntry,
52 _ex: &mut dyn Executor,
53 ) -> std::result::Result<(), crate::WriteError> {
54 Err(immutable())
55 }
56
57 async fn restore_record(
58 _cx: &Cx,
59 _record: &AuditEntry,
60 _ex: &mut dyn Executor,
61 ) -> std::result::Result<(), crate::WriteError> {
62 Err(immutable())
63 }
64
65 async fn force_delete_record(
66 _cx: &Cx,
67 _record: &AuditEntry,
68 _ex: &mut dyn Executor,
69 ) -> std::result::Result<(), crate::WriteError> {
70 Err(immutable())
71 }
72}
73
74fn immutable() -> crate::WriteError {
76 crate::WriteError::declined(Text::message("tablo-audit-immutable"))
77}
78
79fn scope(
82 cx: &Cx,
83 query: toasty::stmt::Query<toasty::stmt::List<AuditEntry>>,
84) -> toasty::stmt::Query<toasty::stmt::List<AuditEntry>> {
85 let Some(panel) = current(cx) else {
86 return query.filter(crate::toasty_compat::never());
87 };
88 let fields = AuditEntry::fields();
89 let tenant = match crate::tenant_id(cx) {
90 Some(tenant) => fields.tenant().eq(Some(tenant)),
91 None => fields.tenant().is_none(),
92 };
93 let listable = listable_slugs(cx);
94 let resource = if listable.is_empty() {
95 fields.resource().is_none()
96 } else {
97 fields
98 .resource()
99 .is_none()
100 .or(fields.resource().in_list(listable))
101 };
102 query.filter(
103 fields
104 .panel()
105 .eq(panel.realm.prefix.clone())
106 .and(tenant)
107 .and(resource),
108 )
109}
110
111fn listable_slugs(cx: &Cx) -> Vec<Option<String>> {
114 let Some(panel) = current(cx) else {
115 return Vec::new();
116 };
117 let mut slugs: Vec<Option<String>> = Vec::new();
118 for child in panel.children.values() {
119 let shared = || {
120 panel
121 .children
122 .values()
123 .filter(|other| other.slug == child.slug)
124 };
125 let slug = Some(child.slug.clone());
126 if !slugs.contains(&slug) && shared().all(|other| (other.listable)(cx)) {
127 slugs.push(slug);
128 }
129 }
130 slugs
131}
132
133struct Immutable(Arc<dyn Policy<AuditEntry>>);
136
137impl Policy<AuditEntry> for Immutable {
138 fn allows(&self, cx: &Cx, ability: Ability<'_, AuditEntry>) -> bool {
139 ability.is_read() && self.0.allows(cx, ability)
140 }
141}
142
143impl Panel {
144 #[must_use]
173 pub fn audit_log(
174 mut self,
175 customize: impl FnOnce(ResourceDef<AuditLog>) -> ResourceDef<AuditLog> + Send + 'static,
176 ) -> Self {
177 if self.audit_log.is_none() {
178 self.commit_hooks
179 .push(|cx, change, ex| Box::pin(store(cx, change, ex)));
180 }
181 self.audit_log = Some(Box::new(move |def| {
182 let mut def = customize(def);
183 def.policy = Arc::new(Immutable(def.policy));
184 def
185 }));
186 self
187 }
188}
189
190impl AuditEvent {
191 pub async fn record(self, cx: &Cx, ex: &mut dyn Executor) -> Result<AuditEntry> {
200 let Some(realm) = crate::auth::current_realm(cx) else {
201 return Err(crate::error::declaration(
202 "AuditEvent::record needs a request under a panel".to_owned(),
203 ));
204 };
205 let prefix = realm.prefix.clone();
206 insert(cx, ex, prefix, self).await
207 }
208}
209
210pub(super) fn declaration_error(
213 db: &toasty::Db,
214 urls: &std::collections::HashMap<std::any::TypeId, String>,
215) -> Option<crate::DeclarationError> {
216 let kept = urls.contains_key(&std::any::TypeId::of::<AuditLog>());
217 (kept && !crate::toasty_compat::model::AppSchema::of_db(db).registers("AuditEntry"))
218 .then(|| crate::DeclarationError::panel(crate::DeclarationErrorKind::MissingAuditModel))
219}
220
221async fn store(cx: &Cx, change: &PanelChange, ex: &mut dyn Executor) -> Result<()> {
223 let Some(panel) = current(cx) else {
224 return Ok(());
225 };
226 let prefix = panel.realm.prefix.clone();
227 for event in events(cx, change) {
228 insert(cx, &mut *ex, prefix.clone(), event).await?;
229 }
230 Ok(())
231}
232
233fn events(cx: &Cx, change: &PanelChange) -> Vec<AuditEvent> {
236 let on = |name: &str, record: &ChangedRecord| {
237 let event = AuditEvent::new(name)
238 .resource(change.slug())
239 .key(record.key(), record.url().map(str::to_owned));
240 record.fields().iter().fold(event, |event, field| {
241 event.change(
242 field.name(),
243 field.label().localize(cx),
244 field.before().map(str::to_owned),
245 field.after().map(str::to_owned),
246 )
247 })
248 };
249 let each = |name: &str, records: &[ChangedRecord]| {
250 records.iter().map(|record| on(name, record)).collect()
251 };
252 match change.change() {
253 Change::Create(record) => vec![on("create", record)],
254 Change::Update { after, .. } if after.fields().is_empty() => Vec::new(),
256 Change::Update { after, .. } => vec![on("update", after)],
257 Change::Delete(records) => each("delete", records),
258 Change::Restore(records) => each("restore", records),
259 Change::ForceDelete(records) => each("force_delete", records),
260 Change::Action { name, records } => each(&format!("action:{name}"), records),
261 Change::HeaderAction { name } => {
262 vec![AuditEvent::new(format!("header_action:{name}")).resource(change.slug())]
263 }
264 Change::Attach {
265 owner,
266 relation,
267 key,
268 } => vec![on("attach", owner).change(relation, relation, None, Some(key.clone()))],
269 Change::Detach {
270 owner,
271 relation,
272 keys,
273 } => vec![on("detach", owner).change(relation, relation, Some(keys.join(", ")), None)],
274 }
275}
276
277async fn insert(
279 cx: &Cx,
280 ex: &mut dyn Executor,
281 prefix: String,
282 event: AuditEvent,
283) -> Result<AuditEntry> {
284 let actor = crate::auth::signed(cx).map(|signed| {
285 let user = signed.impersonator.as_ref().unwrap_or(&signed.user);
286 (user.user_id(), user.display_name().to_owned())
287 });
288 event
289 .write(ex, prefix, crate::tenant_id(cx), actor)
290 .await
291 .map_err(|error| {
292 crate::error::driver_failure(error, "The audit log is unavailable right now.")
293 })
294}