Files
ngit-grasp/docs/explanation/inline-authorization.md
T
DanConwayDev fd7c6bb851 refactor(auth): make current maintainer authority explicit
The v3.0.1 authorization fix is intentionally small. Follow it with a separate structural pass so the implementation and documentation express the present-tense maintainer model directly instead of leaving the security behavior hidden behind owner-oriented names and repeated raw-tag interpretation.

Parse indexed roles once into a current-only snapshot of active maintainers, active lead targets, and announcement-author activity. Preserve detailed lead-resolution failures internally while policy callers continue to fail closed, distinguish selected authorization coordinates from physical owner views, and name broad announcement admission as discovery rather than authority.

Keep history relevant only while deriving current activity and retain active leads only for selected-coordinate resolution. Preserve the v3.0 public API through compatibility projections and deprecated aliases; this commit is not intended to change the authorization outcome established by 650cfb57.

Refresh architecture, inline authorization, storage, sync, and audit documentation. Correct the audit fixture description that claimed a listed maintainer authorized with no reciprocal announcement even though its setup already published one.

Validated with cargo test --lib (903 tests), cargo test --test state_authorization (53 tests), cargo test -p grasp-audit --lib (54 passed, 5 ignored), cargo test --test push_authorization (56 tests), and cargo clippy --tests -- -D warnings.
2026-08-29 21:20:49 +00:00

20 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:

  1. Git Hooks (traditional): Use Git's pre-receive hook mechanism
  2. 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,
    selected_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:

  1. State event arrives → No git data → Goes to purgatory (not database)
  2. Git push arrives → Hook checks database only → No state found → REJECTED ❌

With inline authorization:

  1. State event arrives → No git data → Goes to purgatory
  2. Git push arrives → Checks database + purgatory → State found → AUTHORIZED ✅
  3. 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,
        selected_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,
    selected_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
    //    - Resolve the selected coordinate's active lead path
    //    - Compute reciprocal confirmed membership from that root
    //    - Find latest authorized state
    //    - Validate refs match state

    // 5. Return authorization result with purgatory events
}

Key validation checks:

  1. For state refs (refs/heads/*, refs/tags/*):

    • Parse role history into current activity only
    • Resolve the URL-selected coordinate through one active M path
    • Compute its reciprocal confirmed-maintainer component
    • 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
  2. 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

No-Op Push Acceptance: Pushes where all refs have old_oid == new_oid are accepted without requiring a purgatory state event, matching Git's "Everything up-to-date" behavior and avoiding race condition rejections.

Role history does not flow through authorization. Announcement parsing validates the alternating start/end markers and derives only current active maintainers, current active M lead targets, and whether the announcement author currently claims maintainership. Authorization then resolves the selected coordinate's lead path and computes reciprocal membership. Missing, inactive, incomplete, ambiguous, or cyclic roots fail closed; their distinct internal results exist for diagnostics, not as alternative authority modes.


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(identifier);
        if announcements.is_empty() {
            return Reject("No announcement exists for repository");
        }
        
        // Check 2: Does any selected coordinate resolve to a confirmed
        // component containing this state-event author?
        let coordinates = repository_coordinates_authorized_by(
            event.author,
            announcements,
        );
        if coordinates.is_empty() {
            return Reject("Author not authorized for this repository");
        }
        
        // 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-resolve current authorization now that the dependency exists.
    if repository_coordinates_authorized_by(
        state_event.author,
        announcements,
    ).is_empty() {
        // Remove from purgatory - not currently authorized
    } else {
        // If git data now exists, save to database
        // Otherwise, keep in purgatory
    }
}

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
    let coordinates = repository_coordinates_authorized_by(
        state_event.author,
        announcements,
    );
    if !coordinates.is_empty() {
        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:

  1. On arrival: Prevents unauthorized events from entering the system
  2. On announcement acceptance: Handles race condition where state arrives before announcement
  3. 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 still present in the hot cache are re-processed immediately. Reciprocal invitation announcements can enter purgatory before their source state and Git data are available. During that bootstrap flow, rejected state events are:

  1. Retained in the cold index until policy processing succeeds
  2. Retrieved from the hot cache and re-processed immediately when the full event is still available
  3. Fetched by exact event ID from maintainer-discovery relay dependencies when the hot-cache copy has expired
  4. Removed from both tiers only after the event is accepted, enters purgatory, or is already stored

Repository announcements are processed before dependent state events. Network requests run outside the main sync-manager lock, and unsuccessful requests retain their IDs for a throttled retry. This enables rapid recovery from arrival-order races without reopening a broad synchronization query.

See GRASP-02: Integration with Rejected Events Index 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:

  1. Purgatory integration is essential - Without it, we'd have a deadlock where state events in purgatory can't authorize pushes
  2. Protocol parsing is isolated in src/git/authorization.rs
  3. GRASP is already non-standard (Nostr authorization)
  4. Benefits far outweigh the coupling cost
  5. 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:

  1. Purgatory integration - Without inline authorization, state events in purgatory couldn't authorize pushes, creating a deadlock
  2. Better error messages - Direct HTTP responses with clear rejection reasons
  3. Better performance - Early rejection before spawning Git
  4. Easier testing - Pure Rust unit tests, no Git setup needed
  5. Simpler deployment - Single binary with shared state
  6. 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.



Part of the ngit-grasp explanation docs