//! Reviewable full-configuration edits. Approval rechecks versions in its transaction. //! Filing checks public values. Approval checks current secrets and preserves operation order. use super::{ audit, error::{Error, Result}, items::{self, Item}, seal, types::Caller, }; use rusqlite::{Connection, OptionalExtension, params}; /// Explicit public operations. No operation accepts a secret field value. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] #[serde(tag = "op", rename_all = "snake_case")] pub enum Operation { CreateItem { config: Item, }, GenerateEs256Item { config: Item, }, SetName { name: String, }, SetRules { rules: Vec, }, SetFreeUse { enabled: bool, }, SetValidateJs { value: Option, }, SetTransformJs { value: String, }, AddField { field: items::Field, }, SetFieldValue { field: String, value: String, }, SetFieldType { field: String, field_type: super::types::FieldType, key_type: Option, }, RenameField { field: String, name: String, }, RemoveField { field: String, }, DeleteItem, ItemRevert { version: i64, }, GrantCreate { grant: super::grants::Grant, }, GrantRevoke { target: String, }, } #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct OperationRequest { pub item: Option, pub base_version: Option, pub change: Vec, } #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct Edit { pub item: String, pub base_version: i64, pub config: Item, } #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct Diff { pub old: Item, pub new: Item, pub discards_values: bool, #[serde(default)] pub overlapping_items: Vec, #[serde(default)] pub stale: bool, } fn check_secret( db: &Connection, master: &[u8; 32], item: &str, field: &items::Field, ) -> Result<()> { let cipher: Option> = db .query_row( "SELECT ciphertext FROM fields WHERE item=?1 AND id=?2", params![item, field.field_id], |r| r.get(0), ) .optional()?; if let Some(cipher) = cipher { let value = seal::open(master, &format!("{item}|{}", field.field_id), &cipher)?; super::types::validate( &field.field_type, field.key_type.as_deref(), false, Some(std::str::from_utf8(&value).map_err(|_| Error("invalid_value"))?), )?; } Ok(()) } pub(crate) fn plan(db: &Connection, master: Option<&[u8; 32]>, edit: &Edit) -> Result { let old = items::load(db, &edit.item)?; if old.version != edit.base_version { return Err(Error("stale")); } let mut new = edit.config.clone(); new.id = old.id.clone(); new.version = old .version .checked_add(1) .ok_or(Error("version_exhausted"))?; items::validate(&new)?; items::unique_name(db, &new.name, &old.id)?; for f in &mut new.fields { if let Some(prior) = old.fields.iter().find(|p| p.field_id == f.field_id) { if prior.public != f.public { return Err(Error("immutable_visibility")); } if f.public { f.value = f.value.clone().or_else(|| prior.value.clone()); f.needs_value = f.value.is_none(); } else { f.needs_value = prior.needs_value; if let Some(master) = master && (f.field_type != prior.field_type || f.key_type != prior.key_type) { check_secret(db, master, &old.id, f)?; } } } else { if !f.field_id.is_empty() { return Err(Error("field_missing")); } f.needs_value = f.value.is_none(); } } items::validate(&new)?; let discards_values = old .fields .iter() .any(|f| !f.needs_value && !new.fields.iter().any(|n| n.field_id == f.field_id)); Ok(Diff { overlapping_items: items::overlaps(db, &new)?, old, new, discards_values, stale: false, }) } pub(crate) fn apply( db: &Connection, master: &[u8; 32], edit: &Edit, caller: &Caller, approver: Option<&str>, ) -> Result<()> { let mut diff = plan(db, Some(master), edit)?; db.execute( "INSERT INTO versions(item,created,config) VALUES(?1,unixepoch(),?2)", params![edit.item, serde_json::to_string(&diff.old)?], )?; for old in &diff.old.fields { if !diff.new.fields.iter().any(|f| f.field_id == old.field_id) { db.execute( "DELETE FROM fields WHERE item=?1 AND id=?2", params![edit.item, old.field_id], )?; } } for f in &mut diff.new.fields { if f.field_id.is_empty() { f.field_id = seal::identity(); } } items::save(db, &diff.new)?; audit::record(db, "item.edit", caller, Some(&edit.item), approver) } #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct Revert { pub item: String, pub base_version: i64, pub version: i64, } pub(crate) fn revert_revision(db: &Connection, item: &str) -> Result { if let Ok(item) = items::load(db, item) { return Ok(item.version); } let latest: Option = db.query_row( "SELECT max(json_extract(config,'$.version')) FROM versions WHERE item=?", [item], |r| r.get(0), )?; latest .ok_or(Error("item_deleted"))? .checked_add(1) .ok_or(Error("version_exhausted")) } pub(crate) fn plan_revert(db: &Connection, master: Option<&[u8; 32]>, r: &Revert) -> Result { if revert_revision(db, &r.item)? != r.base_version { return Err(Error("stale")); } let saved:String=db.query_row("SELECT config FROM versions WHERE item=?1 AND json_extract(config,'$.version')=?2 ORDER BY rowid DESC LIMIT 1",params![r.item,r.version],|row|row.get(0))?; let mut new: Item = serde_json::from_str(&saved)?; let current = items::load(db, &r.item).ok(); for f in &mut new.fields { let prior = current .as_ref() .and_then(|i| i.fields.iter().find(|p| p.field_id == f.field_id)); if let Some(prior) = prior { if prior.public != f.public { return Err(Error("immutable_visibility")); } f.needs_value = prior.needs_value; if f.public { f.value = prior.value.clone(); } else if let Some(master) = master { check_secret(db, master, &r.item, f)?; } } else { f.needs_value = f.value.is_none(); } } items::validate(&new)?; items::unique_name(db, &new.name, &new.id)?; new.version = r .base_version .checked_add(1) .ok_or(Error("version_exhausted"))?; let old = current.unwrap_or_else(|| { let mut old = new.clone(); old.version = r.base_version; old.fields.clear(); old }); let discards_values = old .fields .iter() .any(|f| !f.needs_value && !new.fields.iter().any(|n| n.field_id == f.field_id)); Ok(Diff { overlapping_items: items::overlaps(db, &new)?, old, new, discards_values, stale: false, }) } pub(crate) fn apply_revert( db: &Connection, master: &[u8; 32], r: &Revert, caller: &Caller, approver: Option<&str>, ) -> Result<()> { let diff = plan_revert(db, Some(master), r)?; if items::load(db, &r.item).is_ok() { db.execute( "INSERT INTO versions(item,created,config) VALUES(?1,unixepoch(),?2)", params![r.item, serde_json::to_string(&diff.old)?], )?; items::save(db, &diff.new)?; } else { db.execute( "INSERT INTO items VALUES(?1,?2,?3)", params![r.item, diff.new.name, serde_json::to_string(&diff.new)?], )?; } for f in &diff.old.fields { if !diff.new.fields.iter().any(|n| n.field_id == f.field_id) { db.execute( "DELETE FROM fields WHERE item=?1 AND id=?2", params![r.item, f.field_id], )?; } } audit::record(db, "item.revert", caller, Some(&r.item), approver) } #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct Delete { pub item: String, pub base_version: i64, } pub(crate) fn apply_delete( db: &Connection, delete: &Delete, caller: &Caller, approver: Option<&str>, ) -> Result<()> { let item = items::load(db, &delete.item)?; if item.version != delete.base_version { return Err(Error("stale")); } db.execute( "INSERT INTO versions(item,created,config) VALUES(?1,unixepoch(),?2)", params![item.id, serde_json::to_string(&item)?], )?; db.execute("DELETE FROM items WHERE id=?", [&item.id])?; db.execute("UPDATE grants SET revoked=1 WHERE EXISTS(SELECT 1 FROM json_each(grants.items) WHERE value=?)", [&item.id])?; audit::record(db, "item.delete", caller, Some(&item.id), approver) } pub(crate) fn apply_changes( mut config: Item, change: &[Operation], start: usize, secrets: Option<(&Connection, &[u8; 32])>, ) -> Result { for operation in &change[start..] { let field = |id: &str| { if id.is_empty() { return Err(Error("field_missing")); } config .fields .iter() .position(|f| f.field_id == id) .ok_or(Error("field_missing")) }; match operation { Operation::SetName { name } => config.name = name.clone(), Operation::SetRules { rules } => config.rules = rules.clone(), Operation::SetFreeUse { enabled } => config.free_use = *enabled, Operation::SetValidateJs { value } => config.validate_js = value.clone(), Operation::SetTransformJs { value } => config.transform_js = value.clone(), Operation::AddField { field } => { let mut field = field.clone(); if !field.field_id.is_empty() { return Err(Error("invalid_params")); } if !field.public && field.value.is_some() { return Err(Error("secret_field")); } super::types::validate( &field.field_type, field.key_type.as_deref(), field.public, field.value.as_deref(), )?; field.needs_value = field.value.is_none(); config.fields.push(field); } Operation::SetFieldValue { field: id, value } => { let n = field(id)?; if !config.fields[n].public { return Err(Error("secret_field")); } super::types::validate( &config.fields[n].field_type, config.fields[n].key_type.as_deref(), true, Some(value), )?; config.fields[n].value = Some(value.clone()); config.fields[n].needs_value = false; } Operation::SetFieldType { field: id, field_type, key_type, } => { let n = field(id)?; config.fields[n].field_type = field_type.clone(); config.fields[n].key_type = key_type.clone(); let f = &config.fields[n]; super::types::validate( &f.field_type, f.key_type.as_deref(), f.public, f.value.as_deref(), )?; if !f.public && let Some((db, master)) = secrets { check_secret(db, master, &config.id, f)?; } } Operation::RenameField { field: id, name } => { let n = field(id)?; config.fields[n].name = name.clone(); } Operation::RemoveField { field: id } => { let n = field(id)?; config.fields.remove(n); } _ => return Err(Error("invalid_params")), } } Ok(config) } impl super::Cred { pub fn file_operation_request( &self, caller: &Caller, key: &str, request: OperationRequest, ) -> Result { if request.change.is_empty() { return Err(Error("invalid_params")); } if request.change.len() == 1 { match &request.change[0] { Operation::DeleteItem => { return self.file_delete_request( caller, key, Delete { item: request.item.clone().ok_or(Error("invalid_params"))?, base_version: request.base_version.ok_or(Error("invalid_params"))?, }, ); } Operation::ItemRevert { version } => { return self.file_revert_request( caller, key, request.item.as_deref().ok_or(Error("invalid_params"))?, request.base_version.ok_or(Error("invalid_params"))?, *version, ); } Operation::GrantCreate { grant } => { let mut grant = grant.clone(); grant.id.clear(); grant.uses = 0; grant.revoked = false; grant.items.sort(); grant.items.dedup(); let payload = serde_json::json!({"grant_create":grant}); if let Some(id) = self.replay(caller, key, &payload)? { return Ok(id); } super::grants::validate(&self.access()?.db, &grant)?; return self.file_payload( caller, key, serde_json::json!({"grant_create":grant}), ); } Operation::GrantRevoke { target } => { self.grant_status(target)?; return self.file_payload( caller, key, serde_json::json!({"grant_revoke":target}), ); } _ => {} } } let intent = serde_json::json!({"requested":request}); if let Some(id) = self.replay(caller, key, &intent)? { return Ok(id); } let mut create = false; let mut generate = false; let config = if let Some(id) = &request.item { self.item_get(id)? } else { create = true; match &request.change[0] { Operation::CreateItem { config } => config.clone(), Operation::GenerateEs256Item { config } => { generate = true; config.clone() } _ => return Err(Error("invalid_params")), } }; if create { items::validate(&config)?; } let config = apply_changes(config, &request.change, usize::from(create), None)?; if create { if request.base_version.is_some() { return Err(Error("invalid_params")); } items::validate(&config)?; if generate && !config.fields.iter().any(|f| { !f.public && f.field_type == super::types::FieldType::SshPrivateKey && f.key_type.as_deref() == Some("es256") }) { return Err(Error("invalid_params")); } let overlapping_items = { let s = self.access()?; items::unique_name(&s.db, &config.name, "")?; let mut proposed = config.clone(); proposed.id.clear(); items::overlaps(&s.db, &proposed)? }; return self.file_payload( caller, key, serde_json::json!({"create":config,"generate_es256":generate,"requested":request,"overlapping_items":overlapping_items}), ); } let edit = Edit { item: request.item.clone().ok_or(Error("invalid_params"))?, base_version: request.base_version.ok_or(Error("invalid_params"))?, config, }; let diff = { let s = self.access()?; plan(&s.db, None, &edit)? }; self.file_payload( caller, key, serde_json::json!({"edit":edit,"diff":diff,"requested":request}), ) } pub fn file_delete_request( &self, caller: &Caller, key: &str, delete: Delete, ) -> Result { if let Some(id) = self.replay(caller, key, &serde_json::json!({"delete":delete}))? { return Ok(id); } let item = self.item_get(&delete.item)?; if item.version != delete.base_version { return Err(Error("stale")); } self.file_payload(caller, key, serde_json::json!({"delete":delete,"old":item})) } /// Revert public configuration. Removed secrets remain absent. pub fn file_revert_request( &self, caller: &Caller, key: &str, item: &str, base_version: i64, version: i64, ) -> Result { let revert = Revert { item: item.into(), base_version, version, }; if let Some(id) = self.replay(caller, key, &serde_json::json!({"revert":revert}))? { return Ok(id); } let diff = { let s = self.access()?; plan_revert(&s.db, None, &revert)? }; self.file_payload( caller, key, serde_json::json!({"revert":revert,"diff":diff}), ) } pub fn file_edit_request(&self, caller: &Caller, key: &str, edit: Edit) -> Result { if let Some(id) = self.replay(caller, key, &serde_json::json!({"edit":edit}))? { return Ok(id); } let diff = { let s = self.access()?; plan(&s.db, None, &edit)? }; self.file_payload(caller, key, serde_json::json!({"edit":edit,"diff":diff})) } /// Replace a pending edit's base version after the UI reviews the current item. pub fn resolve_stale( &self, id: &str, base_version: i64, caller: &Caller, proof: impl Into, ) -> Result<()> { let mut s = self.access()?; let tx = s.db.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; let binding = super::webauthn::Binding::mutation( super::webauthn::Mutation::ResolveStale, &serde_json::json!({"request_id":id,"base_version":base_version}), )?; let approver = proof.into().check(&tx, &binding)?; let (payload, state): (String, String) = tx.query_row("SELECT payload,state FROM requests WHERE id=?", [id], |r| { Ok((r.get(0)?, r.get(1)?)) })?; if state != "pending" { return Err(Error("already_decided")); } let mut payload: serde_json::Value = serde_json::from_str(&payload)?; if let Some(revert) = payload.get("revert") { let mut revert: Revert = serde_json::from_value(revert.clone())?; revert.base_version = base_version; let diff = plan_revert(&tx, None, &revert)?; payload["revert"] = serde_json::to_value(revert)?; payload["diff"] = serde_json::to_value(diff)?; } else { let mut edit: Edit = serde_json::from_value(payload.get("edit").ok_or(Error("not_edit"))?.clone())?; edit.base_version = base_version; if let Some(requested) = payload.get("requested") { let requested: OperationRequest = serde_json::from_value(requested.clone())?; edit.config = apply_changes(items::load(&tx, &edit.item)?, &requested.change, 0, None)?; } let diff = plan(&tx, None, &edit)?; payload["edit"] = serde_json::to_value(edit)?; payload["diff"] = serde_json::to_value(diff)?; } let encoded = serde_json::to_string(&payload)?; tx.execute( "UPDATE requests SET payload=?2 WHERE id=?1", params![id, encoded], )?; audit::record( &tx, "request.resolve_stale", caller, payload .get("edit") .or_else(|| payload.get("revert")) .and_then(|p| p.get("item")) .and_then(|v| v.as_str()), approver.as_deref(), )?; tx.execute( "UPDATE audit SET payload_sha256=?1 WHERE id=?2", params![ super::webauthn::digest(encoded.as_bytes()), tx.last_insert_rowid() ], )?; tx.commit()?; Ok(()) } pub fn edit_diff(&self, id: &str) -> Result { let payload = self.request_diff(id)?; let mut diff: Diff = serde_json::from_value(payload.get("diff").ok_or(Error("not_edit"))?.clone())?; if let Some(revert) = payload.get("revert") { let revert: Revert = serde_json::from_value(revert.clone())?; diff.stale = revert_revision(&self.access()?.db, &revert.item) .map_or(true, |v| v != revert.base_version); } else { let edit: Edit = serde_json::from_value(payload.get("edit").ok_or(Error("not_edit"))?.clone())?; diff.stale = self .item_get(&edit.item) .map_or(true, |i| i.version != edit.base_version); } Ok(diff) } }