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

514 lines
19 KiB
Rust

//! 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<String>,
pub duration_secs: Option<u64>,
pub max_uses: Option<u64>,
pub validator: Option<String>,
}
#[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<String>,
pub approver: Option<String>,
}
#[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<u64>,
pub max_uses: Option<u64>,
pub validator: Option<String>,
}
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<Option<String>> {
let s = self.access()?;
let subject = serde_json::to_string(&Subject::requester(caller))?;
let old:Option<(String,String,String,i64,Option<String>)>=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<String> {
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<String> {
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<String>)>=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<Vec<Request>> {
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<serde_json::Value> {
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<Vec<Request>> {
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<super::webauthn::Binding> {
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<super::webauthn::Proof>,
) -> 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<super::webauthn::Proof>,
) -> 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<Overrides>,
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<Vec<String>> {
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::<rusqlite::Result<Vec<_>>>()?
};
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)
}
}