diff --git a/AGENTS.md b/AGENTS.md index b09c0e4..03233d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -176,8 +176,9 @@ Keep infrastructure Dockerfiles under `infra/images/` and local Compose, service configuration and shell tooling under `infra/local/`. Keep product source under `backend/` and `frontend/`. Organize Vault KV paths by owning service under `services//`, infrastructure under `infrastructure//`, -and bootstrap administration under `infrastructure//bootstrap`. Caller credentials -belong to the receiving service at `services//callers/`. +and bootstrap administration under `infrastructure//bootstrap`. +Do not add BFF-to-notifications caller authentication on the trusted private network. +Preserve NATS authentication. Use Caddy for local browser HTTPS and certificate management. Treat the private local Docker network as trusted; do not add internal gRPC TLS or Vault-managed local TLS certificates without an explicit requirement. diff --git a/backend/notifications/README.md b/backend/notifications/README.md index 6cb0f45..645c9d2 100644 --- a/backend/notifications/README.md +++ b/backend/notifications/README.md @@ -22,8 +22,7 @@ subject, text and HTML, bounded timeouts and safe error classification. Requires Go 1.27, protoc, an SXCHNGE MongoDB database and a NATS server with JetStream enabled. Copy `config.example.yml` to an untracked `config.yml` here. -Set `SXCHNGE_MONGO_URI`, `SXCHNGE_NATS_URL`, a unique token of at least 32 bytes for -each configured caller, and the selected email provider's API key environment +Set `SXCHNGE_MONGO_URI`, `SXCHNGE_NATS_URL` and the selected email provider's API key environment variable. Configure a verified sender address and template catalog. Paths are relative to the process working directory. diff --git a/backend/notifications/app/config.go b/backend/notifications/app/config.go index e4f5dff..7a3fe32 100644 --- a/backend/notifications/app/config.go +++ b/backend/notifications/app/config.go @@ -4,7 +4,6 @@ import ( "bytes" "io" "os" - "strings" "git.sendico.io/tech/sxchnge/notifications/bus" "git.sendico.io/tech/sxchnge/notifications/email" @@ -15,15 +14,14 @@ import ( ) type Config struct { - Queue bus.Config `yaml:"queue"` - Messaging messaging.Config `yaml:"messaging"` - Listen string `yaml:"listen"` - TLSCert string `yaml:"tls_cert"` - TLSKey string `yaml:"tls_key"` - Database db.Config `yaml:"database"` - Templates string `yaml:"templates"` - Callers map[string]string `yaml:"callers"` // caller -> token environment variable - Email email.Config `yaml:"email"` + Queue bus.Config `yaml:"queue"` + Messaging messaging.Config `yaml:"messaging"` + Listen string `yaml:"listen"` + TLSCert string `yaml:"tls_cert"` + TLSKey string `yaml:"tls_key"` + Database db.Config `yaml:"database"` + Templates string `yaml:"templates"` + Email email.Config `yaml:"email"` } func Load(path string) (Config, error) { @@ -40,7 +38,7 @@ func Load(path string) (Config, error) { if err = decoder.Decode(new(any)); err != io.EOF { return c, merrors.InvalidArgument("unexpected configuration content") } - if c.Listen == "" || c.Database.Driver != db.Mongo || c.Messaging.Driver != messaging.BBNats || c.Templates == "" || len(c.Callers) == 0 { + if c.Listen == "" || c.Database.Driver != db.Mongo || c.Messaging.Driver != messaging.BBNats || c.Templates == "" { return c, merrors.InvalidArgument("missing notification configuration") } if (c.TLSCert == "") != (c.TLSKey == "") { @@ -49,13 +47,5 @@ func Load(path string) (Config, error) { if err = c.Queue.Validate(); err != nil { return c, err } - seen := map[string]bool{} - for caller, env := range c.Callers { - token := os.Getenv(env) - if strings.TrimSpace(caller) == "" || len(strings.TrimSpace(token)) < 32 || seen[token] { - return c, merrors.InvalidArgument("caller credentials must be unique and at least 32 bytes") - } - seen[token] = true - } return c, nil } diff --git a/backend/notifications/app/config_test.go b/backend/notifications/app/config_test.go index 5643ee7..424fbb7 100644 --- a/backend/notifications/app/config_test.go +++ b/backend/notifications/app/config_test.go @@ -3,12 +3,10 @@ package app import ( "os" "path/filepath" - "strings" "testing" ) func TestExampleConfigAndSecrets(t *testing.T) { - t.Setenv("SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN", strings.Repeat("a", 32)) c, err := Load("../config.example.yml") if err != nil { t.Fatal(err) @@ -16,11 +14,6 @@ func TestExampleConfigAndSecrets(t *testing.T) { if c.Email.Driver != "resend" || c.Messaging.Driver != "NATS" || c.Database.Driver != "mongodb" { t.Fatal("wrong driver configuration") } - t.Setenv("SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN", "") - if _, err = Load("../config.example.yml"); err == nil { - t.Fatal("missing credentials accepted") - } - t.Setenv("SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN", strings.Repeat("a", 32)) data, err := os.ReadFile("../config.example.yml") if err != nil { t.Fatal(err) @@ -35,7 +28,6 @@ func TestExampleConfigAndSecrets(t *testing.T) { } func TestPlaintextConfigAndIncompleteTLS(t *testing.T) { - t.Setenv("SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN", strings.Repeat("a", 32)) c, err := Load("../config.example.yml") if err != nil { t.Fatal(err) diff --git a/backend/notifications/app/run.go b/backend/notifications/app/run.go index 08b8110..118b0bb 100644 --- a/backend/notifications/app/run.go +++ b/backend/notifications/app/run.go @@ -4,7 +4,6 @@ import ( "context" "crypto/tls" "net" - "os" "sync" "time" @@ -63,11 +62,7 @@ func Run(ctx context.Context, c Config, logger mlogger.Logger) error { return err } defer broker.Close() - tokens := map[string]string{} - for caller, env := range c.Callers { - tokens[caller] = os.Getenv(env) - } - server := grpc.NewServer(append(options, grpc.UnaryInterceptor(transport.Authenticate(tokens)), grpc.MaxRecvMsgSize(128<<10), grpc.MaxConcurrentStreams(64))...) + server := grpc.NewServer(append(options, grpc.MaxRecvMsgSize(128<<10), grpc.MaxConcurrentStreams(64))...) pb.RegisterNotificationsServiceServer(server, transport.New(svc)) listener, err := net.Listen("tcp", c.Listen) if err != nil { diff --git a/backend/notifications/config.example.yml b/backend/notifications/config.example.yml index 02343a3..ff763e2 100644 --- a/backend/notifications/config.example.yml +++ b/backend/notifications/config.example.yml @@ -12,8 +12,6 @@ messaging: max_reconnects: -1 reconnect_wait: 2 templates: "templates.example.json" -callers: - product: "SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN" email: driver: resend # sendgrid or resend settings: diff --git a/backend/notifications/interface/README.md b/backend/notifications/interface/README.md index 52d2de9..d10824c 100644 --- a/backend/notifications/interface/README.md +++ b/backend/notifications/interface/README.md @@ -20,14 +20,14 @@ Internal Go consumers use the module `git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1`. Within this checkout, use a local replace to this directory and generate before building. -Calls require TLS and `authorization: Bearer ` metadata. The service -maps tokens to configured caller identities. Callers authorize user/destination +Calls use the trusted private network without caller authentication. The service +uses the existing product storage namespace. Callers authorize user/destination ownership and preferences before sending; this contract is not a public user API. Parameters are string values keyed by template parameter name. The entire request is validated before persistence. An identical idempotency key/request returns the original resource, even after completion or expiry. A changed request conflicts. -Keys are scoped to the authenticated caller and retained at least seven days; +Keys are scoped to the product namespace and retained at least seven days; Mongo TTL deletion is asynchronous. Expiry defaults to 24 hours and cannot exceed 24 hours. One email target is accepted; other declared channels return UNIMPLEMENTED. diff --git a/backend/notifications/interface/proto/v1/interface.proto b/backend/notifications/interface/proto/v1/interface.proto index e8821de..f962235 100644 --- a/backend/notifications/interface/proto/v1/interface.proto +++ b/backend/notifications/interface/proto/v1/interface.proto @@ -3,13 +3,13 @@ package sxchnge.notifications.v1; option go_package = "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1;notificationsv1"; import "google/protobuf/timestamp.proto"; -// Internal service: authenticate callers over TLS. Send durably queues work. +// Internal service on the trusted private network; no caller authentication. Send durably queues work. service NotificationsService { rpc Send(SendRequest) returns (SendResponse); rpc Get(GetRequest) returns (GetResponse); } message SendRequest { - string idempotency_key = 1; // Required; scoped to authenticated caller, retained seven days. + string idempotency_key = 1; // Required; scoped to the product namespace, retained seven days. string user_id = 2; // Caller resolves and authorizes recipient ownership. string template_id = 3; string template_version = 4; diff --git a/backend/notifications/service/grpc_test.go b/backend/notifications/service/grpc_test.go index 93f7f15..b46e3ca 100644 --- a/backend/notifications/service/grpc_test.go +++ b/backend/notifications/service/grpc_test.go @@ -1,7 +1,6 @@ package service_test import ( - "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" @@ -17,10 +16,9 @@ import ( "git.sendico.io/tech/sxchnge/notifications/transport" "google.golang.org/grpc" "google.golang.org/grpc/credentials" - "google.golang.org/grpc/metadata" ) -// Exercise authenticated TLS transport against the real service and storage. +// Exercise optional TLS transport against the real service and storage. func grpcClient(t *testing.T, svc *service.Service) pb.NotificationsServiceClient { t.Helper() key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) @@ -42,7 +40,7 @@ func grpcClient(t *testing.T, svc *service.Service) pb.NotificationsServiceClien if err != nil { t.Fatal(err) } - server := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}})), grpc.UnaryInterceptor(transport.Authenticate(map[string]string{"product": "product-test-token", "other": "other-test-token"}))) + server := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}}))) pb.RegisterNotificationsServiceServer(server, transport.New(svc)) go func() { _ = server.Serve(listener) }() t.Cleanup(server.Stop) @@ -53,6 +51,3 @@ func grpcClient(t *testing.T, svc *service.Service) pb.NotificationsServiceClien t.Cleanup(func() { _ = conn.Close() }) return pb.NewNotificationsServiceClient(conn) } -func callerContext(ctx context.Context, caller string) context.Context { - return metadata.NewOutgoingContext(ctx, metadata.Pairs("authorization", "Bearer "+caller+"-test-token")) -} diff --git a/backend/notifications/service/integration_test.go b/backend/notifications/service/integration_test.go index 599304e..0c3cfa7 100644 --- a/backend/notifications/service/integration_test.go +++ b/backend/notifications/service/integration_test.go @@ -26,8 +26,6 @@ import ( "github.com/testcontainers/testcontainers-go/modules/mongodb" "github.com/testcontainers/testcontainers-go/wait" "go.uber.org/zap" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" ) type sender struct { @@ -233,11 +231,8 @@ func TestDurableDelivery(t *testing.T) { } client := grpcClient(t, svc) - rpcCtx := callerContext(ctx, "product") + rpcCtx := ctx input := &pb.SendRequest{IdempotencyKey: "jetstream", UserId: "u", TemplateId: "test", TemplateVersion: "1", Parameters: map[string]string{"name": "End to end & escaped"}, Targets: []*pb.Target{{Destination: &pb.Target_Email{Email: &pb.EmailTarget{Address: "user@example.com"}}}}} - if _, e := client.Send(ctx, input); status.Code(e) != codes.Unauthenticated { - t.Fatal("unauthenticated request accepted", e) - } sent, err := client.Send(rpcCtx, input) if err != nil { t.Fatal(err) @@ -246,8 +241,8 @@ func TestDurableDelivery(t *testing.T) { if err != nil || replay.Notification.Id != sent.Notification.Id { t.Fatal("RPC idempotency failed", err) } - if _, e := client.Get(callerContext(ctx, "other"), &pb.GetRequest{Id: sent.Notification.Id}); status.Code(e) != codes.NotFound { - t.Fatal("RPC caller isolation failed", e) + if _, e := client.Get(ctx, &pb.GetRequest{Id: sent.Notification.Id}); e != nil { + t.Fatal("RPC retrieval without credentials failed", e) } if err = broker.PublishDue(ctx); err != nil { t.Fatal(err) diff --git a/backend/notifications/transport/server.go b/backend/notifications/transport/server.go index 57b1fe7..818fee3 100644 --- a/backend/notifications/transport/server.go +++ b/backend/notifications/transport/server.go @@ -2,21 +2,15 @@ package transport import ( "context" - "crypto/sha256" - "crypto/subtle" "errors" - "strings" pb "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1" "git.sendico.io/tech/sxchnge/notifications/model" merrors "git.sendico.io/tech/sxchnge/shared/errors" - "google.golang.org/grpc" "google.golang.org/grpc/codes" - "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" ) -type callerKey struct{} type API interface { Send(context.Context, string, model.Request) (*model.Notification, error) Get(context.Context, string, string) (*model.Notification, error) @@ -26,14 +20,14 @@ type Server struct { service API } +// The private endpoint uses the existing product storage namespace without caller authentication. func New(service API) *Server { return &Server{service: service} } func (s *Server) Send(ctx context.Context, r *pb.SendRequest) (*pb.SendResponse, error) { input, err := request(r) if err != nil { return nil, rpcError(err) } - caller, _ := ctx.Value(callerKey{}).(string) - n, err := s.service.Send(ctx, caller, input) + n, err := s.service.Send(ctx, "product", input) if err != nil { return nil, rpcError(err) } @@ -43,39 +37,13 @@ func (s *Server) Get(ctx context.Context, r *pb.GetRequest) (*pb.GetResponse, er if r == nil { return nil, status.Error(codes.InvalidArgument, "missing request") } - caller, _ := ctx.Value(callerKey{}).(string) - n, err := s.service.Get(ctx, caller, r.Id) + n, err := s.service.Get(ctx, "product", r.Id) if err != nil { return nil, rpcError(err) } return &pb.GetResponse{Notification: response(n)}, nil } -// Tokens are mapped to stable server-configured caller identities, never supplied IDs. -func Authenticate(tokens map[string]string) grpc.UnaryServerInterceptor { - hashes := map[string][32]byte{} - for caller, token := range tokens { - hashes[caller] = sha256.Sum256([]byte(token)) - } - return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { - md, _ := metadata.FromIncomingContext(ctx) - values := md.Get("authorization") - if len(values) != 1 || !strings.HasPrefix(values[0], "Bearer ") { - return nil, status.Error(codes.Unauthenticated, "invalid service credentials") - } - hash := sha256.Sum256([]byte(strings.TrimPrefix(values[0], "Bearer "))) - identity := "" - for caller, expected := range hashes { - if subtle.ConstantTimeCompare(hash[:], expected[:]) == 1 { - identity = caller - } - } - if identity == "" { - return nil, status.Error(codes.Unauthenticated, "invalid service credentials") - } - return handler(context.WithValue(ctx, callerKey{}, identity), req) - } -} func rpcError(err error) error { if errors.Is(err, context.Canceled) { return status.Error(codes.Canceled, "notification operation canceled") diff --git a/backend/notifications/transport/server_test.go b/backend/notifications/transport/server_test.go index f6cc70b..f6ea23a 100644 --- a/backend/notifications/transport/server_test.go +++ b/backend/notifications/transport/server_test.go @@ -15,7 +15,6 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials/insecure" - "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" "google.golang.org/grpc/test/bufconn" ) @@ -31,10 +30,9 @@ func (fakeAPI) Send(_ context.Context, caller string, r model.Request) (*model.N func (fakeAPI) Get(context.Context, string, string) (*model.Notification, error) { return nil, merrors.NoData("private details") } -func TestGRPCAuthenticationAndMapping(t *testing.T) { +func TestGRPCWithoutAuthenticationAndMapping(t *testing.T) { listener := bufconn.Listen(1 << 20) - token := strings.Repeat("a", 32) - server := grpc.NewServer(grpc.UnaryInterceptor(Authenticate(map[string]string{"product": token}))) + server := grpc.NewServer() pb.RegisterNotificationsServiceServer(server, New(fakeAPI{})) go func() { _ = server.Serve(listener) }() defer server.Stop() @@ -45,10 +43,7 @@ func TestGRPCAuthenticationAndMapping(t *testing.T) { defer conn.Close() client := pb.NewNotificationsServiceClient(conn) req := &pb.SendRequest{UserId: "u", Targets: []*pb.Target{{Destination: &pb.Target_Email{Email: &pb.EmailTarget{Address: "user@example.com"}}}}} - if _, err = client.Send(context.Background(), req); status.Code(err) != codes.Unauthenticated { - t.Fatal(err) - } - ctx := metadata.NewOutgoingContext(context.Background(), metadata.Pairs("authorization", "Bearer "+token)) + ctx := context.Background() result, err := client.Send(ctx, req) if err != nil { t.Fatal(err) diff --git a/backend/product/app/runtime/bootstrap_test.go b/backend/product/app/runtime/bootstrap_test.go index bbacf2c..9b66a61 100644 --- a/backend/product/app/runtime/bootstrap_test.go +++ b/backend/product/app/runtime/bootstrap_test.go @@ -48,7 +48,6 @@ func TestProfileStartsOnEmptyDatabase(t *testing.T) { t.Setenv("SXCHNGE_API_PROTOCOL", "http") t.Setenv("SXCHNGE_API_ENDPOINT", "/api/v1") t.Setenv("SXCHNGE_JWT_SECRET", "test-only-signing-key-at-least-32-bytes") - t.Setenv("SXCHNGE_NOTIFICATIONS_TOKEN", "test-notification-token-at-least-32-bytes") example.API.Notifications.CAFile = "" // This startup test makes no notification RPCs. root, routes := chi.NewRouter(), chi.NewRouter() runtime, err := sharedruntime.CreateAPI(ctx, zap.NewNop(), &example.API, &example.API, factory, root, routes, false, profile.Runtime()) diff --git a/backend/product/config.example.yml b/backend/product/config.example.yml index 2a49aef..b455b11 100644 --- a/backend/product/config.example.yml +++ b/backend/product/config.example.yml @@ -33,5 +33,4 @@ api: min_length: 12 notifications: address: localhost:8083 - token_env: SXCHNGE_NOTIFICATIONS_TOKEN activation_url: http://localhost:5126/verify-email diff --git a/backend/product/notifications/client.go b/backend/product/notifications/client.go index 9174f79..79c2756 100644 --- a/backend/product/notifications/client.go +++ b/backend/product/notifications/client.go @@ -13,13 +13,11 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" - "google.golang.org/grpc/metadata" "google.golang.org/protobuf/types/known/timestamppb" ) type Config struct { Address string `yaml:"address"` - TokenEnv string `yaml:"token_env"` CAFile string `yaml:"ca_file"` ServerName string `yaml:"server_name"` ActivationURL string `yaml:"activation_url"` @@ -33,15 +31,13 @@ type Sender interface { Send(context.Context, Message) error } type Client struct { - conn *grpc.ClientConn - rpc pb.NotificationsServiceClient - token string + conn *grpc.ClientConn + rpc pb.NotificationsServiceClient } func Open(c Config) (*Client, error) { - token := os.Getenv(c.TokenEnv) - if c.Address == "" || len(token) < 32 { - return nil, merrors.InvalidArgument("notification address and caller token of at least 32 bytes are required") + if c.Address == "" { + return nil, merrors.InvalidArgument("notification address is required") } tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12, ServerName: c.ServerName} if c.CAFile != "" { @@ -66,13 +62,12 @@ func Open(c Config) (*Client, error) { if err != nil { return nil, merrors.InternalWrap(err, "create notification client") } - return &Client{conn: conn, rpc: pb.NewNotificationsServiceClient(conn), token: token}, nil + return &Client{conn: conn, rpc: pb.NewNotificationsServiceClient(conn)}, nil } func (c *Client) Close() error { return c.conn.Close() } func (c *Client) Send(ctx context.Context, m Message) error { ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() - ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+c.token) res, err := c.rpc.Send(ctx, request(m)) if err != nil { return merrors.ServiceUnavailableWrap(err, "notification queue unavailable") diff --git a/backend/product/notifications/client_test.go b/backend/product/notifications/client_test.go index bc7f5fb..2ba9e99 100644 --- a/backend/product/notifications/client_test.go +++ b/backend/product/notifications/client_test.go @@ -23,7 +23,7 @@ import ( type rpcStub struct { pb.NotificationsServiceClient request *pb.SendRequest - auth string + auth []string result *pb.SendResponse err error } @@ -31,16 +31,16 @@ type rpcStub struct { func (s *rpcStub) Send(ctx context.Context, r *pb.SendRequest, _ ...grpc.CallOption) (*pb.SendResponse, error) { s.request = r md, _ := metadata.FromOutgoingContext(ctx) - s.auth = md.Get("authorization")[0] + s.auth = md.Get("authorization") return s.result, s.err } func TestSendMapsOwnedContractAndRejectsFailedReceipt(t *testing.T) { rpc := &rpcStub{result: &pb.SendResponse{Notification: &pb.Notification{Id: "job", Deliveries: []*pb.Delivery{{State: pb.DeliveryState_DELIVERY_STATE_QUEUED}}}}} - client := &Client{rpc: rpc, token: "service-secret"} + client := &Client{rpc: rpc} expiry := time.Now().Add(time.Minute) m := Message{Key: "key", UserID: "customer", Email: "user@example.com", Locale: "ru", Template: "auth.login", Parameters: map[string]string{"code": "123456"}, ExpiresAt: expiry} require.NoError(t, client.Send(context.Background(), m)) - require.Equal(t, "Bearer service-secret", rpc.auth) + require.Empty(t, rpc.auth) require.Equal(t, m.Email, rpc.request.Targets[0].GetEmail().Address) require.Equal(t, "1", rpc.request.TemplateVersion) require.Equal(t, m.Parameters, rpc.request.Parameters) @@ -50,57 +50,54 @@ func TestSendMapsOwnedContractAndRejectsFailedReceipt(t *testing.T) { rpc.result = nil require.ErrorIs(t, client.Send(context.Background(), m), merrors.ErrServiceUnavailable) } -func TestOpenRequiresCredentialsAndValidCA(t *testing.T) { - _, err := Open(Config{Address: "localhost:8443", TokenEnv: "ABSENT_NOTIFICATION_TEST_TOKEN"}) +func TestOpenRequiresAddressAndValidCA(t *testing.T) { + _, err := Open(Config{}) require.ErrorIs(t, err, merrors.ErrInvalidArg) - t.Setenv("TEST_NOTIFICATION_TOKEN", "test-token-that-is-at-least-32-bytes") - _, err = Open(Config{Address: "localhost:8443", TokenEnv: "TEST_NOTIFICATION_TOKEN", CAFile: "missing.pem"}) + _, err = Open(Config{Address: "localhost:8443", CAFile: "missing.pem"}) require.Error(t, err) } type tlsRPC struct { pb.UnimplementedNotificationsServiceServer - auth chan string + auth chan []string } func (s *tlsRPC) Send(ctx context.Context, _ *pb.SendRequest) (*pb.SendResponse, error) { md, _ := metadata.FromIncomingContext(ctx) - s.auth <- md.Get("authorization")[0] + s.auth <- md.Get("authorization") return &pb.SendResponse{Notification: &pb.Notification{Id: "tls-job", Deliveries: []*pb.Delivery{{State: pb.DeliveryState_DELIVERY_STATE_QUEUED}}}}, nil } -func TestOpenUsesVerifiedTLSAndCallerAuthentication(t *testing.T) { +func TestOpenUsesVerifiedTLSWithoutCallerToken(t *testing.T) { fixture := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) cert := fixture.TLS.Certificates[0] fixture.Close() listener, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) server := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}))) - rpc := &tlsRPC{auth: make(chan string, 1)} + rpc := &tlsRPC{auth: make(chan []string, 1)} pb.RegisterNotificationsServiceServer(server, rpc) go func() { _ = server.Serve(listener) }() t.Cleanup(server.Stop) ca := filepath.Join(t.TempDir(), "ca.pem") require.NoError(t, os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Certificate[0]}), 0600)) - t.Setenv("TEST_TLS_CALLER_TOKEN", "test-service-token-with-at-least-32-bytes") - client, err := Open(Config{Address: listener.Addr().String(), TokenEnv: "TEST_TLS_CALLER_TOKEN", CAFile: ca}) + client, err := Open(Config{Address: listener.Addr().String(), CAFile: ca}) require.NoError(t, err) t.Cleanup(func() { _ = client.Close() }) require.NoError(t, client.Send(context.Background(), Message{ExpiresAt: time.Now().Add(time.Minute)})) - require.Equal(t, "Bearer test-service-token-with-at-least-32-bytes", <-rpc.auth) + require.Empty(t, <-rpc.auth) } -func TestOpenUsesPlaintextAndCallerTokenOnPrivateNetwork(t *testing.T) { +func TestOpenUsesPlaintextWithoutCallerToken(t *testing.T) { listener, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) server := grpc.NewServer() - rpc := &tlsRPC{auth: make(chan string, 1)} + rpc := &tlsRPC{auth: make(chan []string, 1)} pb.RegisterNotificationsServiceServer(server, rpc) go func() { _ = server.Serve(listener) }() t.Cleanup(server.Stop) - t.Setenv("TEST_PLAIN_CALLER_TOKEN", "test-private-service-token-at-least-32-bytes") - client, err := Open(Config{Address: listener.Addr().String(), TokenEnv: "TEST_PLAIN_CALLER_TOKEN"}) + client, err := Open(Config{Address: listener.Addr().String()}) require.NoError(t, err) t.Cleanup(func() { _ = client.Close() }) require.NoError(t, client.Send(context.Background(), Message{ExpiresAt: time.Now().Add(time.Minute)})) - require.Equal(t, "Bearer test-private-service-token-at-least-32-bytes", <-rpc.auth) + require.Empty(t, <-rpc.auth) } diff --git a/docs/auth-notifications.md b/docs/auth-notifications.md index c0cf038..6467100 100644 --- a/docs/auth-notifications.md +++ b/docs/auth-notifications.md @@ -16,23 +16,21 @@ changes under a new revision rather than mutating deployed content. Configure `api.notifications` in the product YAML: - `address`: internal notifications gRPC address. -- `token_env`: environment variable containing the configured caller's token - (at least 32 bytes); example `SXCHNGE_NOTIFICATIONS_TOKEN`. -- `ca_file`: trusted PEM CA bundle; omit to use system roots. +- `ca_file`: optional trusted PEM CA bundle for explicit TLS. Omit both TLS settings for plaintext. - `server_name`: optional TLS certificate hostname override, never a TLS bypass. - `activation_url`: absolute frontend `/verify-email` URL. HTTPS is required, except HTTP on localhost/127.0.0.1 for development. No user-supplied redirect is accepted. The token is appended as a query parameter. -The caller token must map to the product caller in the notification service's -configuration. Keep Mongo, notification caller, TLS and provider credentials out +The private notifications endpoint uses the existing product storage namespace +without caller authentication. Keep Mongo, TLS and provider credentials out of Git. JWT signing configuration must contain at least 32 bytes of private key material; HMAC-derived issuance keys depend on this key. Rotation changes replay keys and existing pending JWT validity. Omission of `api.notifications` preserves the bootstrap-only legacy routes; it does not enable signup or code completion. -The example config enables the integration and therefore requires credentials. +The example config enables the integration; configure Mongo, NATS and email credentials separately. -The notification service must be reachable over TLS, with its Mongo/NATS/email +The notification service must be reachable on the private network, with its Mongo/NATS/email provider configured. Product startup creates a lazy gRPC channel; dependency outages appear as HTTP 503 when attempting delivery. A successful Send means persisted queue acceptance, not inbox delivery or a provider receipt. diff --git a/docs/implementation.md b/docs/implementation.md index bf0f3a8..44f090d 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -766,3 +766,25 @@ Validation passed: image build, shell/migration tests, live infrastructure check `make up` intentionally fails for the still-missing API key. Verified sender must also be configured in runtime YAML before rebuilding and starting notifications. No backend source changed and no email was sent. + +### Task #42: remove internal notification caller authentication + +Removed BFF bearer metadata, token configuration/validation, and the notifications +authentication interceptor. The private gRPC endpoint uses the existing `product` +storage namespace, preserving notification receipts and idempotency without a +database migration. NATS authentication and application/user authentication remain. + +Removed the caller secret from bootstrap, Vault Agent templates and policies. +Bootstrap archives and retires both `caller` and +`services/notifications/callers/bff`; live Vault no longer contains either path. +Service policies deny both paths. The retirement regression fixture verifies +repeatability and preservation of the NATS authentication secret. + +Validation: `make build`, `make test`, `make lint-backend`, +`make test-dev-tooling`, `make test-dev-infra`, and `git diff --check`. +Tests cover credential-free Send/Get, idempotent replay through the real service, +and absence of outgoing bearer metadata over plaintext and optional TLS. +`make up` applied the live migration and intentionally stopped at the missing +Resend API key. No email was sent; full product deployment still awaits the API +key and configured sender. Delivery continues on `dev-env-42` in +[PR #43](https://git.sendico.io/tech/sxchnge/pulls/43). diff --git a/docs/local-development.md b/docs/local-development.md index 3c0ee72..3233008 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -39,7 +39,6 @@ kv/ ├── services/ │ ├── bff/jwt # jwt │ └── notifications/ -│ ├── callers/bff # token; read by BFF and notifications │ └── email/resend # api_key only └── infrastructure/ ├── nats/auth # token; read by NATS and notifications @@ -56,7 +55,7 @@ password is rotated immediately after initial configuration. There are no frontend certificates, notification certificates or generic `tls` entries in KV. Browser certificates belong to Caddy; internal gRPC is plaintext -on the private Docker network and retains caller-token authentication. +on the trusted private Docker network without caller authentication. ## Resend @@ -114,7 +113,7 @@ Trust installation is not automated. A CLI check can use: curl --cacert .local/dev/ca.pem https://localhost:5126/api/v1/health ``` -BFF calls notifications over plaintext gRPC with its caller token. Mongo and NATS +BFF calls notifications over plaintext gRPC without caller authentication. Mongo and NATS also stay within the private Compose network; no internal service publishes a host port. Only frontend and Vault bind host loopback ports. TLS remains optional in the reusable notification client/server for deployments that explicitly configure @@ -143,7 +142,7 @@ still interrupt work. Repository consumers use `source.Source` operation scopes; ## Migration and persistence -Bootstrap migrates the old flat `bff`, `caller`, `email` and `nats` paths into the +Bootstrap migrates the old flat `bff`, `email` and `nats` paths into the hierarchy above, and moves `bootstrap/mongo` to `infrastructure/mongo/bootstrap`. It copies with CAS protection, compares values, and refuses to overwrite conflicting destinations. After consumers use the new paths, it removes @@ -178,3 +177,6 @@ and requires three replacements with unchanged BFF PID and zero unexpected transaction-backed HTTP responses. It restores TTLs and removes services it started. No verification command sends email. Backend validation remains `make test` and `make lint-backend`, using the host SDKs documented in module guides. + +Bootstrap archives and removes obsolete `caller` and `services/notifications/callers/bff` +KV entries after updating Agent policies/templates. NATS authentication remains enabled. diff --git a/infra/local/bff/runtime.yml b/infra/local/bff/runtime.yml index e399832..1095f8e 100644 --- a/infra/local/bff/runtime.yml +++ b/infra/local/bff/runtime.yml @@ -38,5 +38,4 @@ api: min_length: 12 notifications: address: notifications:8083 - token_env: SXCHNGE_NOTIFICATIONS_TOKEN activation_url: https://localhost:5126/verify-email diff --git a/infra/local/notifications/runtime.yml b/infra/local/notifications/runtime.yml index 80e5e78..0e45830 100644 --- a/infra/local/notifications/runtime.yml +++ b/infra/local/notifications/runtime.yml @@ -17,8 +17,6 @@ messaging: max_reconnects: -1 reconnect_wait: 2 templates: "/etc/sxchnge/templates.json" -callers: - product: "SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN" email: driver: resend settings: diff --git a/infra/local/scripts/bootstrap.sh b/infra/local/scripts/bootstrap.sh index e5dd618..e12765e 100644 --- a/infra/local/scripts/bootstrap.sh +++ b/infra/local/scripts/bootstrap.sh @@ -58,8 +58,8 @@ provision_agents() { directory="$STATE/agents/$service" mkdir -p "$directory"; chmod 755 "$directory" case "$service" in - bff) paths='services/bff/jwt services/notifications/callers/bff';; - notifications) paths='services/notifications/callers/bff infrastructure/nats/auth services/notifications/email/resend';; + bff) paths='services/bff/jwt';; + notifications) paths='infrastructure/nats/auth services/notifications/email/resend';; nats) paths=infrastructure/nats/auth;; esac : > "$WORK/policy" @@ -92,7 +92,7 @@ up() { initialize mounts migrate_secrets - for path in infrastructure/nats/auth services/notifications/callers/bff services/bff/jwt; do + for path in infrastructure/nats/auth services/bff/jwt; do if [ "$(kv "$path")" = null ]; then field=token; [ "$path" != services/bff/jwt ] || field=jwt random | jq -Rs --arg field "$field" '{($field):rtrimstr("\n")}' | put "$path" diff --git a/infra/local/scripts/secrets.sh b/infra/local/scripts/secrets.sh index 1989442..b6b01c8 100644 --- a/infra/local/scripts/secrets.sh +++ b/infra/local/scripts/secrets.sh @@ -1,8 +1,7 @@ -# Canonical paths follow ownership. Shared callers live under the receiving service. +# Canonical paths follow ownership. secret_moves() { cat <<'PATHS' bff services/bff/jwt -caller services/notifications/callers/bff email services/notifications/email/resend nats infrastructure/nats/auth bootstrap/mongo infrastructure/mongo/bootstrap @@ -47,7 +46,7 @@ retire_secrets() ( archive_secret "$old" printf '{}' | vault "kv/metadata/$old" DELETE >/dev/null done < "$WORK/moves" - for old in tls frontend-tls notifications-tls; do + for old in caller services/notifications/callers/bff tls frontend-tls notifications-tls; do archive_secret "$old" if [ "$(vault "kv/metadata/$old" GET yes)" != null ]; then printf '{}' | vault "kv/metadata/$old" DELETE >/dev/null; fi done diff --git a/infra/local/scripts/test.sh b/infra/local/scripts/test.sh index 2dd2f13..ddcede0 100644 --- a/infra/local/scripts/test.sh +++ b/infra/local/scripts/test.sh @@ -74,3 +74,22 @@ printf '{"data":{"metadata":{"version":43},"data":{"api_key":"preserved-fixture- if (normalize_email_secret) >/dev/null 2>&1; then fail 'Unknown secret field was silently dropped'; fi if grep -q EMAIL_CONFIG infra/local/vault/notifications.hcl infra/local/scripts/start-service.sh; then fail 'Email configuration is still rendered from Vault'; fi echo 'PASS: email migration preserves API key, removes configuration fields and rejects unknown fields' +# Caller retirement is repeatable and must not remove NATS authentication. +secret_moves() { :; } +printf '{"caller":true,"services/notifications/callers/bff":true,"infrastructure/nats/auth":true}' > "$WORK/retirement" +vault() { + path=${1#kv/metadata/} + case "${2:-GET}" in + GET) jq -c --arg path "$path" '.[$path] // null' "$WORK/retirement";; + DELETE) + case "$path" in caller|services/notifications/callers/bff) ;; *) fail 'Unexpected secret retirement';; esac + jq --arg path "$path" 'del(.[$path])' "$WORK/retirement" > "$WORK/retirement-next" + mv "$WORK/retirement-next" "$WORK/retirement" + ;; + *) fail 'Unexpected retirement method';; + esac +} +retire_secrets +retire_secrets +jq -e '.=={"infrastructure/nats/auth":true}' "$WORK/retirement" >/dev/null +echo 'PASS: obsolete caller secrets retired idempotently; NATS authentication preserved' diff --git a/infra/local/scripts/verify.sh b/infra/local/scripts/verify.sh index d171fe4..7aaf702 100644 --- a/infra/local/scripts/verify.sh +++ b/infra/local/scripts/verify.sh @@ -6,7 +6,7 @@ policies() ( directory="$STATE/agents/$service" login=$(jq -n --rawfile role "$directory/role-id" --rawfile secret "$directory/secret-id" '{role_id:($role|rtrimstr("\n")),secret_id:($secret|rtrimstr("\n"))}' | vault auth/approle/login POST) identity=$(printf '%s' "$login" | jq -er '.auth.client_token') - printf '%s' "$login" | jq '{token:.auth.client_token,paths:["kv/data/bootstrap/mongo","kv/data/infrastructure/mongo/bootstrap","sys/init","kv/data/bff","kv/data/caller","kv/data/email","kv/data/nats","kv/data/tls"]}' | vault sys/capabilities POST | jq -e '.data | all(.[]; .==["deny"])' >/dev/null || fail 'Service can access bootstrap credentials' + printf '%s' "$login" | jq '{token:.auth.client_token,paths:["kv/data/bootstrap/mongo","kv/data/infrastructure/mongo/bootstrap","sys/init","kv/data/bff","kv/data/caller","kv/data/services/notifications/callers/bff","kv/data/email","kv/data/nats","kv/data/tls"]}' | vault sys/capabilities POST | jq -e '.data | all(.[]; .==["deny"])' >/dev/null || fail 'Service can access bootstrap credentials' VAULT_TOKEN=$(printf '%s' "$login" | jq -er '.auth.client_token') printf '{}' | vault auth/token/revoke-self POST >/dev/null VAULT_TOKEN=$root_token @@ -51,10 +51,10 @@ product() { } layout() { kv services/notifications/email/resend | jq -e 'keys==["api_key"]' >/dev/null || fail 'Non-secret email configuration remains in Vault' - for old in bootstrap/mongo bff caller email nats tls frontend-tls notifications-tls; do + for old in bootstrap/mongo bff caller services/notifications/callers/bff email nats tls frontend-tls notifications-tls; do [ "$(vault "kv/metadata/$old" GET yes)" = null ] || fail "Obsolete KV entry remains: $old" done - for path in services/bff/jwt services/notifications/callers/bff services/notifications/email/resend infrastructure/nats/auth infrastructure/mongo/bootstrap; do + for path in services/bff/jwt services/notifications/email/resend infrastructure/nats/auth infrastructure/mongo/bootstrap; do vault "kv/metadata/$path" | jq -e '.data.current_version>0' >/dev/null || fail "Missing canonical KV entry: $path" done echo 'PASS: canonical Vault hierarchy present; obsolete flat and TLS entries absent' diff --git a/infra/local/vault/bff.hcl b/infra/local/vault/bff.hcl index 1b1eebe..8b1b9f6 100644 --- a/infra/local/vault/bff.hcl +++ b/infra/local/vault/bff.hcl @@ -19,10 +19,6 @@ env_template "SXCHNGE_JWT_SECRET" { error_on_missing_key = true } -env_template "SXCHNGE_NOTIFICATIONS_TOKEN" { - contents = "{{ with secret \"kv/data/services/notifications/callers/bff\" }}{{ .Data.data.token }}{{ end }}" - error_on_missing_key = true -} exec { command = ["/usr/local/bin/start-service"] diff --git a/infra/local/vault/notifications.hcl b/infra/local/vault/notifications.hcl index 0708ff6..7e332ff 100644 --- a/infra/local/vault/notifications.hcl +++ b/infra/local/vault/notifications.hcl @@ -14,10 +14,6 @@ template_config { } -env_template "SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN" { - contents = "{{ with secret \"kv/data/services/notifications/callers/bff\" }}{{ .Data.data.token }}{{ end }}" - error_on_missing_key = true -} env_template "SXCHNGE_NATS_URL" { contents = "{{ with secret \"kv/data/infrastructure/nats/auth\" }}nats://{{ .Data.data.token }}@nats:4222{{ end }}"