mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 23:18:24 +00:00
Add comprehensive authorization checks to ensure state events are only
accepted from maintainers of accepted repository announcements. This
implements the core GRASP-01 requirement that pushes must match the
latest state announcement "respecting the maintainer set."
Changes:
1. StatePolicy authorization (src/nostr/policy/state.rs):
- Check authorization BEFORE git data validation (fail-fast)
- Reject if no announcement exists for repository
- Reject if author not in maintainer set
- Use existing helpers: fetch_repository_data() and
pubkey_authorised_for_repo_owners()
- Structured logging for all rejections
2. Purgatory invalidation (src/nostr/builder.rs):
- New method: check_purgatory_state_events_for_identifier()
- Called when announcements accepted (Accept and AcceptMaintainer)
- Re-evaluates state events in purgatory for the identifier
- Processes newly-authorized events (releases from purgatory)
- Keeps unauthorized events for natural expiry (30 min)
- Enables retroactive authorization when announcements arrive late
3. Purgatory sync authorization (src/git/sync.rs):
- Check authorization BEFORE processing git data
- Remove unauthorized events from purgatory (permanent rejection)
- Prevents processing even if git data arrives first
- Structured logging for monitoring
4. Rejected events tracking (src/sync/rejected_index.rs):
- Add support for tracking rejected state events
- New methods: add_state(), contains_state()
- Separate metrics for state rejections
- Enables sync to avoid re-fetching rejected states
5. Sync metrics (src/sync/metrics.rs, src/sync/mod.rs):
- Add state-specific metrics (hot cache, cold index)
- Track rejected states separately from announcements
- Support monitoring of authorization rejections
6. Comprehensive tests (tests/state_authorization.rs):
- test_reject_state_without_announcement
- test_reject_state_from_unauthorized_author
- test_accept_state_from_announcement_author
- test_accept_state_from_maintainer
Security Impact:
- Before: State events could be published by anyone
- After: Only maintainers can publish state events
- Defense-in-depth: Authorization checked at 3 points:
1. On arrival (StatePolicy)
2. On announcement acceptance (purgatory re-evaluation)
3. On git data arrival (purgatory sync)
All tests pass:
- 248 unit tests
- 51 NIP-34 announcement tests
- 4 new state authorization tests
- 9 rejected index tests
Closes: State authorization requirement from GRASP-01 spec
281 lines
8.5 KiB
Rust
281 lines
8.5 KiB
Rust
//! Tests for state event authorization
|
|
//!
|
|
//! Verifies that state events are properly rejected when:
|
|
//! 1. No announcement exists for the repository
|
|
//! 2. Author is not in the maintainer set
|
|
|
|
mod common;
|
|
|
|
use common::relay::TestRelay;
|
|
use nostr_sdk::prelude::*;
|
|
|
|
#[tokio::test]
|
|
async fn test_reject_state_without_announcement() {
|
|
// Start test relay
|
|
let relay = TestRelay::start().await;
|
|
|
|
// Create test keypair
|
|
let keys = Keys::generate();
|
|
|
|
// Create a state event without any announcement
|
|
let state_event = EventBuilder::new(
|
|
Kind::RepoState,
|
|
"",
|
|
)
|
|
.tags([
|
|
Tag::custom(TagKind::custom("d"), ["test-repo"]),
|
|
Tag::custom(TagKind::custom("refs/heads/main"), ["abc123"]),
|
|
])
|
|
.sign_with_keys(&keys)
|
|
.unwrap();
|
|
|
|
// Connect to relay
|
|
let client = Client::default();
|
|
client.add_relay(relay.url()).await.unwrap();
|
|
client.connect().await;
|
|
|
|
// Try to send state event
|
|
let result = client.send_event(&state_event).await;
|
|
|
|
// Should be rejected
|
|
match result {
|
|
Ok(output) => {
|
|
assert!(
|
|
!output.success.is_empty() || !output.failed.is_empty(),
|
|
"Event should be processed"
|
|
);
|
|
// Check if any relay rejected it
|
|
let rejected = output.failed.values().any(|err| {
|
|
err.to_string().contains("no announcement exists")
|
|
});
|
|
assert!(rejected, "Event should be rejected due to missing announcement");
|
|
}
|
|
Err(e) => {
|
|
// Also acceptable - relay rejected the event
|
|
assert!(
|
|
e.to_string().contains("no announcement exists") ||
|
|
e.to_string().contains("rejected"),
|
|
"Error should indicate missing announcement: {}",
|
|
e
|
|
);
|
|
}
|
|
}
|
|
|
|
relay.stop().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_reject_state_from_unauthorized_author() {
|
|
// Start test relay
|
|
let relay = TestRelay::start().await;
|
|
|
|
// Create two keypairs: one for announcement, one for unauthorized state
|
|
let announcement_keys = Keys::generate();
|
|
let unauthorized_keys = Keys::generate();
|
|
|
|
// Create announcement
|
|
let announcement = EventBuilder::new(
|
|
Kind::GitRepoAnnouncement,
|
|
"",
|
|
)
|
|
.tags([
|
|
Tag::custom(TagKind::custom("d"), ["test-repo"]),
|
|
Tag::custom(TagKind::custom("clone"), [format!("https://{}/test.git", relay.domain())]),
|
|
Tag::custom(TagKind::custom("relays"), [relay.url()]),
|
|
])
|
|
.sign_with_keys(&announcement_keys)
|
|
.unwrap();
|
|
|
|
// Connect to relay
|
|
let client = Client::default();
|
|
client.add_relay(relay.url()).await.unwrap();
|
|
client.connect().await;
|
|
|
|
// Send announcement
|
|
client.send_event(&announcement).await.unwrap();
|
|
|
|
// Wait for announcement to be processed
|
|
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
|
|
|
// Try to send state event from unauthorized author
|
|
let state_event = EventBuilder::new(
|
|
Kind::RepoState,
|
|
"",
|
|
)
|
|
.tags([
|
|
Tag::custom(TagKind::custom("d"), ["test-repo"]),
|
|
Tag::custom(TagKind::custom("refs/heads/main"), ["abc123"]),
|
|
])
|
|
.sign_with_keys(&unauthorized_keys)
|
|
.unwrap();
|
|
|
|
let result = client.send_event(&state_event).await;
|
|
|
|
// Should be rejected
|
|
match result {
|
|
Ok(output) => {
|
|
let rejected = output.failed.values().any(|err| {
|
|
err.to_string().contains("not authorized")
|
|
});
|
|
assert!(rejected, "Event should be rejected due to unauthorized author");
|
|
}
|
|
Err(e) => {
|
|
assert!(
|
|
e.to_string().contains("not authorized") ||
|
|
e.to_string().contains("rejected"),
|
|
"Error should indicate unauthorized author: {}",
|
|
e
|
|
);
|
|
}
|
|
}
|
|
|
|
relay.stop().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_accept_state_from_announcement_author() {
|
|
// Start test relay
|
|
let relay = TestRelay::start().await;
|
|
|
|
// Create keypair
|
|
let keys = Keys::generate();
|
|
|
|
// Create announcement
|
|
let announcement = EventBuilder::new(
|
|
Kind::GitRepoAnnouncement,
|
|
"",
|
|
)
|
|
.tags([
|
|
Tag::custom(TagKind::custom("d"), ["test-repo"]),
|
|
Tag::custom(TagKind::custom("clone"), [format!("https://{}/test.git", relay.domain())]),
|
|
Tag::custom(TagKind::custom("relays"), [relay.url()]),
|
|
])
|
|
.sign_with_keys(&keys)
|
|
.unwrap();
|
|
|
|
// Connect to relay
|
|
let client = Client::default();
|
|
client.add_relay(relay.url()).await.unwrap();
|
|
client.connect().await;
|
|
|
|
// Send announcement
|
|
client.send_event(&announcement).await.unwrap();
|
|
|
|
// Wait for announcement to be processed
|
|
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
|
|
|
// Send state event from same author (should be accepted or go to purgatory)
|
|
let state_event = EventBuilder::new(
|
|
Kind::RepoState,
|
|
"",
|
|
)
|
|
.tags([
|
|
Tag::custom(TagKind::custom("d"), ["test-repo"]),
|
|
Tag::custom(TagKind::custom("refs/heads/main"), ["abc123"]),
|
|
])
|
|
.sign_with_keys(&keys)
|
|
.unwrap();
|
|
|
|
let result = client.send_event(&state_event).await;
|
|
|
|
// Should be accepted or go to purgatory (not permanently rejected)
|
|
match result {
|
|
Ok(output) => {
|
|
// Check that it wasn't permanently rejected
|
|
let permanently_rejected = output.failed.values().any(|err| {
|
|
let err_str = err.to_string();
|
|
err_str.contains("not authorized") || err_str.contains("no announcement exists")
|
|
});
|
|
assert!(
|
|
!permanently_rejected,
|
|
"Event should not be permanently rejected when author is authorized"
|
|
);
|
|
}
|
|
Err(e) => {
|
|
// Purgatory is acceptable
|
|
assert!(
|
|
e.to_string().contains("purgatory") ||
|
|
e.to_string().contains("waiting for git"),
|
|
"Error should be about purgatory, not authorization: {}",
|
|
e
|
|
);
|
|
}
|
|
}
|
|
|
|
relay.stop().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_accept_state_from_maintainer() {
|
|
// Start test relay
|
|
let relay = TestRelay::start().await;
|
|
|
|
// Create two keypairs: owner and maintainer
|
|
let owner_keys = Keys::generate();
|
|
let maintainer_keys = Keys::generate();
|
|
|
|
// Create announcement with maintainer
|
|
let announcement = EventBuilder::new(
|
|
Kind::GitRepoAnnouncement,
|
|
"",
|
|
)
|
|
.tags([
|
|
Tag::custom(TagKind::custom("d"), ["test-repo"]),
|
|
Tag::custom(TagKind::custom("clone"), [format!("https://{}/test.git", relay.domain())]),
|
|
Tag::custom(TagKind::custom("relays"), [relay.url()]),
|
|
Tag::custom(TagKind::custom("maintainers"), [maintainer_keys.public_key().to_hex()]),
|
|
])
|
|
.sign_with_keys(&owner_keys)
|
|
.unwrap();
|
|
|
|
// Connect to relay
|
|
let client = Client::default();
|
|
client.add_relay(relay.url()).await.unwrap();
|
|
client.connect().await;
|
|
|
|
// Send announcement
|
|
client.send_event(&announcement).await.unwrap();
|
|
|
|
// Wait for announcement to be processed
|
|
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
|
|
|
// Send state event from maintainer
|
|
let state_event = EventBuilder::new(
|
|
Kind::RepoState,
|
|
"",
|
|
)
|
|
.tags([
|
|
Tag::custom(TagKind::custom("d"), ["test-repo"]),
|
|
Tag::custom(TagKind::custom("refs/heads/main"), ["abc123"]),
|
|
])
|
|
.sign_with_keys(&maintainer_keys)
|
|
.unwrap();
|
|
|
|
let result = client.send_event(&state_event).await;
|
|
|
|
// Should be accepted or go to purgatory (not permanently rejected)
|
|
match result {
|
|
Ok(output) => {
|
|
let permanently_rejected = output.failed.values().any(|err| {
|
|
let err_str = err.to_string();
|
|
err_str.contains("not authorized") || err_str.contains("no announcement exists")
|
|
});
|
|
assert!(
|
|
!permanently_rejected,
|
|
"Event should not be permanently rejected when maintainer is authorized"
|
|
);
|
|
}
|
|
Err(e) => {
|
|
// Purgatory is acceptable
|
|
assert!(
|
|
e.to_string().contains("purgatory") ||
|
|
e.to_string().contains("waiting for git"),
|
|
"Error should be about purgatory, not authorization: {}",
|
|
e
|
|
);
|
|
}
|
|
}
|
|
|
|
relay.stop().await;
|
|
}
|