fix(workflows): authorize agent owners to trigger

Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz>
This commit is contained in:
Larry
2026-08-14 07:30:40 -04:00
parent 22ec8c8547
commit 05eda12ddd
2 changed files with 182 additions and 3 deletions
@@ -832,6 +832,23 @@ async fn handle_workflow_def(
})
}
async fn caller_controls_workflow(
state: &Arc<AppState>,
community_id: CommunityId,
workflow_owner: &[u8],
caller: &[u8],
) -> Result<bool, IngestError> {
if workflow_owner == caller {
return Ok(true);
}
state
.db
.is_agent_owner(community_id, workflow_owner, caller)
.await
.map_err(|e| IngestError::Internal(format!("error: workflow owner check: {e}")))
}
async fn handle_workflow_trigger(
tenant: &TenantContext,
state: &Arc<AppState>,
@@ -860,10 +877,11 @@ async fn handle_workflow_trigger(
.await
.map_err(|_| IngestError::Rejected("invalid: workflow not found".into()))?;
// 3. Manual triggers execute with the workflow owner's authority, so only
// the owner may start them. Channel membership alone is insufficient: a
// 3. Manual triggers execute with the workflow owner's authority. Permit
// that principal and, when the owner is a managed agent, its immutable
// NIP-OA human owner. Channel membership alone remains insufficient: a
// member could otherwise invoke another user's webhook or message actions.
if workflow.owner_pubkey != self_bytes {
if !caller_controls_workflow(state, community_id, &workflow.owner_pubkey, &self_bytes).await? {
return Err(IngestError::Rejected(
"forbidden: not authorized to trigger this workflow".into(),
));
@@ -0,0 +1,161 @@
//! End-to-end authorization coverage for a human operating an agent-owned workflow.
//!
//! Run against a local relay with:
//! `cargo test -p buzz-test-client --test e2e_workflow_agent_owner -- --ignored`
use buzz_sdk::nip_oa;
use buzz_test_client::BuzzTestClient;
use nostr::{EventBuilder, Keys, Kind, Tag};
fn relay_url() -> String {
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string())
}
fn workflow_yaml() -> String {
"name: Agent-owned workflow\n\
trigger:\n\
\x20 on: webhook\n\
steps:\n\
\x20 - id: notify\n\
\x20 action: send_message\n\
\x20 text: owner-triggered\n"
.to_string()
}
async fn connect_agent_with_owner(agent: &Keys, owner: &Keys) -> BuzzTestClient {
let tag_json =
nip_oa::compute_auth_tag(owner, &agent.public_key(), "").expect("compute NIP-OA auth tag");
let auth_tag = nip_oa::parse_auth_tag(&tag_json).expect("parse NIP-OA auth tag");
let mut client = BuzzTestClient::connect_unauthenticated(&relay_url())
.await
.expect("connect agent");
client
.authenticate_with_nip_oa(agent, &auth_tag)
.await
.expect("authenticate agent with NIP-OA");
client
}
#[tokio::test]
#[ignore]
async fn agent_owner_can_trigger_but_unrelated_user_cannot() {
let owner = Keys::generate();
let agent = Keys::generate();
let unrelated = Keys::generate();
let channel_id = uuid::Uuid::new_v4();
let workflow_id = uuid::Uuid::new_v4();
// Authenticating the agent materializes the immutable community-scoped
// agent→owner relationship used by workflow authorization.
let mut agent_client = connect_agent_with_owner(&agent, &owner).await;
let create_channel = EventBuilder::new(Kind::Custom(9007), "")
.tags(vec![
Tag::parse(["h", &channel_id.to_string()]).unwrap(),
Tag::parse(["name", "workflow-agent-owner-e2e"]).unwrap(),
Tag::parse(["channel_type", "stream"]).unwrap(),
Tag::parse(["visibility", "open"]).unwrap(),
])
.sign_with_keys(&agent)
.unwrap();
let ok = agent_client
.send_event(create_channel)
.await
.expect("create channel");
assert!(
ok.accepted,
"agent channel creation rejected: {}",
ok.message
);
let define = buzz_sdk::build_workflow_def(channel_id, workflow_id, &workflow_yaml())
.expect("build workflow definition")
.sign_with_keys(&agent)
.expect("sign workflow definition");
let ok = agent_client
.send_event(define)
.await
.expect("define workflow");
assert!(
ok.accepted,
"agent workflow definition rejected: {}",
ok.message
);
// SEC-006 boundary: channel membership alone must not grant workflow
// authority. Make the adversarial principal a real member before its
// trigger attempt below.
let add_unrelated = EventBuilder::new(Kind::Custom(9000), "")
.tags(vec![
Tag::parse(["h", &channel_id.to_string()]).unwrap(),
Tag::parse(["p", &unrelated.public_key().to_string()]).unwrap(),
])
.sign_with_keys(&agent)
.expect("sign add-member event");
let ok = agent_client
.send_event(add_unrelated)
.await
.expect("add unrelated user as channel member");
assert!(
ok.accepted,
"adding unrelated channel member rejected: {}",
ok.message
);
let agent_trigger = buzz_sdk::build_workflow_trigger(workflow_id)
.expect("build agent trigger")
.sign_with_keys(&agent)
.expect("sign agent trigger");
let ok = agent_client
.send_event(agent_trigger)
.await
.expect("send agent trigger");
assert!(
ok.accepted,
"agent trigger of its own workflow rejected: {}",
ok.message
);
let mut unrelated_client = BuzzTestClient::connect(&relay_url(), &unrelated)
.await
.expect("connect unrelated user");
let unrelated_trigger = buzz_sdk::build_workflow_trigger(workflow_id)
.expect("build unrelated trigger")
.sign_with_keys(&unrelated)
.expect("sign unrelated trigger");
let ok = unrelated_client
.send_event(unrelated_trigger)
.await
.expect("send unrelated trigger");
assert!(
!ok.accepted,
"unrelated user triggered an agent-owned workflow"
);
assert!(
ok.message
.contains("not authorized to trigger this workflow"),
"unexpected rejection: {}",
ok.message
);
let mut owner_client = BuzzTestClient::connect(&relay_url(), &owner)
.await
.expect("connect owner");
let owner_trigger = buzz_sdk::build_workflow_trigger(workflow_id)
.expect("build owner trigger")
.sign_with_keys(&owner)
.expect("sign owner trigger");
let ok = owner_client
.send_event(owner_trigger)
.await
.expect("send owner trigger");
assert!(
ok.accepted,
"owner trigger of agent-owned workflow rejected: {}",
ok.message
);
agent_client.disconnect().await.ok();
unrelated_client.disconnect().await.ok();
owner_client.disconnect().await.ok();
}