- Add rejected events index to architecture.md with two-tier system explanation - Document NGIT_REJECTED_HOT_CACHE_DURATION_SECS and NGIT_REJECTED_COLD_INDEX_EXPIRY_SECS in configuration.md - Add comprehensive rejected events metrics section to monitoring.md with Grafana queries and alerts - Explain negentropy integration with rejected index in grasp-02-proactive-sync.md - Document state event authorization defense-in-depth and rejection tracking in inline-authorization.md This integrates information from work/rejected-events-index-summary.md into the main documentation, ensuring architecture docs accurately reflect the implemented rejected events index system.
18 KiB
Explanation: Inline Authorization
Purpose: Understand why ngit-grasp validates Git pushes inline rather than using Git hooks
Audience: Developers and architects wanting to understand design decisions
The Problem
Git hosting with authorization requires validating pushes before accepting them. The question is: where should this validation happen?
Two approaches exist:
- Git Hooks (traditional): Use Git's pre-receive hook mechanism
- Inline Authorization (our approach): Validate before spawning Git
This document explains why we chose inline authorization and what benefits it provides.
Background: How Git Hooks Work
Git provides a pre-receive hook that runs during git push:
Client Server
| |
|--- git push ----->|
| |--- spawn git-receive-pack
| |
| |--- pre-receive hook runs
| | (reads stdin: old new ref)
| | (exit 0 = accept, 1 = reject)
| |
|<--- success ------| (if hook exits 0)
|<--- error --------| (if hook exits 1)
Pros:
- Standard Git mechanism
- Language-agnostic (hook can be any executable)
- Well-documented
Cons:
- Hook output goes to stderr (client sees as
remote:messages) - Hard to provide structured error messages
- Requires hook installation and management
- Difficult to test (needs Git repository setup)
- Hook runs after Git has started processing
Background: How Inline Authorization Works
With inline authorization, we validate before spawning Git:
Client Server (ngit-grasp)
| |
|--- git push ----->|--- HTTP handler receives request
| |
| |--- Parse ref updates from request
| |--- Query database + purgatory for state
| |--- Validate push against state
| |
| |--- If invalid: return HTTP error
| |--- If valid: spawn git-receive-pack
| |
|<--- success ------| (if valid)
|<--- HTTP error ---| (if invalid)
Pros:
- Full control over error messages (HTTP response)
- Can skip spawning Git entirely for invalid pushes
- Easier testing (pure Rust, no Git setup needed)
- Shared state between Git and Nostr components
- Better performance (early rejection)
- Can check both database and purgatory for authorization
Cons:
- Requires parsing Git protocol ourselves
- Less standard than hooks
- Tighter coupling to Git HTTP protocol
Why Inline Authorization Is Better for GRASP
1. Purgatory Integration
Critical advantage: Inline authorization allows checking both database and purgatory during authorization:
// From src/git/authorization.rs
pub async fn authorize_push(
database: &SharedDatabase,
identifier: &str,
owner_pubkey: &str,
request_body: &Bytes,
purgatory: &Arc<Purgatory>, // Can check purgatory!
repo_path: &std::path::Path,
) -> anyhow::Result<AuthorizationResult>
Why this matters: State events go to purgatory when git data doesn't exist yet. Without inline authorization checking purgatory, we'd have a deadlock:
- State event arrives → No git data → Goes to purgatory (not database)
- Git push arrives → Hook checks database only → No state found → REJECTED ❌
With inline authorization:
- State event arrives → No git data → Goes to purgatory
- Git push arrives → Checks database + purgatory → State found → AUTHORIZED ✅
- After push succeeds → Save event to database → Remove from purgatory
See src/git/authorization.rs:342-400 for implementation.
otherwise we'd need another way of storing purgatory events.
2. Better Error Messages
With hooks:
$ git push
remote: error: Push rejected - not authorized for ref refs/heads/main
remote: See https://docs.gitnostr.com/errors/unauthorized
To https://gitnostr.com/alice/myrepo.git
! [remote rejected] main -> main (pre-receive hook declined)
With inline authorization:
$ git push
error: RPC failed; HTTP 403 Forbidden
error: Push rejected: No state event found in purgatory from authorized publishers
The inline approach provides clear, actionable error messages directly in the HTTP response.
3. Performance Benefits
With hooks:
- Git process spawns
- Git starts receiving pack data
- Hook runs (might query Nostr relay)
- If rejected, Git throws away received data
With inline authorization:
- Parse ref updates from HTTP request (pkt-line format)
- Validate against database + purgatory state
- If rejected, return HTTP error immediately
- Never spawn Git for invalid pushes
Result: Faster rejection, less resource usage, no wasted pack data transfer.
4. Easier Testing
With hooks:
# Test setup
mkdir -p /tmp/test-repo
cd /tmp/test-repo
git init --bare
cp pre-receive.sh hooks/pre-receive
chmod +x hooks/pre-receive
# Test execution
git push /tmp/test-repo main
# Cleanup
rm -rf /tmp/test-repo
With inline authorization:
#[tokio::test]
async fn test_unauthorized_push() {
let relay = TestRelay::start().await;
let result = validate_push(&state, "refs/heads/main", alice_pubkey).await;
assert!(result.is_err());
relay.stop().await;
}
Result: Pure Rust unit tests, no shell scripts, no Git setup.
See tests/push_authorization.rs for actual test examples.
5. Shared State and Types
With hooks:
- Hook is separate process
- Must query Nostr relay over WebSocket
- Can't share in-memory cache
- Can't access purgatory
- Separate error types
With inline authorization:
// From src/git/handlers.rs
pub async fn handle_receive_pack(
repo_path: PathBuf,
body: Bytes,
database: Option<SharedDatabase>, // Shared with Nostr relay!
purgatory: Option<Arc<Purgatory>>, // Shared purgatory access!
npub: &str,
identifier: &str,
) -> Result<Response<Full<Bytes>>, GitError> {
// Direct database + purgatory access for authorization
let auth = authorize_push(
&database,
identifier,
owner_pubkey,
&body,
&purgatory, // Can check purgatory!
&repo_path
).await?;
// ...
}
Result: Better performance, type safety, simpler architecture, purgatory integration.
6. Simpler Deployment
With hooks (ngit-relay):
Docker container:
- nginx (HTTP frontend)
- git-http-backend (C binary)
- pre-receive hook (Go binary)
- Khatru relay (Go binary)
- supervisord (process manager)
Setup steps:
1. Install all components
2. Configure nginx
3. Install hook in each repository
4. Set up supervisord
5. Configure inter-process communication
With inline authorization (ngit-grasp):
Single Rust binary:
- HTTP server (Hyper)
- Git protocol handler
- Nostr relay (nostr-relay-builder)
- Authorization logic
Setup steps:
1. Run binary
2. Configure environment variables
Result: Simpler deployment, fewer moving parts.
Technical Implementation
How We Parse Ref Updates
The Git HTTP protocol sends ref updates in pkt-line format:
POST /alice/myrepo.git/git-receive-pack HTTP/1.1
Content-Type: application/x-git-receive-pack-request
00a5 0000...0000 abc123...def456 refs/heads/main\0 report-status\n
0000
PACK...
We parse this before spawning Git. See src/git/authorization.rs:695-778 for the implementation:
/// Parse the refs being updated from a Git pack
///
/// The receive-pack protocol sends ref updates in pkt-line format:
/// - 4-byte hex length prefix (e.g., "00a5")
/// - Payload: `<old-oid> <new-oid> <ref-name>\0<capabilities>\n`
/// - Flush packet "0000" terminates the list
pub fn parse_pushed_refs(data: &[u8]) -> Vec<(String, String, String)> {
// Handles both pkt-line format (real Git clients)
// and simple text format (for unit tests)
}
How We Validate
The authorization flow (from src/git/authorization.rs:51-162):
pub async fn authorize_push(
database: &SharedDatabase,
identifier: &str,
owner_pubkey: &str,
request_body: &Bytes,
purgatory: &Arc<Purgatory>,
repo_path: &std::path::Path,
) -> anyhow::Result<AuthorizationResult> {
// 1. Parse refs from push request
let pushed_refs = parse_pushed_refs(request_body);
// 2. Separate refs/nostr/ refs from state refs
let (nostr_refs, state_refs) = partition_refs(&pushed_refs);
// 3. Handle refs/nostr/ refs (PR events)
// - Validate event ID format
// - Check purgatory for PR event
// - Create placeholder if git-data-first scenario
// 4. Handle normal refs (state events)
// - Check database + purgatory for state events
// - Collect authorized maintainers
// - Find latest authorized state
// - Validate refs match state
// 5. Return authorization result with purgatory events
}
Key validation checks:
-
For state refs (
refs/heads/*,refs/tags/*):- Query database for announcements → collect authorized maintainers
- Check purgatory for matching state events (critical for purgatory flow!)
- Filter to events from authorized maintainers
- Find latest state event
- Validate pushed refs match state event refs
-
For PR refs (
refs/nostr/<event-id>):- Validate event ID format
- Check purgatory for PR event with matching commit
- If no event found, create placeholder (git-data-first scenario)
- Collect PR events from purgatory for post-push processing
State Event Authorization
State events (kind 30618) undergo authorization checks at three points (defense-in-depth):
1. On Arrival (StatePolicy)
When a state event arrives via WebSocket or sync:
// src/nostr/policy/state.rs
impl StatePolicy {
async fn admit_event(&self, event: &Event) -> Result<Decision, Error> {
// Check 1: Does announcement exist for this repository?
let announcements = query_announcements(pubkey, identifier);
if announcements.is_empty() {
return Reject("No announcement exists for repository");
}
// Check 2: Is author in maintainer set?
let maintainers = build_maintainer_set(announcements);
if !maintainers.contains(&event.author) {
return Reject("Author not in maintainer set");
}
// If git data doesn't exist yet, goes to purgatory
// Otherwise, accepted to database
}
}
2. On Announcement Acceptance (Purgatory Re-evaluation)
When a repository announcement is accepted, waiting state events are re-evaluated:
// After announcement is saved to database
for state_event in purgatory.get_state_events(identifier) {
// Re-check authorization now that announcement exists
if author_in_maintainer_set(state_event.author, identifier) {
// If git data now exists, save to database
// Otherwise, keep in purgatory
} else {
// Remove from purgatory - not authorized
}
}
3. On Git Data Arrival (Purgatory Sync)
When git data is pushed, purgatory state events are validated before saving:
// src/git/handlers.rs - after successful git push
for state_event in purgatory.get_matching_state_events(identifier) {
// Final authorization check before database save
if author_in_maintainer_set(state_event.author, identifier) {
database.save(state_event);
purgatory.remove(state_event);
} else {
purgatory.remove(state_event); // Not authorized
}
}
Why Three Checkpoints?
Defense-in-depth ensures authorization is always validated:
- On arrival: Prevents unauthorized events from entering the system
- On announcement acceptance: Handles race condition where state arrives before announcement
- On git data arrival: Final check before committing to database
This prevents scenarios where:
- Unauthorized state events are saved after maintainer changes
- Race conditions bypass authorization
- Purgatory holds events that will never be authorized
Rejection Tracking
State events rejected during authorization are tracked in the rejected events index:
- Reason: MaintainerNotYetValid - Author not in maintainer set (may become valid later)
- Reason: Other - Other validation failures
When a repository announcement is accepted, rejected state events for that repository are:
- Invalidated from cold index (removed from negentropy exclusion)
- Retrieved from hot cache (if still available within 2 minutes)
- Re-processed immediately with new maintainer set
This enables rapid recovery from race conditions where state events arrive before maintainer announcements.
See work/rejected-events-index-summary.md for complete details on rejection tracking and re-processing.
Comparison with Reference Implementation
| Aspect | ngit-relay (hooks) | ngit-grasp (inline) |
|---|---|---|
| Components | nginx + git-http-backend + hook + Khatru | Single Rust binary |
| Validation | Pre-receive hook (separate process) | Inline HTTP handler |
| Error messages | Hook stderr → remote: |
HTTP response JSON |
| Performance | Spawns Git first | Validates first |
| Testing | Shell scripts + Go tests | Pure Rust tests |
| Deployment | Docker + supervisord | Single binary |
| State sharing | WebSocket query | Direct database access |
Both are GRASP-compliant, but inline authorization is simpler and more efficient.
Trade-offs and Limitations
What We Gain
- ✅ Purgatory integration - Can check database + purgatory during authorization
- ✅ Prevents deadlock - State events in purgatory can authorize pushes
- ✅ Better error messages
- ✅ Better performance (early rejection)
- ✅ Easier testing (pure Rust)
- ✅ Simpler deployment (single binary)
- ✅ Tighter integration (shared state)
What We Lose
- ❌ Non-standard approach (not using Git's hook system)
- ❌ Tighter coupling to Git HTTP protocol
- ❌ Must parse pkt-line protocol ourselves
Is It Worth It?
Absolutely, because:
- Purgatory integration is essential - Without it, we'd have a deadlock where state events in purgatory can't authorize pushes
- Protocol parsing is isolated in
src/git/authorization.rs - GRASP is already non-standard (Nostr authorization)
- Benefits far outweigh the coupling cost
- We can still add hook support later if needed (but purgatory checking would still need inline access)
Implementation References
Key files in the ngit-grasp implementation:
| Component | Location |
|---|---|
| HTTP routing | src/http/mod.rs |
| Git handlers | src/git/handlers.rs |
| Push authorization | src/git/authorization.rs |
| Pkt-line parsing | src/git/authorization.rs:695-778 |
| Subprocess management | src/git/subprocess.rs |
| Purgatory integration | src/purgatory/mod.rs |
| Event acceptance policy | src/nostr/builder.rs - Nip34WritePolicy |
Future Considerations
If We Need Hooks Later
We can add hook support without removing inline validation:
pub struct GitConfig {
inline_validation: bool, // Default: true
hook_validation: bool, // Default: false
}
This would allow:
- Migration path for hook-based systems
- Extra validation for paranoid deployments
- Compatibility with other Git tools
If Git Protocol Changes
The protocol parsing is isolated in src/git/protocol.rs. If the Git protocol changes:
- Update the protocol module
- Tests will catch any breakage
Conclusion
Inline authorization is the right choice for ngit-grasp because:
- Purgatory integration - Without inline authorization, state events in purgatory couldn't authorize pushes, creating a deadlock
- Better error messages - Direct HTTP responses with clear rejection reasons
- Better performance - Early rejection before spawning Git
- Easier testing - Pure Rust unit tests, no Git setup needed
- Simpler deployment - Single binary with shared state
- Shared database + purgatory - Both authorization sources accessible during validation
The trade-off (coupling to Git HTTP protocol) is acceptable because:
- The pkt-line protocol is stable and well-specified
- Protocol parsing is isolated in
src/git/authorization.rs - Purgatory integration requires inline access anyway
- Benefits far outweigh the cost
This decision aligns with our goal of creating a developer-friendly, production-ready GRASP implementation that properly handles the event-git-data ordering problem via purgatory.
Related Documentation
- Architecture Overview - Full system design
- Design Decisions - All architectural choices
- Comparison with ngit-relay - Detailed comparison
- Git Protocol Reference - Protocol details
- Test Strategy - How we test this
Part of the ngit-grasp explanation docs