From 5d21632ea70e1c7de7becb7ab6227b06b1535a83 Mon Sep 17 00:00:00 2001 From: bndw Date: Sat, 14 Feb 2026 10:02:52 -0800 Subject: feat: add separate read/write allowlists for granular access control - Split allowed_npubs into allowed_npubs_read and allowed_npubs_write - Write operations: Publish, Delete, Create, Update, Insert, Remove, Set, Put - Read operations: everything else (Query, Subscribe, Get, List, etc.) - Auth interceptor checks appropriate list based on method type - Enables common patterns: - Public relay: only some can write, everyone can read - Private relay: restricted read and write - Open relay: everyone can read and write - Updated config, docs, and comprehensive tests Use cases: "only some can write, everyone can read" --- internal/auth/README.md | 13 ++++++++- internal/auth/auth_test.go | 38 +++++++++++++++++++++++++ internal/auth/interceptor.go | 67 ++++++++++++++++++++++++++++++++++++-------- 3 files changed, 105 insertions(+), 13 deletions(-) (limited to 'internal/auth') diff --git a/internal/auth/README.md b/internal/auth/README.md index df0de6a..366e110 100644 --- a/internal/auth/README.md +++ b/internal/auth/README.md @@ -209,9 +209,20 @@ authOpts := &auth.InterceptorOptions{ - **`TimestampWindow`**: Maximum age of events in seconds (default: 60) - **`Required`**: Whether to reject unauthenticated requests (default: false) - **`ValidatePayload`**: Whether to verify payload hash when present (default: false) -- **`AllowedNpubs`**: Optional whitelist of allowed pubkeys (nil = allow all) +- **`AllowedNpubsRead`**: Optional whitelist of allowed pubkeys for read operations (nil = allow all) - Config accepts npub format only (human-readable bech32) - Automatically normalized to hex format (computer-readable) at config load time + - Controls access to Query, Get, List, Subscribe, and other read methods +- **`AllowedNpubsWrite`**: Optional whitelist of allowed pubkeys for write operations (nil = allow all) + - Config accepts npub format only (human-readable bech32) + - Automatically normalized to hex format (computer-readable) at config load time + - Controls access to Publish, Delete, Create, Update, and other write methods + +**Access Control Patterns:** +- **Public relay**: Set `AllowedNpubsWrite` (only some can publish), leave `AllowedNpubsRead` empty (everyone can read) +- **Private relay**: Set both lists (restricted read and write access) +- **Open relay**: Leave both empty (everyone can read and write) +- **Read-only relay**: Set `AllowedNpubsRead`, block all writes ### NostrCredentials Options diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 1f0efee..7a0da19 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -304,3 +304,41 @@ func TestHashPayload(t *testing.T) { t.Error("different payloads produced same hash") } } + +func TestIsWriteMethod(t *testing.T) { + tests := []struct { + method string + want bool + }{ + // Write methods + {"/nostr.v1.NostrRelay/PublishEvent", true}, + {"/nostr.v1.NostrRelay/DeleteEvent", true}, + {"/admin.v1.Admin/CreateUser", true}, + {"/admin.v1.Admin/UpdateSettings", true}, + {"/data.v1.Data/InsertRecord", true}, + {"/data.v1.Data/RemoveItem", true}, + {"/storage.v1.Storage/SetValue", true}, + {"/storage.v1.Storage/PutObject", true}, + + // Read methods + {"/nostr.v1.NostrRelay/QueryEvents", false}, + {"/nostr.v1.NostrRelay/Subscribe", false}, + {"/nostr.v1.NostrRelay/GetEvent", false}, + {"/admin.v1.Admin/ListUsers", false}, + {"/health.v1.Health/Check", false}, + {"/info.v1.Info/GetRelayInfo", false}, + + // Edge cases + {"", false}, + {"/", false}, + } + + for _, tt := range tests { + t.Run(tt.method, func(t *testing.T) { + got := isWriteMethod(tt.method) + if got != tt.want { + t.Errorf("isWriteMethod(%q) = %v, want %v", tt.method, got, tt.want) + } + }) + } +} diff --git a/internal/auth/interceptor.go b/internal/auth/interceptor.go index 7d785bf..66880a7 100644 --- a/internal/auth/interceptor.go +++ b/internal/auth/interceptor.go @@ -3,6 +3,7 @@ package auth import ( "context" "fmt" + "strings" "google.golang.org/grpc" "google.golang.org/grpc/codes" @@ -35,11 +36,17 @@ type InterceptorOptions struct { // Default: false ValidatePayload bool - // AllowedNpubs is an optional whitelist of allowed pubkeys (hex format). + // AllowedNpubsRead is an optional whitelist of allowed pubkeys for read operations (hex format). // Config accepts npub format only, normalized to hex at load time. - // If nil or empty, all valid signatures are accepted. + // If nil or empty, all valid signatures are accepted for reads. // Default: nil (allow all) - AllowedNpubs []string + AllowedNpubsRead []string + + // AllowedNpubsWrite is an optional whitelist of allowed pubkeys for write operations (hex format). + // Config accepts npub format only, normalized to hex at load time. + // If nil or empty, all valid signatures are accepted for writes. + // Default: nil (allow all) + AllowedNpubsWrite []string // SkipMethods is a list of gRPC methods that bypass authentication. // Useful for public endpoints like health checks or relay info. @@ -51,11 +58,12 @@ type InterceptorOptions struct { // DefaultInterceptorOptions returns the default configuration. func DefaultInterceptorOptions() *InterceptorOptions { return &InterceptorOptions{ - TimestampWindow: 60, - Required: false, - ValidatePayload: false, - AllowedNpubs: nil, - SkipMethods: nil, + TimestampWindow: 60, + Required: false, + ValidatePayload: false, + AllowedNpubsRead: nil, + AllowedNpubsWrite: nil, + SkipMethods: nil, } } @@ -169,16 +177,51 @@ func validateAuthFromContext(ctx context.Context, method string, opts *Intercept // Extract pubkey pubkey := ExtractPubkey(event) - // Check whitelist if configured (all values are already normalized to hex) - if len(opts.AllowedNpubs) > 0 { - if !contains(opts.AllowedNpubs, pubkey) { - return "", fmt.Errorf("pubkey not in whitelist") + // Check whitelist based on operation type (all values are already normalized to hex) + if isWriteMethod(method) { + // Write operation - check write allowlist + if len(opts.AllowedNpubsWrite) > 0 { + if !contains(opts.AllowedNpubsWrite, pubkey) { + return "", fmt.Errorf("pubkey not authorized for write operations") + } + } + } else { + // Read operation - check read allowlist + if len(opts.AllowedNpubsRead) > 0 { + if !contains(opts.AllowedNpubsRead, pubkey) { + return "", fmt.Errorf("pubkey not authorized for read operations") + } } } return pubkey, nil } +// isWriteMethod determines if a gRPC method is a write operation. +// Write operations modify state (Publish, Delete, Create, Update, etc.) +// Read operations query state (Query, Get, List, Subscribe, etc.) +func isWriteMethod(method string) bool { + // Common write operation patterns + writePatterns := []string{ + "Publish", + "Delete", + "Create", + "Update", + "Insert", + "Remove", + "Set", + "Put", + } + + for _, pattern := range writePatterns { + if strings.Contains(method, pattern) { + return true + } + } + + return false +} + // shouldSkipAuth checks if a method should bypass authentication. func shouldSkipAuth(method string, skipMethods []string) bool { for _, skip := range skipMethods { -- cgit v1.2.3