233 lines
8.4 KiB
Rust
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)))
|
|
}
|
|
}
|