//! Keyed grant requests, transactional decisions, explicit expiry and decision notifications. use super::{ audit, error::{Error, Result}, grants::{self, Grant}, seal, store::{Cred, now}, types::{Caller, Subject}, }; use rusqlite::{OptionalExtension, TransactionBehavior, params}; #[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)] pub struct GrantRequest { pub items: Vec, pub duration_secs: Option, pub max_uses: Option, pub validator: Option, } #[derive(Clone, Debug, serde::Serialize)] pub struct Request { pub id: String, pub subject: Subject, pub payload: serde_json::Value, pub state: String, pub expires: i64, pub pid: u32, pub program: String, pub decision: Option, pub approver: Option, } #[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] pub struct Overrides { #[serde(default)] pub unlimited: bool, pub duration_secs: Option, pub max_uses: Option, pub validator: Option, } fn normalised(payload: &serde_json::Value) -> serde_json::Value { let mut value = if let Some(requested) = payload.get("requested") { serde_json::json!({"requested":requested}) } else { payload.clone() }; if let Some(object) = value.as_object_mut() { object.remove("diff"); object.remove("old"); } fn sets(value: &mut serde_json::Value, ordered: bool) { match value { serde_json::Value::Object(object) => { for (key, value) in object { sets(value, key == "change" || key == "fields"); } } serde_json::Value::Array(values) => { for value in values.iter_mut() { sets(value, false); } if !ordered { values.sort_by_cached_key(|v| v.to_string()); values.dedup(); } } _ => {} } } sets(&mut value, false); value } impl Cred { pub(crate) fn replay( &self, caller: &Caller, key: &str, payload: &serde_json::Value, ) -> Result> { let s = self.access()?; let subject = serde_json::to_string(&Subject::requester(caller))?; let old:Option<(String,String,String,i64,Option)>=s.db.query_row("SELECT id,payload,state,expires,grant_id FROM requests WHERE subject=?1 AND key=?2 ORDER BY rowid DESC LIMIT 1",params![subject,key],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?))).optional()?; let Some((id, prior, state, expires, grant_id)) = old else { return Ok(None); }; if normalised(&serde_json::from_str(&prior)?) != normalised(payload) { return Err(Error("key_conflict")); } let usable = match grant_id { None => true, Some(id) => grants::list(&s.db)?.iter().any(|g| { g.id == id && !g.revoked && g.exp.is_none_or(|e| e > now()) && g.max_uses.is_none_or(|m| g.uses < m) }), }; Ok( (((state == "pending" || state == "denied") && expires > now()) || (state == "approved" && usable)) .then_some(id), ) } pub fn file_request( &self, caller: &Caller, key: &str, mut payload: GrantRequest, ) -> Result { if payload.items.is_empty() { return Err(Error("invalid_items")); } { let s = self.access()?; for item in &mut payload.items { *item = s.db.query_row( "SELECT id FROM items WHERE id=?1 OR name=?1 ORDER BY id=?1 DESC LIMIT 1", [&*item], |r| r.get(0), ) .optional()? .unwrap_or_else(|| item.clone()); } } payload.items.sort(); payload.items.dedup(); payload.duration_secs = Some(payload.duration_secs.unwrap_or(3600)); if payload.duration_secs.is_some_and(|d| d == 0 || d > 86400) { return Err(Error("invalid_duration")); } if payload.validator.as_deref() == Some("") { payload.validator = None; } let value = serde_json::to_value(&payload)?; if let Some(id) = self.replay(caller, key, &value)? { return Ok(id); } { let s = self.access()?; for item in &payload.items { super::items::load(&s.db, item)?; } } self.file_payload(caller, key, value) } pub(crate) fn file_payload( &self, caller: &Caller, key: &str, payload: serde_json::Value, ) -> Result { let mut s = self.access()?; let tx = s.db.transaction_with_behavior(TransactionBehavior::Immediate)?; let mut expired_id = None; let subject = Subject::requester(caller); let subject_json = serde_json::to_string(&subject)?; let encoded = serde_json::to_string(&payload)?; let old:Option<(String,String,String,i64,Option)>=tx.query_row("SELECT id,payload,state,expires,grant_id FROM requests WHERE subject IS ?1 AND key=?2 ORDER BY rowid DESC LIMIT 1",params![subject_json,key],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?))).optional()?; if let Some((id, prior, state, expires, grant_id)) = old { if normalised(&serde_json::from_str(&prior)?) != normalised(&payload) { return Err(Error("key_conflict")); } if (state == "pending" || state == "denied") && expires > now() { return Ok(id); } if state == "approved" && match grant_id { None => true, Some(grant_id) => grants::list(&tx)?.iter().any(|g| { g.id == grant_id && !g.revoked && g.exp.is_none_or(|e| e > now()) && g.max_uses.is_none_or(|m| g.uses < m) }), } { return Ok(id); } if state == "pending" { tx.execute("UPDATE requests SET state='expired' WHERE id=?", [&id])?; expired_id = Some(id); } } let id = seal::identity(); tx.execute("INSERT INTO requests(id,subject,key,payload,state,expires,pid,program) VALUES(?1,?2,?3,?4,'pending',?5,?6,?7)",params![id,subject_json,key,encoded,now()+86400,caller.pid,caller.program])?; tx.commit()?; if let Some(expired) = expired_id { let _ = self.decided.send(expired); } let _ = self.filed.send(id.clone()); Ok(id) } pub fn request_list(&self) -> Result> { let s = self.access()?; let mut q=s.db.prepare("SELECT id,subject,payload,state,expires,pid,decision,approver,program FROM requests ORDER BY rowid")?; let rows = q.query_map([], |r| { Ok(( r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?, r.get(7)?, r.get(8)?, )) })?; rows.map(|r| { let (id, subject, payload, state, expires, pid, decision, approver, program) = r?; Ok(Request { id, subject: serde_json::from_str(&subject)?, payload: serde_json::from_str(&payload)?, state, expires, pid, program, decision, approver, }) }) .collect() } pub fn request_diff(&self, id: &str) -> Result { self.request_list()? .into_iter() .find(|r| r.id == id) .map(|r| r.payload) .ok_or(Error("request_missing")) } pub fn request_decisions(&self) -> Result> { Ok(self .request_list()? .into_iter() .filter(|r| r.state != "pending") .collect()) } /// Build a binding from the exact stored payload, not a preview supplied by the world. pub fn decision_binding( &self, id: &str, decision: super::webauthn::Decision, overrides: Overrides, ) -> Result { let s = self.access()?; let payload: String = s.db.query_row("SELECT payload FROM requests WHERE id=?", [id], |r| { r.get(0) })?; Ok(super::webauthn::Binding::Decision { request_id: id.into(), decision, overrides, payload_sha256: super::webauthn::digest(payload.as_bytes()), }) } pub fn approve( &self, id: &str, decision: super::webauthn::Decision, overrides: Overrides, proof: impl Into, ) -> Result<()> { if decision != super::webauthn::Decision::Approve { return Err(Error("invalid_decision")); } self.decide(id, decision, Some(overrides), proof.into()) } pub fn deny( &self, id: &str, decision: super::webauthn::Decision, proof: impl Into, ) -> Result<()> { if decision != super::webauthn::Decision::Deny { return Err(Error("invalid_decision")); } self.decide(id, decision, None, proof.into()) } fn decide( &self, id: &str, decision: super::webauthn::Decision, overrides: Option, proof: super::webauthn::Proof, ) -> Result<()> { let mut s = self.access()?; let master = s.master.clone(); let tx = s.db.transaction_with_behavior(TransactionBehavior::Immediate)?; let (subject, payload, state, expires, pid, program): ( String, String, String, i64, u32, String, ) = tx.query_row( "SELECT subject,payload,state,expires,pid,program FROM requests WHERE id=?", [id], |r| { Ok(( r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, )) }, )?; let approver = proof.check( &tx, &super::webauthn::Binding::Decision { request_id: id.into(), decision, overrides: overrides.clone().unwrap_or_default(), payload_sha256: super::webauthn::digest(payload.as_bytes()), }, )?; let approver = approver.as_deref(); let subject: Subject = serde_json::from_str(&subject)?; if state == "expired" { return Err(Error("request_expired")); } if state != "pending" { return Err(Error("already_decided")); } if expires <= now() { tx.execute("UPDATE requests SET state='expired' WHERE id=?", [id])?; tx.commit()?; let _ = self.decided.send(id.to_owned()); return Err(Error("request_expired")); } let caller = Caller { pid, program, conversations: match &subject { Subject::Conversation(id) => vec![id.clone()], Subject::Program(_) => vec![], }, }; let mut issued_grant = None; let state = if let Some(o) = overrides { let parsed: serde_json::Value = serde_json::from_str(&payload)?; if parsed.get("items").is_none() && parsed.get("grant_create").is_none() && o != Overrides::default() { return Err(Error("ignored_overrides")); } if o.unlimited && o.max_uses.is_some() { return Err(Error("invalid_uses")); } if let Some(edit) = parsed.get("edit") { let mut edit: super::edits::Edit = serde_json::from_value(edit.clone())?; if let Some(requested) = parsed.get("requested") { let requested: super::edits::OperationRequest = serde_json::from_value(requested.clone())?; let current = super::items::load(&tx, &edit.item)?; if current.version != edit.base_version { return Err(Error("stale")); } edit.config = super::edits::apply_changes( current, &requested.change, 0, Some((&tx, &master)), )?; } super::edits::apply(&tx, &master, &edit, &caller, approver)?; } else if let Some(revert) = parsed.get("revert") { super::edits::apply_revert( &tx, &master, &serde_json::from_value(revert.clone())?, &caller, approver, )?; } else if let Some(create) = parsed.get("create") { let mut item = super::items::insert_new( &tx, serde_json::from_value(create.clone())?, &caller, approver, )?; if parsed.get("generate_es256").and_then(|v| v.as_bool()) == Some(true) { let private = super::sign::generate_es256().map_err(|_| Error("key_generation"))?; let index = item .fields .iter() .position(|f| { !f.public && f.field_type == super::types::FieldType::SshPrivateKey && f.key_type.as_deref() == Some("es256") }) .ok_or(Error("invalid_params"))?; let field = &item.fields[index].field_id; let cipher = seal::seal(&master, &format!("{}|{field}", item.id), private.as_bytes())?; tx.execute( "INSERT INTO fields VALUES(?1,?2,?3)", params![item.id, field, cipher], )?; item.fields[index].needs_value = false; super::items::save(&tx, &item)?; } } else if let Some(grant) = parsed.get("grant_create") { let mut grant: Grant = serde_json::from_value(grant.clone())?; grant.id = seal::identity(); grant.uses = 0; grant.revoked = false; if let Some(duration) = o.duration_secs { if duration == 0 { return Err(Error("invalid_duration")); } grant.exp = Some( now() .checked_add( i64::try_from(duration).map_err(|_| Error("invalid_duration"))?, ) .ok_or(Error("invalid_duration"))?, ); } else if grant.exp.is_none() { grant.exp = Some(now() + 3600); } grant.max_uses = if o.unlimited { None } else { o.max_uses.or(grant.max_uses) }; grant.validator = o.validator.or(grant.validator); grants::insert(&tx, &grant, &caller, approver)?; issued_grant = Some(grant.id); } else if let Some(target) = parsed.get("grant_revoke") { let target = target.as_str().ok_or(Error("invalid_params"))?; if tx.execute("UPDATE grants SET revoked=1,revoked_at=COALESCE(revoked_at,unixepoch()) WHERE id=?", [target])? != 1 { return Err(Error("grant_missing")); } audit::record(&tx, "grant.revoke", &caller, None, approver)?; } else if let Some(delete) = parsed.get("delete") { super::edits::apply_delete( &tx, &serde_json::from_value(delete.clone())?, &caller, approver, )?; } else { let p: GrantRequest = serde_json::from_value(parsed)?; let duration = o.duration_secs.or(p.duration_secs).unwrap_or(3600); if duration == 0 { return Err(Error("invalid_duration")); } let duration = i64::try_from(duration).map_err(|_| Error("invalid_duration"))?; let g = Grant { id: seal::identity(), items: p.items, subject, exp: Some( now() .checked_add(duration) .ok_or(Error("invalid_duration"))?, ), max_uses: if o.unlimited { None } else { o.max_uses.or(p.max_uses) }, uses: 0, revoked: false, validator: o.validator.or(p.validator), note: None, }; grants::insert(&tx, &g, &caller, approver)?; issued_grant = Some(g.id); } "approved" } else { "denied" }; tx.execute( "UPDATE requests SET state=?2,decision=?3,approver=?4,grant_id=?5,decided_at=unixepoch() WHERE id=?1", params![id, state, match decision { super::webauthn::Decision::Approve => "approve", super::webauthn::Decision::Deny => "deny" }, approver, issued_grant], )?; audit::record(&tx, state, &caller, None, approver)?; tx.commit()?; s.refresh_rules()?; let _ = self.decided.send(id.to_owned()); Ok(()) } pub fn expire_requests(&self) -> Result> { let mut s = self.access()?; let tx = s.db.transaction_with_behavior(TransactionBehavior::Immediate)?; let ids = { let mut q = tx.prepare("SELECT id FROM requests WHERE state='pending' AND expires<=?")?; q.query_map([now()], |r| r.get::<_, String>(0))? .collect::>>()? }; tx.execute( "UPDATE requests SET state='expired' WHERE state='pending' AND expires<=?", [now()], )?; tx.commit()?; for id in &ids { let _ = self.decided.send(id.clone()); } Ok(ids) } }