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 ++++++++++++++++---- internal/config/README.md | 24 ++++++-- internal/config/config.go | 52 +++++++++++----- internal/config/config_test.go | 136 ++++++++++++++++++++++++++++++----------- 6 files changed, 263 insertions(+), 67 deletions(-) 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 { diff --git a/internal/config/README.md b/internal/config/README.md index dbb8760..7deb38f 100644 --- a/internal/config/README.md +++ b/internal/config/README.md @@ -90,12 +90,25 @@ auth: # Timestamp window in seconds for replay protection timestamp_window: 60 - # Allowed npubs (optional, whitelist) - # If empty, all valid signatures are accepted + # Allowed npubs for read operations (optional, whitelist) + # If empty, all valid signatures are accepted for reads # Use npub format only (e.g., npub1...) - allowed_npubs: [] + allowed_npubs_read: [] + + # Allowed npubs for write operations (optional, whitelist) + # If empty, all valid signatures are accepted for writes + # Use npub format only (e.g., npub1...) + allowed_npubs_write: [] + + # Example use cases: + # - Public relay: allowed_npubs_write (only some can publish), empty read (everyone can read) + # - Private relay: both lists populated (restricted read and write) + # - Open relay: both lists empty (everyone can read and write) + # # Example: - # allowed_npubs: + # allowed_npubs_read: + # - npub1a2b3c4d5e6f... + # allowed_npubs_write: # - npub1a2b3c4d5e6f... # Skip authentication for these methods @@ -222,7 +235,8 @@ Complex types: ```bash # Lists (comma-separated, npub format) -export MUXSTR_AUTH_ALLOWED_NPUBS="npub1...,npub1...,npub1..." +export MUXSTR_AUTH_ALLOWED_NPUBS_READ="npub1...,npub1..." +export MUXSTR_AUTH_ALLOWED_NPUBS_WRITE="npub1..." # Durations export MUXSTR_SERVER_READ_TIMEOUT="30s" diff --git a/internal/config/config.go b/internal/config/config.go index 0566537..3e52272 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -39,11 +39,12 @@ type DatabaseConfig struct { // AuthConfig holds authentication configuration. type AuthConfig struct { - Enabled bool `yaml:"enabled"` - Required bool `yaml:"required"` - TimestampWindow int64 `yaml:"timestamp_window"` - AllowedNpubs []string `yaml:"allowed_npubs"` // npub format only (bech32) - normalized to hex internally - SkipMethods []string `yaml:"skip_methods"` + Enabled bool `yaml:"enabled"` + Required bool `yaml:"required"` + TimestampWindow int64 `yaml:"timestamp_window"` + AllowedNpubsRead []string `yaml:"allowed_npubs_read"` // npub format only (bech32) - normalized to hex internally + AllowedNpubsWrite []string `yaml:"allowed_npubs_write"` // npub format only (bech32) - normalized to hex internally + SkipMethods []string `yaml:"skip_methods"` } // RateLimitConfig holds rate limiting configuration. @@ -180,12 +181,31 @@ func Load(filename string) (*Config, error) { // Config only accepts npub format (human-readable), which is converted // to hex format (computer-readable) for internal use. func normalizeNpubs(cfg *Config) error { - if len(cfg.Auth.AllowedNpubs) == 0 { - return nil + var err error + + // Normalize read allowlist + cfg.Auth.AllowedNpubsRead, err = normalizeNpubList(cfg.Auth.AllowedNpubsRead) + if err != nil { + return fmt.Errorf("allowed_npubs_read: %w", err) + } + + // Normalize write allowlist + cfg.Auth.AllowedNpubsWrite, err = normalizeNpubList(cfg.Auth.AllowedNpubsWrite) + if err != nil { + return fmt.Errorf("allowed_npubs_write: %w", err) + } + + return nil +} + +// normalizeNpubList converts a list of npubs to hex pubkeys. +func normalizeNpubList(npubs []string) ([]string, error) { + if len(npubs) == 0 { + return nil, nil } - normalized := make([]string, 0, len(cfg.Auth.AllowedNpubs)) - for _, npub := range cfg.Auth.AllowedNpubs { + normalized := make([]string, 0, len(npubs)) + for _, npub := range npubs { // Skip empty strings npub = strings.TrimSpace(npub) if npub == "" { @@ -194,21 +214,20 @@ func normalizeNpubs(cfg *Config) error { // Validate npub format if !strings.HasPrefix(npub, "npub1") { - return fmt.Errorf("invalid npub format %q: must start with 'npub1'", npub) + return nil, fmt.Errorf("invalid npub format %q: must start with 'npub1'", npub) } // Parse npub to get hex pubkey key, err := nostr.ParsePublicKey(npub) if err != nil { - return fmt.Errorf("invalid npub %q: %w", npub, err) + return nil, fmt.Errorf("invalid npub %q: %w", npub, err) } // Get the hex representation for internal use normalized = append(normalized, key.Public()) } - cfg.Auth.AllowedNpubs = normalized - return nil + return normalized, nil } // Validate validates the configuration. @@ -292,8 +311,11 @@ func applyEnvOverrides(cfg *Config) { cfg.Auth.TimestampWindow = n } } - if val := os.Getenv("MUXSTR_AUTH_ALLOWED_NPUBS"); val != "" { - cfg.Auth.AllowedNpubs = strings.Split(val, ",") + if val := os.Getenv("MUXSTR_AUTH_ALLOWED_NPUBS_READ"); val != "" { + cfg.Auth.AllowedNpubsRead = strings.Split(val, ",") + } + if val := os.Getenv("MUXSTR_AUTH_ALLOWED_NPUBS_WRITE"); val != "" { + cfg.Auth.AllowedNpubsWrite = strings.Split(val, ",") } // Rate limit diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5fa159e..c0d4555 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -260,9 +260,11 @@ database: auth: enabled: true - allowed_npubs: + allowed_npubs_read: - npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6 - npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft + allowed_npubs_write: + - npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6 ` if _, err := tmpfile.Write([]byte(configData)); err != nil { @@ -275,18 +277,32 @@ auth: t.Fatalf("failed to load config: %v", err) } - // Verify npubs were normalized to hex - if len(cfg.Auth.AllowedNpubs) != 2 { - t.Errorf("expected 2 allowed npubs, got %d", len(cfg.Auth.AllowedNpubs)) + // Verify read npubs were normalized to hex + if len(cfg.Auth.AllowedNpubsRead) != 2 { + t.Errorf("expected 2 allowed npubs for read, got %d", len(cfg.Auth.AllowedNpubsRead)) + } + + // Verify write npubs were normalized to hex + if len(cfg.Auth.AllowedNpubsWrite) != 1 { + t.Errorf("expected 1 allowed npub for write, got %d", len(cfg.Auth.AllowedNpubsWrite)) } // Check that they're hex format (64 chars, not npub1...) - for i, pubkey := range cfg.Auth.AllowedNpubs { + for i, pubkey := range cfg.Auth.AllowedNpubsRead { + if len(pubkey) != 64 { + t.Errorf("read npub %d: expected 64 hex chars, got %d", i, len(pubkey)) + } + if len(pubkey) >= 5 && pubkey[:5] == "npub1" { + t.Errorf("read npub %d: should be normalized to hex, still in npub format", i) + } + } + + for i, pubkey := range cfg.Auth.AllowedNpubsWrite { if len(pubkey) != 64 { - t.Errorf("npub %d: expected 64 hex chars, got %d", i, len(pubkey)) + t.Errorf("write npub %d: expected 64 hex chars, got %d", i, len(pubkey)) } - if pubkey[:5] == "npub1" { - t.Errorf("npub %d: should be normalized to hex, still in npub format", i) + if len(pubkey) >= 5 && pubkey[:5] == "npub1" { + t.Errorf("write npub %d: should be normalized to hex, still in npub format", i) } } @@ -294,46 +310,98 @@ auth: expectedHex1 := "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" expectedHex2 := "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52" - if cfg.Auth.AllowedNpubs[0] != expectedHex1 { - t.Errorf("npub 0: expected %s, got %s", expectedHex1, cfg.Auth.AllowedNpubs[0]) + if cfg.Auth.AllowedNpubsRead[0] != expectedHex1 { + t.Errorf("read npub 0: expected %s, got %s", expectedHex1, cfg.Auth.AllowedNpubsRead[0]) + } + if cfg.Auth.AllowedNpubsRead[1] != expectedHex2 { + t.Errorf("read npub 1: expected %s, got %s", expectedHex2, cfg.Auth.AllowedNpubsRead[1]) } - if cfg.Auth.AllowedNpubs[1] != expectedHex2 { - t.Errorf("npub 1: expected %s, got %s", expectedHex2, cfg.Auth.AllowedNpubs[1]) + if cfg.Auth.AllowedNpubsWrite[0] != expectedHex1 { + t.Errorf("write npub 0: expected %s, got %s", expectedHex1, cfg.Auth.AllowedNpubsWrite[0]) } } func TestNpubValidation(t *testing.T) { - tmpfile, err := os.CreateTemp("", "config-*.yaml") - if err != nil { - t.Fatal(err) - } - defer os.Remove(tmpfile.Name()) - - // Invalid: hex format instead of npub - configData := ` + tests := []struct { + name string + config string + expectError bool + errorMsg string + }{ + { + name: "invalid hex in read list", + config: ` server: grpc_addr: ":50051" http_addr: ":8080" - database: path: "test.db" - auth: - allowed_npubs: + allowed_npubs_read: - 3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d -` - - if _, err := tmpfile.Write([]byte(configData)); err != nil { - t.Fatal(err) +`, + expectError: true, + errorMsg: "must start with 'npub1'", + }, + { + name: "invalid hex in write list", + config: ` +server: + grpc_addr: ":50051" + http_addr: ":8080" +database: + path: "test.db" +auth: + allowed_npubs_write: + - 3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d +`, + expectError: true, + errorMsg: "must start with 'npub1'", + }, + { + name: "valid npub lists", + config: ` +server: + grpc_addr: ":50051" + http_addr: ":8080" +database: + path: "test.db" +auth: + allowed_npubs_read: + - npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6 + allowed_npubs_write: + - npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft +`, + expectError: false, + }, } - tmpfile.Close() - _, err = Load(tmpfile.Name()) - if err == nil { - t.Error("expected error for hex format in allowed_npubs, got nil") - } - if err != nil && !strings.Contains(err.Error(), "must start with 'npub1'") { - t.Errorf("expected 'must start with npub1' error, got: %v", err) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpfile, err := os.CreateTemp("", "config-*.yaml") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpfile.Name()) + + if _, err := tmpfile.Write([]byte(tt.config)); err != nil { + t.Fatal(err) + } + tmpfile.Close() + + _, err = Load(tmpfile.Name()) + if tt.expectError { + if err == nil { + t.Error("expected error, got nil") + } else if !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("expected error containing %q, got: %v", tt.errorMsg, err) + } + } else { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + } + }) } } -- cgit v1.2.3