Skip to main content

tablo_core/panel/
audit.rs

1//! [`Panel::audit_log`]: every write of a panel kept as [`AuditEntry`] rows inside the write's
2//! transaction, and the read-only [`AuditLog`] resource that lists them.
3
4use 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
18/// How [`Panel::audit_log`] adjusts the [`AuditLog`] resource's def.
19pub(super) type CustomizeAuditLog =
20    Box<dyn FnOnce(ResourceDef<AuditLog>) -> ResourceDef<AuditLog> + Send>;
21
22/// The read-only resource listing the [`AuditEntry`] rows of its panel, newest first, at
23/// `{prefix}/audit-log`, or under the area that places it. [`Panel::audit_log`] registers it.
24///
25/// It lists what happened on its own panel only, for the tenant the reader acts for, and to the
26/// resources the reader may list: an entry about a resource whose list the reader may not open
27/// stays out of it, and a field [`field_access`](crate::ResourceDef::field_access) hides from the
28/// reader reads "Hidden".
29pub 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            // Every entry carries its tenant; `scope` keeps the reader's own.
41            .tenancy(Tenancy::none())
42            .table(columns::table())
43            .detail(columns::detail())
44            .query(scope)
45    }
46
47    // The log keeps what happened: even registered by hand, past [`Panel::audit_log`]'s policy,
48    // its pages delete nothing.
49    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
74/// The refusal of every write through the log's pages.
75fn immutable() -> crate::WriteError {
76    crate::WriteError::declined(Text::message("tablo-audit-immutable"))
77}
78
79/// The entries `cx` may read: its panel's, for the tenant it acts for, about the resources it
80/// may list or about none.
81fn 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
111/// The slugs of the panel's resources `cx` may list. A slug two resources share, in two areas,
112/// counts only when `cx` may list both: an entry names the slug alone.
113fn 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
133/// Allows the reads its inner policy allows, and nothing else: the log is never written through
134/// its pages, whatever policy the app gives it.
135struct 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    /// Keeps an [`AuditEntry`] for each record every write of the panel names, inside the write's
145    /// transaction, and lists them through the [`AuditLog`] resource at `{prefix}/audit-log`, or
146    /// `{prefix}/{area}/audit-log` once [`AreaGroup::audit_log`](crate::AreaGroup::audit_log)
147    /// places it.
148    ///
149    /// Each entry names the signed-in user, the tenant they act for, the resource and record,
150    /// and the fields the write changed, as a [`CommitHook`](crate::CommitHook) reads them: a
151    /// field marked `#[columns(skip)]`, such as a password hash, is never stored. A write whose
152    /// entry fails to store rolls back with it.
153    ///
154    /// A reader sees the entries of the resources whose list they may open, whatever row scope
155    /// each resource's own query keeps, so the log's policy should be no wider than the narrowest
156    /// of them. A delete keeps every field the record held until
157    /// [`prune_audit_log`](crate::PanelHandle::prune_audit_log) removes the entry.
158    ///
159    /// The log is denied to everyone until `customize` gives it a policy, and that policy allows
160    /// reads only:
161    ///
162    /// ```rust
163    /// # use tablo_core::{AuditLog, Panel, PanelUser, auth, when};
164    /// # use topcoat::context::Cx;
165    /// # fn is_admin(cx: &Cx) -> bool { false }
166    /// Panel::new("admin").audit_log(|log| log.policy(when(is_admin)));
167    /// ```
168    ///
169    /// The database registers [`AuditEntry`]: [`models!`](crate::models!) does.
170    ///
171    /// Calling `audit_log` again replaces `customize`.
172    #[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    /// Stores the event for the request's panel, user and tenant through `ex`: the write's
192    /// transaction inside a [`CommitHook`](crate::CommitHook) or
193    /// [`Resource::before_commit`], or the panel's [`Db`](toasty::Db) outside one.
194    ///
195    /// # Errors
196    ///
197    /// A declaration error outside every panel; an opaque infrastructure failure when the row
198    /// cannot be written, the driver's text going to the log.
199    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
210/// The mount's refusal of a panel whose registrations, by the URLs they serve at, include the
211/// [`AuditLog`], over a `db` that does not register [`AuditEntry`].
212pub(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
221/// Store an entry for each record `change` names.
222async 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
233/// The events `change` records: one per record it names, or one on no record for a header
234/// action.
235fn 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        // A save that changed no field the log reads writes no entry.
255        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
277/// Write `event` as an entry of the panel at `prefix`, by the request's user and tenant.
278async 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}