diff --git a/src/storage.rs b/src/storage.rs index 55917c1f424f7036bb8724ddb453c28918bae839..6eac40cadda57e103591b410b6c52513c2d8c16c 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -962,6 +962,10 @@ impl Clusterwide { } /// Perform the `dml` operation on the local storage. + /// When possible, return the new tuple produced by the operation: + /// * `Some(tuple)` in case of insert and replace; + /// * `Some(tuple)` or `None` depending on update's result (it may be NOP); + /// * `None` in case of delete (because the tuple is gone). #[inline] pub fn do_dml(&self, dml: &Dml) -> tarantool::Result<Option<Tuple>> { let space = space_by_id_unchecked(dml.space()); @@ -969,7 +973,7 @@ impl Clusterwide { Dml::Insert { tuple, .. } => space.insert(tuple).map(Some), Dml::Replace { tuple, .. } => space.replace(tuple).map(Some), Dml::Update { key, ops, .. } => space.update(key, ops), - Dml::Delete { key, .. } => space.delete(key), + Dml::Delete { key, .. } => space.delete(key).map(|_| None), } } } @@ -1170,6 +1174,22 @@ impl PropertyName { Ok(()) } + + /// Try decoding property's value specifically for audit log. + /// Returns `Some(string)` if it should be logged, `None` otherwise. + pub fn should_be_audited(&self, tuple: &Tuple) -> Result<Option<String>> { + let bad_value = || Error::other("bad value"); + Ok(match self { + Self::PasswordMinLength + | Self::MaxLoginAttempts + | Self::SnapshotChunkMaxSize + | Self::MaxPgPortals => { + let v = tuple.field::<usize>(1)?.ok_or_else(bad_value)?; + Some(format!("{v}")) + } + _ => None, + }) + } } //////////////////////////////////////////////////////////////////////////////// @@ -1225,7 +1245,8 @@ impl Properties { // Not a builtin property. // Cannot be a wrong type error, because tarantool checks // the format for us. - if old.is_none() { // Insert + if old.is_none() { + // Insert // FIXME: this is currently printed twice tlog!(Warning, "non builtin property inserted into _pico_property, this may be an error in a future version of picodata"); } diff --git a/src/traft/node.rs b/src/traft/node.rs index a845747d1cbdc9a054c29ea46da10eaabfc5dffc..f0e46c53a215e300193900e6cab4341cde73e40e 100644 --- a/src/traft/node.rs +++ b/src/traft/node.rs @@ -17,6 +17,7 @@ use crate::schema::{Distribution, IndexDef, TableDef}; use crate::sentinel; use crate::storage::acl; use crate::storage::ddl_meta_drop_space; +use crate::storage::space_by_id; use crate::storage::SnapshotData; use crate::storage::{ddl_abort_on_master, ddl_meta_space_update_operable}; use crate::storage::{local_schema_version, set_local_schema_version}; @@ -60,7 +61,7 @@ use ::tarantool::space::FieldType as SFT; use ::tarantool::time::Instant; use ::tarantool::tlua; use ::tarantool::transaction::transaction; -use ::tarantool::tuple::Decode; +use ::tarantool::tuple::{Decode, Tuple}; use ::tarantool::vclock::Vclock; use protobuf::Message as _; @@ -728,32 +729,101 @@ impl NodeImpl { match op { Op::Nop => {} Op::Dml(op) => { - let res = self.storage.do_dml(&op); + let space = op.space().try_into(); + + // In order to implement the audit log events, we have to compare + // tuples from certain system spaces before and after a DML operation. + // + // In theory, we could move this code to `do_dml`, but in practice we + // cannot do that just yet, because we aren't allowed to universally call + // `extract_key` for arbitrary tuple/space pairs due to insufficient validity + // checks on its side -- it might just crash for user input. + // + // Remeber, we're not the only ones using CaS; users may call it for their + // own spaces, thus emitting unrestricted (and unsafe) DML records. + // + // TODO: merge this into `do_dml` once `box_tuple_extract_key` is fixed. + let old = match space { + Ok(s @ (ClusterwideTable::Property | ClusterwideTable::Instance)) => { + let s = space_by_id(s.id()).expect("system space must exist"); + match &op { + // There may be no previous version for inserts. + Dml::Insert { .. } => Ok(None), + Dml::Update { key, .. } => s.get(key), + Dml::Delete { key, .. } => s.get(key), + Dml::Replace { tuple, .. } => { + let key = s.primary_key().extract_key(Tuple::from(tuple)); + s.get(&key) + } + } + } + _ => Ok(None), + }; + + // Perform DML and combine both tuple versions into a pair. + let res = old.and_then(|old| { + let new = self.storage.do_dml(&op)?; + Ok((old, new)) + }); + match &res { Err(e) => { tlog!(Error, "clusterwide dml failed: {e}"); } - Ok(Some(tuple)) - if op.space() == ClusterwideTable::Instance.id() - // TODO: do we need to log something into the audit when deleting instances? - && !matches!(op, Dml::Delete { .. }) => - { + // Handle delete from _pico_property + Ok((Some(old), None)) if space == Ok(ClusterwideTable::Property) => { + assert!(matches!(op, Dml::Delete { .. })); + + if let Ok(Some(key)) = old.field::<PropertyName>(0) { + crate::audit!( + message: "property `{key}` was deleted", + title: "change_config", + severity: High, + ); + } + } + // Handle insert, replace, update in _pico_property + Ok((_old, Some(new))) if space == Ok(ClusterwideTable::Property) => { + // Dml::Delete mandates that new tuple is None. + assert!(!matches!(op, Dml::Delete { .. })); + + // Keep in mind that currently we allow invalid names, + // which means that we have to ignore the decoding error. + let key = new.field::<PropertyName>(0).ok().flatten(); + + // Try decoding the value iff the name's known. + let value = key + .map(|p| p.should_be_audited(new)) + .transpose() + .expect("property value decoding should not fail") + .flatten(); + + if let Some((key, value)) = key.zip(value) { + crate::audit!( + message: "property `{key}` was changed to {value}", + title: "change_config", + severity: High, + ); + } + } + // Handle insert, replace, update in _pico_instance + Ok((old, Some(new))) if space == Ok(ClusterwideTable::Instance) => { + // Dml::Delete mandates that new tuple is None. + assert!(!matches!(op, Dml::Delete { .. })); + + let old: Option<Instance> = + old.as_ref().map(|x| x.decode().expect("must be Instance")); + // FIXME: we do this prematurely, because the // transaction may still be rolled back for some reason. - let new: Instance = tuple + let new: Instance = new .decode() .expect("tuple already passed format verification"); - if has_grades!(new, Expelled -> *) && new.raft_id == self.raft_id() { - // cannot exit during a transaction - *expelled = true; - } - // Check if we're handling a "new node joined" event: // * Either there's no tuple for this node in the storage; // * Or its raft id has changed, meaning it's no longer the same node. - let prev = self.storage.instances.get(&new.instance_id).ok(); - if prev.as_ref().map(|x| x.raft_id) != Some(new.raft_id) { + if old.as_ref().map(|x| x.raft_id) != Some(new.raft_id) { let instance_id = &new.instance_id; crate::audit!( message: "a new instance `{instance_id}` joined the cluster", @@ -764,7 +834,7 @@ impl NodeImpl { ); } - if prev.as_ref().map(|x| x.current_grade) != Some(new.current_grade) { + if old.as_ref().map(|x| x.current_grade) != Some(new.current_grade) { let instance_id = &new.instance_id; let grade = &new.current_grade; crate::audit!( @@ -772,11 +842,12 @@ impl NodeImpl { title: "change_current_grade", severity: Medium, instance_id: %instance_id, + raft_id: %new.raft_id, new_grade: %grade, ); } - if prev.as_ref().map(|x| x.target_grade) != Some(new.target_grade) { + if old.as_ref().map(|x| x.target_grade) != Some(new.target_grade) { let instance_id = &new.instance_id; let grade = &new.target_grade; crate::audit!( @@ -785,8 +856,25 @@ impl NodeImpl { severity: Low, instance_id: %instance_id, raft_id: %new.raft_id, + new_grade: %grade, ); } + + if has_grades!(new, Expelled -> *) { + let instance_id = &new.instance_id; + crate::audit!( + message: "instance `{instance_id}` was expelled from the cluster", + title: "expel_instance", + severity: Low, + instance_id: %instance_id, + raft_id: %new.raft_id, + ); + + if new.raft_id == self.raft_id() { + // cannot exit during a transaction + *expelled = true; + } + } } Ok(_) => {} } diff --git a/src/traft/op.rs b/src/traft/op.rs index 2f03e89ae0164672cdb4b3e29e43be1cf9e27400..3cbaf2972d45066c833db03bd392c10d2dc55e41 100644 --- a/src/traft/op.rs +++ b/src/traft/op.rs @@ -339,7 +339,7 @@ pub enum Dml { // TODO: remove this impl OpResult for Dml { - type Result = tarantool::Result<Option<Tuple>>; + type Result = tarantool::Result<(Option<Tuple>, Option<Tuple>)>; fn result(&self) -> Self::Result { unreachable!() } diff --git a/test/int/test_audit.py b/test/int/test_audit.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391