Authorize delegated Project changes

Signed-off-by: John Tennant <jtennant@squareup.com>
This commit is contained in:
John Tennant
2026-09-01 12:51:23 -04:00
parent f0e28714ad
commit a1f60b0c5d
3 changed files with 282 additions and 21 deletions

View File

@@ -19,6 +19,8 @@ const RELATED_CHANNEL_CAP: usize = 64;
pub struct ProjectRelatedChannelChange<'a> {
/// Owner pubkey from the canonical kind:30621 coordinate.
pub project_owner: &'a [u8],
/// Owner currently attested by the actor, after event-level verification.
pub delegated_owner: Option<&'a [u8]>,
/// Verbatim `d` value from the canonical kind:30621 coordinate.
pub project_d_tag: &'a str,
/// Relational revision the actor observed.
@@ -120,6 +122,12 @@ fn validate_patch(change: ProjectRelatedChannelChange<'_>) -> Option<String> {
if change.project_owner.len() != 32 {
return Some("Project owner must be 32 bytes".into());
}
if change
.delegated_owner
.is_some_and(|owner| owner.len() != 32)
{
return Some("delegated Project owner must be 32 bytes".into());
}
if change.project_d_tag.is_empty() || change.project_d_tag.len() > crate::event::D_TAG_MAX_LEN {
return Some("Project d tag is empty or too long".into());
}
@@ -200,6 +208,25 @@ impl Db {
});
}
let actor = event.pubkey.to_bytes();
let delegated_owner = if let Some(owner) = change.delegated_owner {
let registered: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM users WHERE community_id=$1 AND pubkey=$2 \
AND agent_owner_pubkey=$3)",
)
.bind(community_id.as_uuid())
.bind(actor.as_slice())
.bind(owner)
.fetch_one(&mut *tx)
.await?;
if !registered {
return Ok(ProjectChangeApplyResult::Forbidden);
}
Some(owner)
} else {
None
};
let head = sqlx::query(
"SELECT revision, deleted, identity_event_id FROM project_state_heads \
WHERE community_id=$1 AND project_owner=$2 AND project_d_tag=$3 FOR UPDATE",
@@ -269,8 +296,9 @@ impl Db {
1
};
let actor = event.pubkey.to_bytes();
if actor.as_slice() != change.project_owner {
let actor_owns_project = actor.as_slice() == change.project_owner;
let delegate_owns_project = delegated_owner == Some(change.project_owner);
if !actor_owns_project && !delegate_owns_project {
let Some(home_channel) = home_channel else {
return Ok(ProjectChangeApplyResult::Forbidden);
};
@@ -280,20 +308,23 @@ impl Db {
home_channel,
)
.await?;
let role: Option<String> = sqlx::query_scalar(
let authorized_role: Option<String> = sqlx::query_scalar(
"SELECT member.role::text FROM channel_members member \
JOIN channels channel ON channel.community_id=member.community_id \
AND channel.id=member.channel_id \
WHERE member.community_id=$1 AND member.channel_id=$2 AND member.pubkey=$3 \
WHERE member.community_id=$1 AND member.channel_id=$2 \
AND (member.pubkey=$3 OR member.pubkey=$4) \
AND member.role IN ('owner', 'admin') \
AND member.removed_at IS NULL AND channel.archived_at IS NULL \
AND channel.deleted_at IS NULL FOR SHARE OF channel, member",
AND channel.deleted_at IS NULL LIMIT 1 FOR SHARE OF channel, member",
)
.bind(community_id.as_uuid())
.bind(home_channel)
.bind(actor.as_slice())
.bind(delegated_owner)
.fetch_optional(&mut *tx)
.await?;
if !matches!(role.as_deref(), Some("owner" | "admin")) {
if authorized_role.is_none() {
return Ok(ProjectChangeApplyResult::Forbidden);
}
}
@@ -460,6 +491,27 @@ mod tests {
let owner = Keys::generate();
let admin = Keys::generate();
let member = Keys::generate();
let owner_agent = Keys::generate();
let admin_agent = Keys::generate();
for principal in [&owner, &admin, &owner_agent, &admin_agent] {
db.ensure_user(community, principal.public_key().to_bytes().as_slice())
.await
.expect("ensure authorization principal");
}
db.set_agent_owner(
community,
owner_agent.public_key().to_bytes().as_slice(),
owner.public_key().to_bytes().as_slice(),
)
.await
.expect("register Project owner's agent");
db.set_agent_owner(
community,
admin_agent.public_key().to_bytes().as_slice(),
admin.public_key().to_bytes().as_slice(),
)
.await
.expect("register home admin's agent");
let home = Uuid::new_v4();
crate::channel::create_channel_with_id(
&pool,
@@ -510,6 +562,7 @@ mod tests {
&first,
ProjectRelatedChannelChange {
project_owner: owner_bytes.as_slice(),
delegated_owner: None,
project_d_tag: "shared",
expected_revision: 1,
add: &[added],
@@ -532,6 +585,7 @@ mod tests {
&later,
ProjectRelatedChannelChange {
project_owner: owner_bytes.as_slice(),
delegated_owner: None,
project_d_tag: "shared",
expected_revision: 2,
add: &[],
@@ -548,6 +602,7 @@ mod tests {
&first,
ProjectRelatedChannelChange {
project_owner: owner_bytes.as_slice(),
delegated_owner: None,
project_d_tag: "shared",
expected_revision: 1,
add: &[added],
@@ -570,6 +625,7 @@ mod tests {
&denied,
ProjectRelatedChannelChange {
project_owner: owner_bytes.as_slice(),
delegated_owner: None,
project_d_tag: "shared",
expected_revision: 3,
add: &[denied_channel],
@@ -589,6 +645,107 @@ mod tests {
.expect("count rejected event");
assert_eq!(rejected_rows, 0);
let stale_mapping_channel = Uuid::new_v4();
let stale_mapping = command(
&owner_agent,
&owner,
"shared",
3,
&[stale_mapping_channel],
&[],
);
assert_eq!(
db.apply_project_related_channel_change(
community,
&stale_mapping,
ProjectRelatedChannelChange {
project_owner: owner_bytes.as_slice(),
delegated_owner: None,
project_d_tag: "shared",
expected_revision: 3,
add: &[stale_mapping_channel],
remove: &[],
},
)
.await
.expect("stale mapping alone cannot authorize"),
ProjectChangeApplyResult::Forbidden
);
let mismatched_owner = admin.public_key().to_bytes();
assert_eq!(
db.apply_project_related_channel_change(
community,
&stale_mapping,
ProjectRelatedChannelChange {
project_owner: owner_bytes.as_slice(),
delegated_owner: Some(mismatched_owner.as_slice()),
project_d_tag: "shared",
expected_revision: 3,
add: &[stale_mapping_channel],
remove: &[],
},
)
.await
.expect("mismatched registered owner cannot authorize"),
ProjectChangeApplyResult::Forbidden
);
let delegated_owner = owner.public_key().to_bytes();
assert!(matches!(
db.apply_project_related_channel_change(
community,
&stale_mapping,
ProjectRelatedChannelChange {
project_owner: owner_bytes.as_slice(),
delegated_owner: Some(delegated_owner.as_slice()),
project_d_tag: "shared",
expected_revision: 3,
add: &[stale_mapping_channel],
remove: &[],
},
)
.await
.expect("registered owner's current credential authorizes"),
ProjectChangeApplyResult::Applied(ProjectStateSnapshot { revision: 4, .. })
));
let delegated_admin_channel = Uuid::new_v4();
let delegated_admin = command(
&admin_agent,
&owner,
"shared",
4,
&[delegated_admin_channel],
&[],
);
let admin_bytes = admin.public_key().to_bytes();
assert!(matches!(
db.apply_project_related_channel_change(
community,
&delegated_admin,
ProjectRelatedChannelChange {
project_owner: owner_bytes.as_slice(),
delegated_owner: Some(admin_bytes.as_slice()),
project_d_tag: "shared",
expected_revision: 4,
add: &[delegated_admin_channel],
remove: &[],
},
)
.await
.expect("registered home admin credential authorizes"),
ProjectChangeApplyResult::Applied(ProjectStateSnapshot { revision: 5, .. })
));
let stored_author: Vec<u8> =
sqlx::query_scalar("SELECT pubkey FROM events WHERE community_id=$1 AND id=$2")
.bind(community.as_uuid())
.bind(delegated_admin.id.as_bytes().as_slice())
.fetch_one(&pool)
.await
.expect("load delegated command author");
assert_eq!(stored_author, admin_agent.public_key().to_bytes());
let stale_channel = Uuid::new_v4();
let stale = command(&owner, &owner, "shared", 2, &[stale_channel], &[]);
assert_eq!(
@@ -597,6 +754,7 @@ mod tests {
&stale,
ProjectRelatedChannelChange {
project_owner: owner_bytes.as_slice(),
delegated_owner: None,
project_d_tag: "shared",
expected_revision: 2,
add: &[stale_channel],
@@ -606,7 +764,7 @@ mod tests {
.await
.expect("reject stale CAS"),
ProjectChangeApplyResult::Conflict {
current_revision: 3
current_revision: 5
}
);
}

View File

@@ -21,6 +21,7 @@ const MAX_PATCH_CHANNELS: usize = 64;
#[derive(Debug)]
struct ParsedChange {
owner: Vec<u8>,
delegated_owner: Option<Vec<u8>>,
d_tag: String,
expected_revision: i64,
add: Vec<Uuid>,
@@ -106,12 +107,44 @@ fn parse_channels(values: Vec<String>) -> Result<Vec<Uuid>, IngestError> {
.collect()
}
fn verify_delegated_owner(event: &Event, auth_tag: &[String]) -> Result<Vec<u8>, IngestError> {
let auth_tag_json = serde_json::to_string(auth_tag)
.map_err(|error| invalid(format!("could not encode Project auth tag: {error}")))?;
let owner = buzz_sdk::nip_oa::verify_auth_tag(&auth_tag_json, &event.pubkey)
.map_err(|error| invalid(format!("invalid Project auth tag: {error}")))?;
let conditions = &auth_tag[2];
for clause in conditions.split('&').filter(|clause| !clause.is_empty()) {
let satisfied = if let Some(value) = clause.strip_prefix("kind=") {
value.parse::<u16>().ok() == Some(event.kind.as_u16())
} else if let Some(value) = clause.strip_prefix("created_at<") {
value
.parse::<u64>()
.is_ok_and(|bound| event.created_at.as_secs() < bound)
} else if let Some(value) = clause.strip_prefix("created_at>") {
value
.parse::<u64>()
.is_ok_and(|bound| event.created_at.as_secs() > bound)
} else {
false
};
if !satisfied {
return Err(invalid(format!(
"Project command does not satisfy auth condition {clause}"
)));
}
}
Ok(owner.to_bytes().to_vec())
}
fn parse(event: &Event) -> Result<ParsedChange, IngestError> {
if event.tags.len() != 2 {
return Err(invalid("Project change must contain exactly two tags"));
if !(2..=3).contains(&event.tags.len()) {
return Err(invalid(
"Project change must contain two required tags and at most one auth tag",
));
}
let mut coordinate = None;
let mut revision = None;
let mut auth_tag = None;
for tag in event.tags.iter() {
match tag.as_slice() {
[name, value] if name == "a" && coordinate.is_none() => {
@@ -120,6 +153,9 @@ fn parse(event: &Event) -> Result<ParsedChange, IngestError> {
[name, value] if name == "expected-revision" && revision.is_none() => {
revision = Some(parse_revision(value)?);
}
[name, _, _, _] if name == "auth" && auth_tag.is_none() => {
auth_tag = Some(tag.as_slice().to_vec());
}
_ => return Err(invalid("Project change tags are malformed")),
}
}
@@ -143,8 +179,13 @@ fn parse(event: &Event) -> Result<ParsedChange, IngestError> {
if !add_set.is_disjoint(&remove_set) {
return Err(invalid("Project change adds and removes the same channel"));
}
let delegated_owner = auth_tag
.as_deref()
.map(|tag| verify_delegated_owner(event, tag))
.transpose()?;
Ok(ParsedChange {
owner,
delegated_owner,
d_tag,
expected_revision,
add,
@@ -160,6 +201,7 @@ pub(crate) async fn handle(
let parsed = parse(event)?;
let change = ProjectRelatedChannelChange {
project_owner: &parsed.owner,
delegated_owner: parsed.delegated_owner.as_deref(),
project_d_tag: &parsed.d_tag,
expected_revision: parsed.expected_revision,
add: &parsed.add,
@@ -216,11 +258,34 @@ pub(crate) async fn handle(
#[cfg(test)]
mod tests {
use nostr::{EventBuilder, Keys, Kind, Tag};
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
use super::*;
fn command(tags: Vec<Tag>, content: &str) -> Event {
command_with(&Keys::generate(), tags, content, None)
}
fn command_with(actor: &Keys, tags: Vec<Tag>, content: &str, created_at: Option<u64>) -> Event {
let builder = EventBuilder::new(Kind::Custom(47_010), content).tags(tags);
let builder = match created_at {
Some(timestamp) => builder.custom_created_at(Timestamp::from(timestamp)),
None => builder,
};
builder.sign_with_keys(actor).expect("sign test command")
}
fn auth_tag(owner: &Keys, actor: &Keys, conditions: &str) -> Tag {
let json = buzz_sdk::nip_oa::compute_auth_tag(owner, &actor.public_key(), conditions)
.expect("compute auth tag");
buzz_sdk::nip_oa::parse_auth_tag(&json).expect("parse auth tag")
}
fn valid_content() -> &'static str {
r#"{"v":1,"patch":{"related_channels":{"add":["11111111-1111-4111-8111-111111111111"],"remove":[]}}}"#
}
fn legacy_command(tags: Vec<Tag>, content: &str) -> Event {
EventBuilder::new(Kind::Custom(47_010), content)
.tags(tags)
.sign_with_keys(&Keys::generate())
@@ -237,16 +302,46 @@ mod tests {
#[test]
fn parses_exact_v1_envelope() {
let event = command(
valid_tags(),
r#"{"v":1,"patch":{"related_channels":{"add":["11111111-1111-4111-8111-111111111111"],"remove":[]}}}"#,
);
let event = command(valid_tags(), valid_content());
let parsed = parse(&event).expect("valid command");
assert_eq!(parsed.expected_revision, 1);
assert_eq!(parsed.d_tag, "project:x");
assert_eq!(parsed.add.len(), 1);
}
#[test]
fn verifies_current_auth_tag_without_rewriting_actor() {
let actor = Keys::generate();
let owner = Keys::generate();
let mut tags = valid_tags();
tags.push(auth_tag(&owner, &actor, "kind=47010&created_at>99"));
let event = command_with(&actor, tags, valid_content(), Some(100));
let parsed = parse(&event).expect("valid delegated command");
assert_eq!(event.pubkey, actor.public_key());
assert_eq!(
parsed.delegated_owner,
Some(owner.public_key().to_bytes().to_vec())
);
}
#[test]
fn rejects_forged_and_stale_auth_tags() {
let actor = Keys::generate();
let owner = Keys::generate();
let other_agent = Keys::generate();
let mut forged = valid_tags();
forged.push(auth_tag(&owner, &other_agent, "kind=47010"));
assert!(parse(&command_with(&actor, forged, valid_content(), None)).is_err());
for conditions in ["kind=1", "created_at<100"] {
let mut stale = valid_tags();
stale.push(auth_tag(&owner, &actor, conditions));
assert!(parse(&command_with(&actor, stale, valid_content(), Some(100))).is_err());
}
}
#[test]
fn rejects_noncanonical_and_ambiguous_inputs() {
let cases = [
@@ -264,15 +359,22 @@ mod tests {
),
];
for (tags, content) in cases {
assert!(parse(&command(tags, content)).is_err());
assert!(parse(&legacy_command(tags, content)).is_err());
}
let mut bad_revision = valid_tags();
bad_revision[1] = Tag::parse(["expected-revision", "01"]).expect("tag");
assert!(parse(&command(bad_revision, "{}")).is_err());
assert!(parse(&legacy_command(bad_revision, "{}")).is_err());
let mut extra_tag = valid_tags();
extra_tag.push(Tag::parse(["h", &Uuid::new_v4().to_string()]).expect("tag"));
assert!(parse(&command(extra_tag, "{}")).is_err());
assert!(parse(&legacy_command(extra_tag, "{}")).is_err());
let actor = Keys::generate();
let owner = Keys::generate();
let mut duplicate_auth = valid_tags();
duplicate_auth.push(auth_tag(&owner, &actor, ""));
duplicate_auth.push(auth_tag(&owner, &actor, ""));
assert!(parse(&command_with(&actor, duplicate_auth, valid_content(), None)).is_err());
}
}

View File

@@ -55,13 +55,14 @@ The kind segment is the literal `30621`, the owner is exactly 64 lowercase hexad
"pubkey": "<actor-pubkey-hex>",
"tags": [
["a", "30621:<owner-pubkey-hex>:<project-d>"],
["expected-revision", "7"]
["expected-revision", "7"],
["auth", "<owner-pubkey-hex>", "kind=47010", "<signature-hex>"]
],
"content": "{\"v\":1,\"patch\":{\"related_channels\":{\"add\":[\"11111111-1111-4111-8111-111111111111\"],\"remove\":[]}}}"
}
```
The event MUST contain exactly one two-element `a` tag and exactly one two-element `expected-revision` tag. The expected revision is a canonical base-10 integer in `1..=9223372036854775807`: digits only, no sign, and no leading zero. The command compares this value with the authoritative relational revision, never with a Project State event id.
The event MUST contain exactly one two-element `a` tag and exactly one two-element `expected-revision` tag. It MAY additionally contain exactly one canonical four-element NIP-OA `auth` tag; no other tags are permitted. When present, the relay MUST verify the credential and every condition against this command event. The expected revision is a canonical base-10 integer in `1..=9223372036854775807`: digits only, no sign, and no leading zero. The command compares this value with the authoritative relational revision, never with a Project State event id.
The JSON content has this version 1 shape:
@@ -85,9 +86,9 @@ Version 1 decoders MUST reject unknown JSON fields. This prevents a newer client
## Authorization
The Project owner is always authorized to issue a version 1 change.
The command signer is the actor and remains the sole event author. A valid current NIP-OA credential does not rewrite authorship; it identifies an optional delegated authorization principal. The relay MUST use that principal only when it also matches the actor's immutable registered owner in the current community. A stored relationship without a valid credential on this command grants no authority.
A different actor is authorized only when the effective Project state has a resolvable home channel and the actor is an active `owner` or `admin` of that channel. An archived or deleted home channel grants no authority. Authorization reads the current relational Project state and current channel membership under the same serialization boundary as the CAS; an event projection or stale client snapshot is not an authorization source.
The actor is authorized when the actor is the Project owner. The delegated principal, when present and registered, is independently authorized when it is the Project owner. Otherwise, the effective Project state must have a resolvable home channel and either the actor or the delegated principal must independently be an active `owner` or `admin` of that channel. Privileges from the two principals MUST NOT be combined. An archived or deleted home channel grants no authority. Authorization reads the current relational Project state and current channel membership under the same serialization boundary as the CAS; an event projection or stale client snapshot is not an authorization source.
No Project relationship grants authority over a related channel. Adding a channel records grouping metadata only.