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

233 lines
8.4 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.
/// The harness supplies every conversation id in the terminated subtree.
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)>> {
self.prepare_use(item, caller, true, "fetch.pending")
}
/// Identity discovery checks access without spending a signing use.
pub(crate) fn prepare_use(
&self,
item: &super::items::Item,
caller: &Caller,
consume: bool,
event: &str,
) -> 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
&& consume
{
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, event, caller, Some(&item.id), None)?;
let audit_id = tx.last_insert_rowid();
tx.commit()?;
Ok(Some((grant, credential, audit_id)))
}
}