514 lines
19 KiB
Rust
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)
|
|
}
|
|
}
|