Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 111 additions & 41 deletions crates/volt-core/src/plugin_grant_registry.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
use std::collections::{HashMap, HashSet};
use std::collections::HashMap;
use std::sync::Mutex;

use thiserror::Error;

use crate::grant_store;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GrantDelegation {
pub grant_id: String,
pub plugin_id: String,
pub delegated_at: u64,
pub revoked: bool,
}

#[derive(Debug, Error, PartialEq, Eq)]
pub enum PluginGrantError {
#[error("PLUGIN_GRANT_INVALID: grant ID does not exist")]
Expand All @@ -13,11 +21,12 @@ pub enum PluginGrantError {
AlreadyDelegated,
}

static PLUGIN_GRANTS: Mutex<Option<HashMap<String, HashSet<String>>>> = Mutex::new(None);
static PLUGIN_GRANTS: Mutex<Option<HashMap<String, HashMap<String, GrantDelegation>>>> =
Mutex::new(None);

fn with_store<F, R>(f: F) -> R
where
F: FnOnce(&mut HashMap<String, HashSet<String>>) -> R,
F: FnOnce(&mut HashMap<String, HashMap<String, GrantDelegation>>) -> R,
{
let mut guard = PLUGIN_GRANTS
.lock()
Expand All @@ -26,58 +35,86 @@ where
f(store)
}

fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}

pub fn delegate_grant(plugin_id: &str, grant_id: &str) -> Result<(), PluginGrantError> {
if grant_store::resolve_grant(grant_id).is_err() {
return Err(PluginGrantError::InvalidGrant);
}

with_store(|store| {
let grants = store.entry(plugin_id.to_string()).or_default();
if !grants.insert(grant_id.to_string()) {
return Err(PluginGrantError::AlreadyDelegated);
let delegations = store.entry(plugin_id.to_string()).or_default();
match delegations.get_mut(grant_id) {
Some(delegation) if !delegation.revoked => Err(PluginGrantError::AlreadyDelegated),
Some(delegation) => {
delegation.delegated_at = now_ms();
delegation.revoked = false;
Ok(())
}
None => {
delegations.insert(
grant_id.to_string(),
GrantDelegation {
grant_id: grant_id.to_string(),
plugin_id: plugin_id.to_string(),
delegated_at: now_ms(),
revoked: false,
},
);
Ok(())
}
}
Ok(())
})
}

pub fn revoke_grant(plugin_id: &str, grant_id: &str) {
with_store(|store| {
if let Some(delegations) = store.get_mut(plugin_id)
&& let Some(delegation) = delegations.get_mut(grant_id)
{
delegation.revoked = true;
}
});
}

pub fn revoke_all_grants(plugin_id: &str) {
with_store(|store| {
if let Some(delegations) = store.get_mut(plugin_id) {
for delegation in delegations.values_mut() {
delegation.revoked = true;
}
}
});
}

pub fn is_delegated(plugin_id: &str, grant_id: &str) -> bool {
with_store(|store| {
store
.get(plugin_id)
.is_some_and(|grants| grants.contains(grant_id))
.and_then(|delegations| delegations.get(grant_id))
.is_some_and(|delegation| !delegation.revoked)
})
}

pub fn delegated_grants(plugin_id: &str) -> Vec<String> {
pub fn list_delegated_grants(plugin_id: &str) -> Vec<String> {
with_store(|store| {
let mut grants = store
let mut grant_ids = store
.get(plugin_id)
.cloned()
.unwrap_or_default()
.into_iter()
.flat_map(|delegations| delegations.values())
.filter(|delegation| !delegation.revoked)
.map(|delegation| delegation.grant_id.clone())
.collect::<Vec<_>>();
grants.sort();
grants
grant_ids.sort();
grant_ids
})
}

pub fn revoke_grant(plugin_id: &str, grant_id: &str) {
with_store(|store| {
if let Some(grants) = store.get_mut(plugin_id) {
grants.remove(grant_id);
if grants.is_empty() {
store.remove(plugin_id);
}
}
});
}

pub fn revoke_all(plugin_id: &str) {
with_store(|store| {
store.remove(plugin_id);
});
}

pub fn clear_delegations() {
with_store(|store| store.clear());
}
Expand All @@ -89,38 +126,71 @@ mod tests {
use super::*;
use crate::test_support::lock_grant_state;

fn create_grant() -> String {
let path = std::env::temp_dir().join(format!("volt-plugin-grants-{}", std::process::id()));
std::fs::create_dir_all(&path).expect("temp dir");
grant_store::create_grant(PathBuf::from(&path)).expect("grant")
}

#[test]
fn delegate_grant_requires_existing_grant() {
fn delegate_requires_existing_grant() {
let _guard = lock_grant_state();
let error = delegate_grant("acme.search", "missing").expect_err("invalid grant");
assert_eq!(error, PluginGrantError::InvalidGrant);
}

#[test]
fn delegate_grant_tracks_plugin_ownership() {
fn delegate_and_list_track_active_grants() {
let _guard = lock_grant_state();
let grant_id = grant_store::create_grant(std::env::temp_dir()).expect("grant");
let grant_id = create_grant();

delegate_grant("acme.search", &grant_id).expect("delegate");

assert!(is_delegated("acme.search", &grant_id));
assert_eq!(delegated_grants("acme.search"), vec![grant_id.clone()]);
assert!(!is_delegated("beta.index", &grant_id));
assert_eq!(list_delegated_grants("acme.search"), vec![grant_id]);
}

#[test]
fn duplicate_delegation_is_rejected() {
fn duplicate_delegate_is_rejected_until_revoked() {
let _guard = lock_grant_state();
let temp = std::env::temp_dir().join("volt-plugin-grants");
std::fs::create_dir_all(&temp).expect("temp");
let grant_id = grant_store::create_grant(PathBuf::from(&temp)).expect("grant");
let grant_id = create_grant();

delegate_grant("acme.search", &grant_id).expect("delegate");
let error = delegate_grant("acme.search", &grant_id).expect_err("duplicate");
assert_eq!(error, PluginGrantError::AlreadyDelegated);

revoke_grant("acme.search", &grant_id);
delegate_grant("acme.search", &grant_id).expect("re-delegate");
assert!(is_delegated("acme.search", &grant_id));
}

#[test]
fn revoke_marks_delegation_inactive_idempotently() {
let _guard = lock_grant_state();
let grant_id = create_grant();
delegate_grant("acme.search", &grant_id).expect("delegate");

revoke_grant("acme.search", &grant_id);
revoke_grant("acme.search", &grant_id);

assert!(!is_delegated("acme.search", &grant_id));
assert!(list_delegated_grants("acme.search").is_empty());
assert!(grant_store::resolve_grant(&grant_id).is_ok());
}

#[test]
fn revoke_all_marks_all_plugin_grants_inactive() {
let _guard = lock_grant_state();
let first = create_grant();
let second = create_grant();
delegate_grant("acme.search", &first).expect("delegate first");
delegate_grant("acme.search", &second).expect("delegate second");

revoke_all_grants("acme.search");

let _ = std::fs::remove_dir_all(temp);
assert!(!is_delegated("acme.search", &first));
assert!(!is_delegated("acme.search", &second));
assert!(grant_store::resolve_grant(&first).is_ok());
assert!(grant_store::resolve_grant(&second).is_ok());
}
}
8 changes: 8 additions & 0 deletions crates/volt-plugin-host/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,14 @@ impl PluginEngine {
let _ = self.call_async_global("__volt_plugin_dispatch_event__", &args)?;
Ok(false)
}
(MessageType::Event, "plugin:grant-revoked") => {
let payload = message.payload.unwrap_or(serde_json::Value::Null);
let args = [JsValue::from(js_string!(
required_string(&payload, "grantId")?.as_str()
))];
let _ = self.call_async_global("__volt_plugin_revoke_grant__", &args)?;
Ok(false)
}
(MessageType::Response, _)
| (MessageType::Event, _)
| (MessageType::Signal, "cancel") => Ok(false),
Expand Down
Loading
Loading