use std::sync::Arc;
use toasty::Executor;
use topcoat::{Result, context::Cx};
use super::{Panel, PanelChange, state::current};
use crate::{
Ability, ChangedRecord, NoForm, Policy, Resource, ResourceDef, Tenancy, Text,
audit::{AuditEntry, AuditEvent},
resource::Change,
};
mod columns;
pub(super) type CustomizeAuditLog =
Box<dyn FnOnce(ResourceDef<AuditLog>) -> ResourceDef<AuditLog> + Send>;
pub struct AuditLog;
impl Resource for AuditLog {
type Model = AuditEntry;
type Form = NoForm<AuditEntry>;
fn declare() -> ResourceDef<Self> {
ResourceDef::new()
.slug("audit-log")
.label(Text::message("tablo-audit-entry"))
.plural_label(Text::message("tablo-audit-log"))
.tenancy(Tenancy::none())
.table(columns::table())
.detail(columns::detail())
.query(scope)
}
async fn delete_record(
_cx: &Cx,
_record: &AuditEntry,
_ex: &mut dyn Executor,
) -> std::result::Result<(), crate::WriteError> {
Err(immutable())
}
async fn restore_record(
_cx: &Cx,
_record: &AuditEntry,
_ex: &mut dyn Executor,
) -> std::result::Result<(), crate::WriteError> {
Err(immutable())
}
async fn force_delete_record(
_cx: &Cx,
_record: &AuditEntry,
_ex: &mut dyn Executor,
) -> std::result::Result<(), crate::WriteError> {
Err(immutable())
}
}
fn immutable() -> crate::WriteError {
crate::WriteError::declined(Text::message("tablo-audit-immutable"))
}
fn scope(
cx: &Cx,
query: toasty::stmt::Query<toasty::stmt::List<AuditEntry>>,
) -> toasty::stmt::Query<toasty::stmt::List<AuditEntry>> {
let Some(panel) = current(cx) else {
return query.filter(crate::toasty_compat::never());
};
let fields = AuditEntry::fields();
let tenant = match crate::tenant_id(cx) {
Some(tenant) => fields.tenant().eq(Some(tenant)),
None => fields.tenant().is_none(),
};
let listable = listable_slugs(cx);
let resource = if listable.is_empty() {
fields.resource().is_none()
} else {
fields
.resource()
.is_none()
.or(fields.resource().in_list(listable))
};
query.filter(
fields
.panel()
.eq(panel.realm.prefix.clone())
.and(tenant)
.and(resource),
)
}
fn listable_slugs(cx: &Cx) -> Vec<Option<String>> {
let Some(panel) = current(cx) else {
return Vec::new();
};
let mut slugs: Vec<Option<String>> = Vec::new();
for child in panel.children.values() {
let shared = || {
panel
.children
.values()
.filter(|other| other.slug == child.slug)
};
let slug = Some(child.slug.clone());
if !slugs.contains(&slug) && shared().all(|other| (other.listable)(cx)) {
slugs.push(slug);
}
}
slugs
}
struct Immutable(Arc<dyn Policy<AuditEntry>>);
impl Policy<AuditEntry> for Immutable {
fn allows(&self, cx: &Cx, ability: Ability<'_, AuditEntry>) -> bool {
ability.is_read() && self.0.allows(cx, ability)
}
}
impl Panel {
#[must_use]
pub fn audit_log(
mut self,
customize: impl FnOnce(ResourceDef<AuditLog>) -> ResourceDef<AuditLog> + Send + 'static,
) -> Self {
if self.audit_log.is_none() {
self.commit_hooks
.push(|cx, change, ex| Box::pin(store(cx, change, ex)));
}
self.audit_log = Some(Box::new(move |def| {
let mut def = customize(def);
def.policy = Arc::new(Immutable(def.policy));
def
}));
self
}
}
impl AuditEvent {
pub async fn record(self, cx: &Cx, ex: &mut dyn Executor) -> Result<AuditEntry> {
let Some(realm) = crate::auth::current_realm(cx) else {
return Err(crate::error::declaration(
"AuditEvent::record needs a request under a panel".to_owned(),
));
};
let prefix = realm.prefix.clone();
insert(cx, ex, prefix, self).await
}
}
pub(super) fn declaration_error(
db: &toasty::Db,
urls: &std::collections::HashMap<std::any::TypeId, String>,
) -> Option<crate::DeclarationError> {
let kept = urls.contains_key(&std::any::TypeId::of::<AuditLog>());
(kept && !crate::toasty_compat::model::AppSchema::of_db(db).registers("AuditEntry"))
.then(|| crate::DeclarationError::panel(crate::DeclarationErrorKind::MissingAuditModel))
}
async fn store(cx: &Cx, change: &PanelChange, ex: &mut dyn Executor) -> Result<()> {
let Some(panel) = current(cx) else {
return Ok(());
};
let prefix = panel.realm.prefix.clone();
for event in events(cx, change) {
insert(cx, &mut *ex, prefix.clone(), event).await?;
}
Ok(())
}
fn events(cx: &Cx, change: &PanelChange) -> Vec<AuditEvent> {
let on = |name: &str, record: &ChangedRecord| {
let event = AuditEvent::new(name)
.resource(change.slug())
.key(record.key(), record.url().map(str::to_owned));
record.fields().iter().fold(event, |event, field| {
event.change(
field.name(),
field.label().localize(cx),
field.before().map(str::to_owned),
field.after().map(str::to_owned),
)
})
};
let each = |name: &str, records: &[ChangedRecord]| {
records.iter().map(|record| on(name, record)).collect()
};
match change.change() {
Change::Create(record) => vec![on("create", record)],
Change::Update { after, .. } if after.fields().is_empty() => Vec::new(),
Change::Update { after, .. } => vec![on("update", after)],
Change::Delete(records) => each("delete", records),
Change::Restore(records) => each("restore", records),
Change::ForceDelete(records) => each("force_delete", records),
Change::Action { name, records } => each(&format!("action:{name}"), records),
Change::HeaderAction { name } => {
vec![AuditEvent::new(format!("header_action:{name}")).resource(change.slug())]
}
Change::Attach {
owner,
relation,
key,
} => vec![on("attach", owner).change(relation, relation, None, Some(key.clone()))],
Change::Detach {
owner,
relation,
keys,
} => vec![on("detach", owner).change(relation, relation, Some(keys.join(", ")), None)],
}
}
async fn insert(
cx: &Cx,
ex: &mut dyn Executor,
prefix: String,
event: AuditEvent,
) -> Result<AuditEntry> {
let actor = crate::auth::signed(cx).map(|signed| {
let user = signed.impersonator.as_ref().unwrap_or(&signed.user);
(user.user_id(), user.display_name().to_owned())
});
event
.write(ex, prefix, crate::tenant_id(cx), actor)
.await
.map_err(|error| {
crate::error::driver_failure(error, "The audit log is unavailable right now.")
})
}