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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 10 additions & 1 deletion crates/agent/src/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -549,9 +549,18 @@ impl<N: Network> WillowToolRouter<N> {
}
"set_permission" => {
let p: SetPermissionParams = parse_args(&args)?;
let perm = willow_state::Permission::from_name(&p.permission).ok_or_else(|| {
ErrorData::invalid_params(
format!(
"unknown permission '{}'; valid: SyncProvider, ManageChannels, ManageRoles, SendMessages, CreateInvite",
p.permission
),
None,
)
})?;
match self
.client
.set_permission(&p.role_id, &p.permission, p.granted)
.set_permission(&p.role_id, perm, p.granted)
.await
{
Ok(()) => success_json(serde_json::json!({"success": true})),
Expand Down
3 changes: 1 addition & 2 deletions crates/client/src/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,11 +196,10 @@ impl<N: willow_network::Network> ClientHandle<N> {
pub async fn set_permission(
&self,
role_id: &str,
permission: &str,
permission: willow_state::Permission,
granted: bool,
) -> anyhow::Result<()> {
let role_id = role_id.to_string();
let permission = permission.to_string();
let event = self
.mutation_handle
.build_event(willow_state::EventKind::SetPermission {
Expand Down
5 changes: 4 additions & 1 deletion crates/client/src/views.rs
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,9 @@ pub struct RolesView {
pub struct RoleEntry {
pub id: String,
pub name: String,
/// Permission names (string form) in stable, deduplicated order.
/// Surfaced as strings so UI / accessor consumers stay format-stable
/// even as the underlying [`willow_state::Permission`] enum grows.
pub permissions: Vec<String>,
}

Expand Down Expand Up @@ -786,7 +789,7 @@ pub fn compute_roles_view(events: &Arc<willow_state::ServerState>) -> RolesView
.map(|role| RoleEntry {
id: role.id.clone(),
name: role.name.clone(),
permissions: role.permissions.iter().cloned().collect(),
permissions: role.permissions.iter().map(|p| format!("{p:?}")).collect(),
})
.collect();
roles.sort_by(|a, b| a.name.cmp(&b.name));
Expand Down
3 changes: 3 additions & 0 deletions crates/state/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,6 @@ iroh-base = { workspace = true, features = ["key"] }
serde = { workspace = true }
sha2 = "0.10"
willow-identity = { path = "../identity" }

[dev-dependencies]
serde_json = "1"
124 changes: 121 additions & 3 deletions crates/state/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
//! containing an [`EventKind`]. Events are content-addressed — their
//! identity is their SHA-256 hash.

use serde::{Deserialize, Serialize};
use serde::{Deserialize, Deserializer, Serialize};
use willow_identity::{EndpointId, Identity, Signature};

use crate::hash::EventHash;
Expand All @@ -17,7 +17,7 @@ use crate::hash::EventHash;
/// [`ProposedAction`] and the vote path. This structural separation makes
/// it impossible for any peer to grant admin via a direct
/// [`EventKind::GrantPermission`] event.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
pub enum Permission {
/// Can sync/provide full history to other peers.
SyncProvider,
Expand All @@ -30,6 +30,124 @@ pub enum Permission {
SendMessages,
/// Can create invites.
CreateInvite,
/// Sentinel for permission names that an unknown / future client
/// emitted. Reached only via the back-compat string-form deserialize
/// path (e.g. an MCP tool boundary or a legacy JSON snapshot).
/// `apply_event` treats this sentinel as a no-op so the event still
/// joins the DAG — preserving signatures + hash linkage — without
/// mutating any role's permission set.
///
/// Hidden from generated docs; never emitted by Willow itself.
#[doc(hidden)]
#[serde(skip)]
__UnknownLegacy,
}

impl Permission {
/// Try to parse a permission name from its string form.
///
/// Returns `None` for unknown names. Used by the agent MCP tool
/// boundary, which rejects unknown permissions before they enter
/// the DAG.
pub fn from_name(name: &str) -> Option<Self> {
match name {
"SyncProvider" => Some(Self::SyncProvider),
"ManageChannels" => Some(Self::ManageChannels),
"ManageRoles" => Some(Self::ManageRoles),
"SendMessages" => Some(Self::SendMessages),
"CreateInvite" => Some(Self::CreateInvite),
_ => None,
}
}
}

impl<'de> Deserialize<'de> for Permission {
/// Custom deserialize that accepts both the enum form (default for
/// any format — bincode emits a u32 discriminant, JSON emits the
/// variant name) and tolerates unknown variant names by mapping
/// them to the [`Permission::__UnknownLegacy`] sentinel.
///
/// This lets a peer running an older or rogue client that broadcast
/// an unrecognised permission name still have its event join the
/// DAG; `apply_event` then silently drops the unknown perm so the
/// role's permission set is never polluted.
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct PermissionVisitor;

impl<'de> serde::de::Visitor<'de> for PermissionVisitor {
type Value = Permission;

fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a Permission variant")
}

// String form (JSON, MCP boundary, legacy snapshots).
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Permission, E> {
Ok(Permission::from_name(v).unwrap_or_else(|| {
tracing::warn!(
permission = %v,
"unknown permission name; mapping to __UnknownLegacy",
);
Permission::__UnknownLegacy
}))
}

// Owned-string variant — same handling.
fn visit_string<E: serde::de::Error>(self, v: String) -> Result<Permission, E> {
self.visit_str(&v)
}

// Bincode encodes a unit-variant enum as the discriminant
// index via `deserialize_enum`, which routes through this
// method. Forward to the standard derived parser.
fn visit_enum<A>(self, data: A) -> Result<Permission, A::Error>
where
A: serde::de::EnumAccess<'de>,
{
use serde::de::VariantAccess;
#[derive(Deserialize)]
enum Tag {
SyncProvider,
ManageChannels,
ManageRoles,
SendMessages,
CreateInvite,
}
let (tag, variant) = data.variant::<Tag>()?;
variant.unit_variant()?;
Ok(match tag {
Tag::SyncProvider => Permission::SyncProvider,
Tag::ManageChannels => Permission::ManageChannels,
Tag::ManageRoles => Permission::ManageRoles,
Tag::SendMessages => Permission::SendMessages,
Tag::CreateInvite => Permission::CreateInvite,
})
}
}

if deserializer.is_human_readable() {
// JSON-style formats encode unit variants as strings; route
// through the visitor so unknown names hit the sentinel.
deserializer.deserialize_any(PermissionVisitor)
} else {
// Bincode-style formats encode unit variants as discriminant
// indices; route through the enum visitor.
deserializer.deserialize_enum(
"Permission",
&[
"SyncProvider",
"ManageChannels",
"ManageRoles",
"SendMessages",
"CreateInvite",
],
PermissionVisitor,
)
}
}
}

// ───── Governance types ────────────────────────────────────────────────────
Expand Down Expand Up @@ -125,7 +243,7 @@ pub enum EventKind {
/// Set or clear a permission on a role.
SetPermission {
role_id: String,
permission: String,
permission: Permission,
granted: bool,
},
/// Assign a role to a member.
Expand Down
13 changes: 12 additions & 1 deletion crates/state/src/materialize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -418,9 +418,20 @@ fn apply_mutation(state: &mut ServerState, event: &Event) -> ApplyResult {
permission,
granted,
} => {
// Drop the unknown-legacy sentinel produced by the
// back-compat deserialize path so a rogue / future client
// can never inject an unrecognised permission name into a
// role's permission set.
if matches!(permission, Permission::__UnknownLegacy) {
tracing::warn!(
role_id = %role_id,
"SetPermission with unknown legacy permission; dropping",
);
return ApplyResult::Applied;
}
if let Some(role) = state.roles.get_mut(role_id) {
if *granted {
role.permissions.insert(permission.clone());
role.permissions.insert(*permission);
} else {
role.permissions.remove(permission);
}
Expand Down
139 changes: 137 additions & 2 deletions crates/state/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,7 @@ fn set_permission_on_nonexistent_role_is_noop() {
&admin,
EventKind::SetPermission {
role_id: "nonexistent".to_string(),
permission: "SendMessages".to_string(),
permission: Permission::SendMessages,
granted: true,
},
);
Expand Down Expand Up @@ -856,7 +856,7 @@ fn has_permission_ignores_role_based_permissions() {
&admin,
EventKind::SetPermission {
role_id: "r-1".to_string(),
permission: "SendMessages".to_string(),
permission: Permission::SendMessages,
granted: true,
},
);
Expand Down Expand Up @@ -4376,3 +4376,138 @@ fn derive_ephemeral_state_bands() {
EphemeralState::Archived
);
}

// ───── SetPermission typed permission round-trip ─────────────────────────

/// Emit `SetPermission` with a typed `Permission` enum value, serialize
/// via the wire format (`willow-transport` = bincode), deserialize, apply
/// to a fresh DAG, and assert the role's permission set contains the
/// typed permission.
#[test]
fn set_permission_with_typed_permission_round_trips() {
let admin = Identity::generate();
let mut dag = test_dag(&admin);

do_emit(
&mut dag,
&admin,
EventKind::CreateRole {
name: "mod".into(),
role_id: "r-1".into(),
},
);
let set_event = do_emit(
&mut dag,
&admin,
EventKind::SetPermission {
role_id: "r-1".into(),
permission: Permission::ManageChannels,
granted: true,
},
);

// Wire-round-trip the event through bincode (the format used by
// `willow-transport` and the storage layer) and re-apply.
let bytes = bincode::serialize(&set_event).unwrap();
let decoded: Event = bincode::deserialize(&bytes).unwrap();
match &decoded.kind {
EventKind::SetPermission { permission, .. } => {
assert_eq!(*permission, Permission::ManageChannels);
}
other => panic!("expected SetPermission, got {other:?}"),
}

let state = materialize(&dag);
let role = state.roles.get("r-1").expect("role created");
assert!(role.permissions.contains(&Permission::ManageChannels));
}

/// Synthesize a JSON document carrying the legacy `permission: "<name>"`
/// string form (the shape MCP / agent boundary accepts) and assert the
/// custom deserializer maps it to the typed `Permission::ManageChannels`.
#[test]
fn set_permission_legacy_string_form_still_loads() {
let json = serde_json::json!({
"SetPermission": {
"role_id": "r-1",
"permission": "ManageChannels",
"granted": true,
}
});
let kind: EventKind = serde_json::from_value(json).expect("legacy string form must load");
match kind {
EventKind::SetPermission { permission, .. } => {
assert_eq!(permission, Permission::ManageChannels);
}
other => panic!("expected SetPermission, got {other:?}"),
}
}

/// Unknown legacy permission strings deserialize successfully (so the
/// event still enters the DAG and the chain is not broken) but apply as
/// a no-op — the unknown name is dropped.
#[test]
fn set_permission_legacy_unknown_string_drops_silently() {
let json = serde_json::json!({
"SetPermission": {
"role_id": "r-1",
"permission": "FrobnicateWidgets",
"granted": true,
}
});
let kind: EventKind =
serde_json::from_value(json).expect("unknown legacy string must deserialize, not fail");
match kind {
EventKind::SetPermission { permission, .. } => {
// Unknown name is mapped to the sentinel that apply_event drops.
assert_eq!(permission, Permission::__UnknownLegacy);
}
other => panic!("expected SetPermission, got {other:?}"),
}

// Apply path: synthesize the post-deserialize event in memory (the
// sentinel never crosses the wire — it only exists after a custom
// deserialize from an unrecognised string form). Bypass `do_emit`
// (which signs + bincodes the kind) and feed the event directly to
// `apply_incremental`, mirroring what would happen if a JSON
// snapshot containing the unknown name were replayed into state.
use crate::materialize::apply_incremental;

let admin = Identity::generate();
let mut dag = test_dag(&admin);
do_emit(
&mut dag,
&admin,
EventKind::CreateRole {
name: "mod".into(),
role_id: "r-1".into(),
},
);
let mut state = materialize(&dag);

// Fabricate an event whose kind carries the sentinel; we reuse a
// valid hash from the genesis chain since `apply_event` does not
// re-verify signatures and we only care about the apply branch.
let signable = EventKind::SetPermission {
role_id: "r-1".into(),
permission: Permission::__UnknownLegacy,
granted: true,
};
let synthetic = Event {
hash: EventHash::from_bytes(b"synthetic-unknown-legacy"),
author: admin.endpoint_id(),
seq: 99,
prev: EventHash::ZERO,
deps: vec![],
kind: signable,
sig: willow_identity::Signature::from_bytes(&[0u8; 64]),
timestamp_hint_ms: 0,
};
let _ = apply_incremental(&mut state, &synthetic);

let role = state.roles.get("r-1").expect("role created");
assert!(
role.permissions.is_empty(),
"unknown legacy permission must apply as a no-op"
);
}
Loading