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

190 lines
7.7 KiB
Rust

//! Conversation-subtree grant ledger. Reservations use an immediate transaction.
use super::{
audit,
error::{Error, Result},
seal,
store::{Cred, now},
types::{Caller, Subject},
};
use rusqlite::{Connection, TransactionBehavior, params};
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Grant {
#[serde(default)]
pub id: String,
pub items: Vec<String>,
pub subject: Subject,
pub exp: Option<i64>,
pub max_uses: Option<u64>,
#[serde(default)]
pub uses: u64,
#[serde(default)]
pub revoked: bool,
pub validator: Option<String>,
pub note: Option<String>,
}
pub(crate) fn list(db: &Connection) -> Result<Vec<Grant>> {
let mut q=db.prepare("SELECT id,items,subject,exp,max_uses,uses,revoked,validator,note FROM grants ORDER BY rowid")?;
Ok(q.query_map([], read)?.collect::<rusqlite::Result<_>>()?)
}
fn read(r: &rusqlite::Row<'_>) -> rusqlite::Result<Grant> {
Ok(Grant {
id: r.get(0)?,
items: serde_json::from_str(&r.get::<_, String>(1)?)
.map_err(|_| rusqlite::Error::InvalidQuery)?,
subject: serde_json::from_str(&r.get::<_, String>(2)?)
.map_err(|_| rusqlite::Error::InvalidQuery)?,
exp: r.get(3)?,
max_uses: r
.get::<_, Option<i64>>(4)?
.map(u64::try_from)
.transpose()
.map_err(|_| rusqlite::Error::InvalidQuery)?,
uses: u64::try_from(r.get::<_, i64>(5)?).map_err(|_| rusqlite::Error::InvalidQuery)?,
revoked: r.get(6)?,
validator: r.get(7)?,
note: r.get(8)?,
})
}
fn active(db: &Connection, item: &str, caller: &Caller) -> Result<Vec<Grant>> {
let mut q = db.prepare("SELECT id,items,subject,exp,max_uses,uses,revoked,validator,note FROM grants WHERE revoked=0 AND (exp IS NULL OR exp>?1) AND (max_uses IS NULL OR uses<max_uses) AND EXISTS(SELECT 1 FROM json_each(items) WHERE value=?2) AND ((json_extract(subject,'$.kind')='program' AND json_extract(subject,'$.id')=?3) OR (json_extract(subject,'$.kind')='conversation' AND EXISTS(SELECT 1 FROM json_each(?4) WHERE value=json_extract(grants.subject,'$.id')))) ORDER BY rowid LIMIT 1")?;
Ok(q.query_map(params![now(), item, caller.program, serde_json::to_string(&caller.conversations)?], read)?.collect::<rusqlite::Result<_>>()?)
}
pub(crate) fn insert(
db: &Connection,
grant: &Grant,
caller: &Caller,
approver: Option<&str>,
) -> Result<()> {
validate(db, grant)?;
db.execute(
"INSERT INTO grants(id,items,subject,exp,max_uses,uses,revoked,validator,note) VALUES(?1,?2,?3,?4,?5,0,0,?6,?7)",
params![
grant.id,
serde_json::to_string(&grant.items)?,
serde_json::to_string(&grant.subject)?,
grant.exp,
grant.max_uses.map(|m| m as i64),
grant.validator,
grant.note
],
)?;
audit::record(
db,
"grant.create",
caller,
grant.items.first().map(String::as_str),
approver,
)
}
pub(crate) fn validate(db: &Connection, grant: &Grant) -> Result<()> {
if grant.max_uses == Some(0) || grant.max_uses.is_some_and(|m| m > i64::MAX as u64) {
return Err(Error("invalid_uses"));
}
if !grant.subject.valid() {
return Err(Error("invalid_subject"));
}
if grant.items.is_empty() {
return Err(Error("invalid_items"));
}
for item in &grant.items {
super::items::load(db, item)?;
}
Ok(())
}
impl Cred {
pub fn grant_create(
&self,
mut grant: Grant,
caller: &Caller,
proof: impl Into<super::webauthn::Proof>,
) -> Result<Grant> {
let binding = super::webauthn::Binding::mutation(super::webauthn::Mutation::GrantCreate, &grant)?;
grant.id = seal::identity();
grant.uses = 0;
grant.revoked = false;
grant.items.sort();
grant.items.dedup();
let mut s = self.access()?;
let tx =
s.db.transaction_with_behavior(TransactionBehavior::Immediate)?;
let approver = proof.into().check(&tx, &binding)?;
insert(&tx, &grant, caller, approver.as_deref())?;
tx.commit()?;
Ok(grant)
}
pub fn grant_list(&self) -> Result<Vec<Grant>> {
list(&self.access()?.db)
}
pub fn grant_status(&self, id: &str) -> Result<Grant> {
self.grant_list()?
.into_iter()
.find(|g| g.id == id)
.ok_or(Error("grant_missing"))
}
/// The harness supplies every conversation in the subtree, not only its root.
pub fn revoke_subjects(
&self,
subjects: &[String],
caller: &Caller,
proof: impl Into<super::webauthn::Proof>,
) -> Result<()> {
let mut s = self.access()?;
let tx = s.db.transaction_with_behavior(TransactionBehavior::Immediate)?;
let binding = super::webauthn::Binding::mutation(super::webauthn::Mutation::RevokeSubjects, &subjects)?;
let approver = proof.into().check(&tx, &binding)?;
for subject in subjects {
tx.execute(
"UPDATE grants SET revoked=1,revoked_at=COALESCE(revoked_at,unixepoch()) WHERE subject=?",
[serde_json::to_string(&Subject::Conversation(
subject.clone(),
))?],
)?;
}
audit::record(&tx, "grant.revoke", caller, None, approver.as_deref())?;
tx.commit()?;
Ok(())
}
/// Removing access needs no assertion, including after passkey registration.
pub fn revoke_grant(&self, id: &str, caller: &Caller) -> Result<()> {
let mut s = self.access()?;
let tx = s.db.transaction_with_behavior(TransactionBehavior::Immediate)?;
if tx.execute("UPDATE grants SET revoked=1,revoked_at=COALESCE(revoked_at,unixepoch()) WHERE id=?", [id])? != 1 {
return Err(Error("grant_missing"));
}
audit::record(&tx, "grant.revoke", caller, None, None)?;
tx.commit()?;
Ok(())
}
pub fn revoke_program(&self, program: &str, caller: &Caller) -> Result<()> {
let mut s = self.access()?;
let tx = s.db.transaction_with_behavior(TransactionBehavior::Immediate)?;
tx.execute("UPDATE grants SET revoked=1,revoked_at=COALESCE(revoked_at,unixepoch()) WHERE subject=?", [serde_json::to_string(&Subject::Program(program.into()))?])?;
audit::record(&tx, "grant.revoke", caller, None, None)?;
tx.commit()?;
Ok(())
}
pub(crate) fn reserve(
&self,
item: &super::items::Item,
caller: &Caller,
) -> Result<Option<(Option<Grant>, serde_json::Value, i64)>> {
let mut s = self.access()?;
let master = s.master.clone();
let tx =
s.db.transaction_with_behavior(TransactionBehavior::Immediate)?;
let mut grant = if item.free_use { None } else { active(&tx, &item.id, caller)?.into_iter().next() };
if !item.free_use && grant.is_none() { return Ok(None); }
if super::items::load(&tx, &item.id)?.version != item.version { return Err(Error("stale")); }
let credential = super::items::credential(&tx, &master, item, true)?;
if let Some(g) = &mut grant {
let changed=tx.execute("UPDATE grants SET uses=uses+1 WHERE id=?1 AND revoked=0 AND (exp IS NULL OR exp>?2) AND (max_uses IS NULL OR uses<max_uses)",params![g.id,now()])?;
if changed != 1 { return Err(Error("grant_exhausted")); }
g.uses += 1;
}
audit::record(&tx, "fetch.pending", caller, Some(&item.id), None)?;
let audit_id = tx.last_insert_rowid();
tx.commit()?;
Ok(Some((grant, credential, audit_id)))
}
}