Files
sausage/host/src/cred/edits.rs
T

663 lines
22 KiB
Rust

//! 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<items::Rule>,
},
SetFreeUse {
enabled: bool,
},
SetValidateJs {
value: Option<String>,
},
SetTransformJs {
value: String,
},
AddField {
field: items::Field,
},
SetFieldValue {
field: String,
value: String,
},
SetFieldType {
field: String,
field_type: super::types::FieldType,
key_type: Option<String>,
},
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<String>,
pub base_version: Option<i64>,
pub change: Vec<Operation>,
}
#[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<String>,
#[serde(default)]
pub stale: bool,
}
fn check_secret(
db: &Connection,
master: &[u8; 32],
item: &str,
field: &items::Field,
) -> Result<()> {
let cipher: Option<Vec<u8>> = 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<Diff> {
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<i64> {
if let Ok(item) = items::load(db, item) {
return Ok(item.version);
}
let latest: Option<i64> = 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<Diff> {
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<Item> {
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<String> {
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<String> {
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<String> {
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<String> {
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<super::webauthn::Proof>,
) -> 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<Diff> {
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)
}
}