Run local infrastructure and product images with Vault-managed secrets #43

Merged
tech merged 12 commits from dev-env-42 into main 2026-09-13 08:35:02 +00:00
27 changed files with 112 additions and 165 deletions
Showing only changes of commit caad7a3d5b - Show all commits

No files matched your search

+3 -2
View File
@@ -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 configuration and shell tooling under `infra/local/`. Keep product source under
`backend/` and `frontend/`. Organize Vault KV paths by owning service under `backend/` and `frontend/`. Organize Vault KV paths by owning service under
`services/<service>/<purpose>`, infrastructure under `infrastructure/<component>/<purpose>`, `services/<service>/<purpose>`, infrastructure under `infrastructure/<component>/<purpose>`,
and bootstrap administration under `infrastructure/<component>/bootstrap`. Caller credentials and bootstrap administration under `infrastructure/<component>/bootstrap`.
belong to the receiving service at `services/<service>/callers/<caller>`. 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 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 Docker network as trusted; do not add internal gRPC TLS or Vault-managed
local TLS certificates without an explicit requirement. local TLS certificates without an explicit requirement.
+1 -2
View File
@@ -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 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. 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 Set `SXCHNGE_MONGO_URI`, `SXCHNGE_NATS_URL` and the selected email provider's API key environment
each configured caller, and the selected email provider's API key environment
variable. Configure a verified sender address and template variable. Configure a verified sender address and template
catalog. Paths are relative to the process working directory. catalog. Paths are relative to the process working directory.
+9 -19
View File
@@ -4,7 +4,6 @@ import (
"bytes" "bytes"
"io" "io"
"os" "os"
"strings"
"git.sendico.io/tech/sxchnge/notifications/bus" "git.sendico.io/tech/sxchnge/notifications/bus"
"git.sendico.io/tech/sxchnge/notifications/email" "git.sendico.io/tech/sxchnge/notifications/email"
@@ -15,15 +14,14 @@ import (
) )
type Config struct { type Config struct {
Queue bus.Config `yaml:"queue"` Queue bus.Config `yaml:"queue"`
Messaging messaging.Config `yaml:"messaging"` Messaging messaging.Config `yaml:"messaging"`
Listen string `yaml:"listen"` Listen string `yaml:"listen"`
TLSCert string `yaml:"tls_cert"` TLSCert string `yaml:"tls_cert"`
TLSKey string `yaml:"tls_key"` TLSKey string `yaml:"tls_key"`
Database db.Config `yaml:"database"` Database db.Config `yaml:"database"`
Templates string `yaml:"templates"` Templates string `yaml:"templates"`
Callers map[string]string `yaml:"callers"` // caller -> token environment variable Email email.Config `yaml:"email"`
Email email.Config `yaml:"email"`
} }
func Load(path string) (Config, error) { 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 { if err = decoder.Decode(new(any)); err != io.EOF {
return c, merrors.InvalidArgument("unexpected configuration content") 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") return c, merrors.InvalidArgument("missing notification configuration")
} }
if (c.TLSCert == "") != (c.TLSKey == "") { if (c.TLSCert == "") != (c.TLSKey == "") {
@@ -49,13 +47,5 @@ func Load(path string) (Config, error) {
if err = c.Queue.Validate(); err != nil { if err = c.Queue.Validate(); err != nil {
return c, err 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 return c, nil
} }
-8
View File
@@ -3,12 +3,10 @@ package app
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
func TestExampleConfigAndSecrets(t *testing.T) { func TestExampleConfigAndSecrets(t *testing.T) {
t.Setenv("SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN", strings.Repeat("a", 32))
c, err := Load("../config.example.yml") c, err := Load("../config.example.yml")
if err != nil { if err != nil {
t.Fatal(err) 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" { if c.Email.Driver != "resend" || c.Messaging.Driver != "NATS" || c.Database.Driver != "mongodb" {
t.Fatal("wrong driver configuration") 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") data, err := os.ReadFile("../config.example.yml")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -35,7 +28,6 @@ func TestExampleConfigAndSecrets(t *testing.T) {
} }
func TestPlaintextConfigAndIncompleteTLS(t *testing.T) { func TestPlaintextConfigAndIncompleteTLS(t *testing.T) {
t.Setenv("SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN", strings.Repeat("a", 32))
c, err := Load("../config.example.yml") c, err := Load("../config.example.yml")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
+1 -6
View File
@@ -4,7 +4,6 @@ import (
"context" "context"
"crypto/tls" "crypto/tls"
"net" "net"
"os"
"sync" "sync"
"time" "time"
@@ -63,11 +62,7 @@ func Run(ctx context.Context, c Config, logger mlogger.Logger) error {
return err return err
} }
defer broker.Close() defer broker.Close()
tokens := map[string]string{} server := grpc.NewServer(append(options, grpc.MaxRecvMsgSize(128<<10), grpc.MaxConcurrentStreams(64))...)
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))...)
pb.RegisterNotificationsServiceServer(server, transport.New(svc)) pb.RegisterNotificationsServiceServer(server, transport.New(svc))
listener, err := net.Listen("tcp", c.Listen) listener, err := net.Listen("tcp", c.Listen)
if err != nil { if err != nil {
-2
View File
@@ -12,8 +12,6 @@ messaging:
max_reconnects: -1 max_reconnects: -1
reconnect_wait: 2 reconnect_wait: 2
templates: "templates.example.json" templates: "templates.example.json"
callers:
product: "SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN"
email: email:
driver: resend # sendgrid or resend driver: resend # sendgrid or resend
settings: settings:
+3 -3
View File
@@ -20,14 +20,14 @@ Internal Go consumers use the module
`git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1`. Within this `git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1`. Within this
checkout, use a local replace to this directory and generate before building. checkout, use a local replace to this directory and generate before building.
Calls require TLS and `authorization: Bearer <service-token>` metadata. The service Calls use the trusted private network without caller authentication. The service
maps tokens to configured caller identities. Callers authorize user/destination uses the existing product storage namespace. Callers authorize user/destination
ownership and preferences before sending; this contract is not a public user API. 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 Parameters are string values keyed by template parameter name. The entire request
is validated before persistence. An identical idempotency key/request returns the is validated before persistence. An identical idempotency key/request returns the
original resource, even after completion or expiry. A changed request conflicts. 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 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. 24 hours. One email target is accepted; other declared channels return UNIMPLEMENTED.
@@ -3,13 +3,13 @@ package sxchnge.notifications.v1;
option go_package = "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1;notificationsv1"; option go_package = "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1;notificationsv1";
import "google/protobuf/timestamp.proto"; 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 { service NotificationsService {
rpc Send(SendRequest) returns (SendResponse); rpc Send(SendRequest) returns (SendResponse);
rpc Get(GetRequest) returns (GetResponse); rpc Get(GetRequest) returns (GetResponse);
} }
message SendRequest { 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 user_id = 2; // Caller resolves and authorizes recipient ownership.
string template_id = 3; string template_id = 3;
string template_version = 4; string template_version = 4;
+2 -7
View File
@@ -1,7 +1,6 @@
package service_test package service_test
import ( import (
"context"
"crypto/ecdsa" "crypto/ecdsa"
"crypto/elliptic" "crypto/elliptic"
"crypto/rand" "crypto/rand"
@@ -17,10 +16,9 @@ import (
"git.sendico.io/tech/sxchnge/notifications/transport" "git.sendico.io/tech/sxchnge/notifications/transport"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/credentials" "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 { func grpcClient(t *testing.T, svc *service.Service) pb.NotificationsServiceClient {
t.Helper() t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 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 { if err != nil {
t.Fatal(err) 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)) pb.RegisterNotificationsServiceServer(server, transport.New(svc))
go func() { _ = server.Serve(listener) }() go func() { _ = server.Serve(listener) }()
t.Cleanup(server.Stop) t.Cleanup(server.Stop)
@@ -53,6 +51,3 @@ func grpcClient(t *testing.T, svc *service.Service) pb.NotificationsServiceClien
t.Cleanup(func() { _ = conn.Close() }) t.Cleanup(func() { _ = conn.Close() })
return pb.NewNotificationsServiceClient(conn) return pb.NewNotificationsServiceClient(conn)
} }
func callerContext(ctx context.Context, caller string) context.Context {
return metadata.NewOutgoingContext(ctx, metadata.Pairs("authorization", "Bearer "+caller+"-test-token"))
}
@@ -26,8 +26,6 @@ import (
"github.com/testcontainers/testcontainers-go/modules/mongodb" "github.com/testcontainers/testcontainers-go/modules/mongodb"
"github.com/testcontainers/testcontainers-go/wait" "github.com/testcontainers/testcontainers-go/wait"
"go.uber.org/zap" "go.uber.org/zap"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
) )
type sender struct { type sender struct {
@@ -233,11 +231,8 @@ func TestDurableDelivery(t *testing.T) {
} }
client := grpcClient(t, svc) 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"}}}}} 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) sent, err := client.Send(rpcCtx, input)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -246,8 +241,8 @@ func TestDurableDelivery(t *testing.T) {
if err != nil || replay.Notification.Id != sent.Notification.Id { if err != nil || replay.Notification.Id != sent.Notification.Id {
t.Fatal("RPC idempotency failed", err) t.Fatal("RPC idempotency failed", err)
} }
if _, e := client.Get(callerContext(ctx, "other"), &pb.GetRequest{Id: sent.Notification.Id}); status.Code(e) != codes.NotFound { if _, e := client.Get(ctx, &pb.GetRequest{Id: sent.Notification.Id}); e != nil {
t.Fatal("RPC caller isolation failed", e) t.Fatal("RPC retrieval without credentials failed", e)
} }
if err = broker.PublishDue(ctx); err != nil { if err = broker.PublishDue(ctx); err != nil {
t.Fatal(err) t.Fatal(err)
+3 -35
View File
@@ -2,21 +2,15 @@ package transport
import ( import (
"context" "context"
"crypto/sha256"
"crypto/subtle"
"errors" "errors"
"strings"
pb "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1" pb "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1"
"git.sendico.io/tech/sxchnge/notifications/model" "git.sendico.io/tech/sxchnge/notifications/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors" merrors "git.sendico.io/tech/sxchnge/shared/errors"
"google.golang.org/grpc"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
) )
type callerKey struct{}
type API interface { type API interface {
Send(context.Context, string, model.Request) (*model.Notification, error) Send(context.Context, string, model.Request) (*model.Notification, error)
Get(context.Context, string, string) (*model.Notification, error) Get(context.Context, string, string) (*model.Notification, error)
@@ -26,14 +20,14 @@ type Server struct {
service API service API
} }
// The private endpoint uses the existing product storage namespace without caller authentication.
func New(service API) *Server { return &Server{service: service} } func New(service API) *Server { return &Server{service: service} }
func (s *Server) Send(ctx context.Context, r *pb.SendRequest) (*pb.SendResponse, error) { func (s *Server) Send(ctx context.Context, r *pb.SendRequest) (*pb.SendResponse, error) {
input, err := request(r) input, err := request(r)
if err != nil { if err != nil {
return nil, rpcError(err) return nil, rpcError(err)
} }
caller, _ := ctx.Value(callerKey{}).(string) n, err := s.service.Send(ctx, "product", input)
n, err := s.service.Send(ctx, caller, input)
if err != nil { if err != nil {
return nil, rpcError(err) return nil, rpcError(err)
} }
@@ -43,39 +37,13 @@ func (s *Server) Get(ctx context.Context, r *pb.GetRequest) (*pb.GetResponse, er
if r == nil { if r == nil {
return nil, status.Error(codes.InvalidArgument, "missing request") return nil, status.Error(codes.InvalidArgument, "missing request")
} }
caller, _ := ctx.Value(callerKey{}).(string) n, err := s.service.Get(ctx, "product", r.Id)
n, err := s.service.Get(ctx, caller, r.Id)
if err != nil { if err != nil {
return nil, rpcError(err) return nil, rpcError(err)
} }
return &pb.GetResponse{Notification: response(n)}, nil 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 { func rpcError(err error) error {
if errors.Is(err, context.Canceled) { if errors.Is(err, context.Canceled) {
return status.Error(codes.Canceled, "notification operation canceled") return status.Error(codes.Canceled, "notification operation canceled")
@@ -15,7 +15,6 @@ import (
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"google.golang.org/grpc/test/bufconn" "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) { func (fakeAPI) Get(context.Context, string, string) (*model.Notification, error) {
return nil, merrors.NoData("private details") return nil, merrors.NoData("private details")
} }
func TestGRPCAuthenticationAndMapping(t *testing.T) { func TestGRPCWithoutAuthenticationAndMapping(t *testing.T) {
listener := bufconn.Listen(1 << 20) listener := bufconn.Listen(1 << 20)
token := strings.Repeat("a", 32) server := grpc.NewServer()
server := grpc.NewServer(grpc.UnaryInterceptor(Authenticate(map[string]string{"product": token})))
pb.RegisterNotificationsServiceServer(server, New(fakeAPI{})) pb.RegisterNotificationsServiceServer(server, New(fakeAPI{}))
go func() { _ = server.Serve(listener) }() go func() { _ = server.Serve(listener) }()
defer server.Stop() defer server.Stop()
@@ -45,10 +43,7 @@ func TestGRPCAuthenticationAndMapping(t *testing.T) {
defer conn.Close() defer conn.Close()
client := pb.NewNotificationsServiceClient(conn) client := pb.NewNotificationsServiceClient(conn)
req := &pb.SendRequest{UserId: "u", Targets: []*pb.Target{{Destination: &pb.Target_Email{Email: &pb.EmailTarget{Address: "user@example.com"}}}}} 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 { ctx := context.Background()
t.Fatal(err)
}
ctx := metadata.NewOutgoingContext(context.Background(), metadata.Pairs("authorization", "Bearer "+token))
result, err := client.Send(ctx, req) result, err := client.Send(ctx, req)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -48,7 +48,6 @@ func TestProfileStartsOnEmptyDatabase(t *testing.T) {
t.Setenv("SXCHNGE_API_PROTOCOL", "http") t.Setenv("SXCHNGE_API_PROTOCOL", "http")
t.Setenv("SXCHNGE_API_ENDPOINT", "/api/v1") t.Setenv("SXCHNGE_API_ENDPOINT", "/api/v1")
t.Setenv("SXCHNGE_JWT_SECRET", "test-only-signing-key-at-least-32-bytes") 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. example.API.Notifications.CAFile = "" // This startup test makes no notification RPCs.
root, routes := chi.NewRouter(), chi.NewRouter() root, routes := chi.NewRouter(), chi.NewRouter()
runtime, err := sharedruntime.CreateAPI(ctx, zap.NewNop(), &example.API, &example.API, factory, root, routes, false, profile.Runtime()) runtime, err := sharedruntime.CreateAPI(ctx, zap.NewNop(), &example.API, &example.API, factory, root, routes, false, profile.Runtime())
-1
View File
@@ -33,5 +33,4 @@ api:
min_length: 12 min_length: 12
notifications: notifications:
address: localhost:8083 address: localhost:8083
token_env: SXCHNGE_NOTIFICATIONS_TOKEN
activation_url: http://localhost:5126/verify-email activation_url: http://localhost:5126/verify-email
+5 -10
View File
@@ -13,13 +13,11 @@ import (
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
) )
type Config struct { type Config struct {
Address string `yaml:"address"` Address string `yaml:"address"`
TokenEnv string `yaml:"token_env"`
CAFile string `yaml:"ca_file"` CAFile string `yaml:"ca_file"`
ServerName string `yaml:"server_name"` ServerName string `yaml:"server_name"`
ActivationURL string `yaml:"activation_url"` ActivationURL string `yaml:"activation_url"`
@@ -33,15 +31,13 @@ type Sender interface {
Send(context.Context, Message) error Send(context.Context, Message) error
} }
type Client struct { type Client struct {
conn *grpc.ClientConn conn *grpc.ClientConn
rpc pb.NotificationsServiceClient rpc pb.NotificationsServiceClient
token string
} }
func Open(c Config) (*Client, error) { func Open(c Config) (*Client, error) {
token := os.Getenv(c.TokenEnv) if c.Address == "" {
if c.Address == "" || len(token) < 32 { return nil, merrors.InvalidArgument("notification address is required")
return nil, merrors.InvalidArgument("notification address and caller token of at least 32 bytes are required")
} }
tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12, ServerName: c.ServerName} tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12, ServerName: c.ServerName}
if c.CAFile != "" { if c.CAFile != "" {
@@ -66,13 +62,12 @@ func Open(c Config) (*Client, error) {
if err != nil { if err != nil {
return nil, merrors.InternalWrap(err, "create notification client") 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) Close() error { return c.conn.Close() }
func (c *Client) Send(ctx context.Context, m Message) error { func (c *Client) Send(ctx context.Context, m Message) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second) ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel() defer cancel()
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+c.token)
res, err := c.rpc.Send(ctx, request(m)) res, err := c.rpc.Send(ctx, request(m))
if err != nil { if err != nil {
return merrors.ServiceUnavailableWrap(err, "notification queue unavailable") return merrors.ServiceUnavailableWrap(err, "notification queue unavailable")
+17 -20
View File
@@ -23,7 +23,7 @@ import (
type rpcStub struct { type rpcStub struct {
pb.NotificationsServiceClient pb.NotificationsServiceClient
request *pb.SendRequest request *pb.SendRequest
auth string auth []string
result *pb.SendResponse result *pb.SendResponse
err error err error
} }
@@ -31,16 +31,16 @@ type rpcStub struct {
func (s *rpcStub) Send(ctx context.Context, r *pb.SendRequest, _ ...grpc.CallOption) (*pb.SendResponse, error) { func (s *rpcStub) Send(ctx context.Context, r *pb.SendRequest, _ ...grpc.CallOption) (*pb.SendResponse, error) {
s.request = r s.request = r
md, _ := metadata.FromOutgoingContext(ctx) md, _ := metadata.FromOutgoingContext(ctx)
s.auth = md.Get("authorization")[0] s.auth = md.Get("authorization")
return s.result, s.err return s.result, s.err
} }
func TestSendMapsOwnedContractAndRejectsFailedReceipt(t *testing.T) { func TestSendMapsOwnedContractAndRejectsFailedReceipt(t *testing.T) {
rpc := &rpcStub{result: &pb.SendResponse{Notification: &pb.Notification{Id: "job", Deliveries: []*pb.Delivery{{State: pb.DeliveryState_DELIVERY_STATE_QUEUED}}}}} 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) 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} 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.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, m.Email, rpc.request.Targets[0].GetEmail().Address)
require.Equal(t, "1", rpc.request.TemplateVersion) require.Equal(t, "1", rpc.request.TemplateVersion)
require.Equal(t, m.Parameters, rpc.request.Parameters) require.Equal(t, m.Parameters, rpc.request.Parameters)
@@ -50,57 +50,54 @@ func TestSendMapsOwnedContractAndRejectsFailedReceipt(t *testing.T) {
rpc.result = nil rpc.result = nil
require.ErrorIs(t, client.Send(context.Background(), m), merrors.ErrServiceUnavailable) require.ErrorIs(t, client.Send(context.Background(), m), merrors.ErrServiceUnavailable)
} }
func TestOpenRequiresCredentialsAndValidCA(t *testing.T) { func TestOpenRequiresAddressAndValidCA(t *testing.T) {
_, err := Open(Config{Address: "localhost:8443", TokenEnv: "ABSENT_NOTIFICATION_TEST_TOKEN"}) _, err := Open(Config{})
require.ErrorIs(t, err, merrors.ErrInvalidArg) 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", CAFile: "missing.pem"})
_, err = Open(Config{Address: "localhost:8443", TokenEnv: "TEST_NOTIFICATION_TOKEN", CAFile: "missing.pem"})
require.Error(t, err) require.Error(t, err)
} }
type tlsRPC struct { type tlsRPC struct {
pb.UnimplementedNotificationsServiceServer pb.UnimplementedNotificationsServiceServer
auth chan string auth chan []string
} }
func (s *tlsRPC) Send(ctx context.Context, _ *pb.SendRequest) (*pb.SendResponse, error) { func (s *tlsRPC) Send(ctx context.Context, _ *pb.SendRequest) (*pb.SendResponse, error) {
md, _ := metadata.FromIncomingContext(ctx) 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 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) {})) fixture := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
cert := fixture.TLS.Certificates[0] cert := fixture.TLS.Certificates[0]
fixture.Close() fixture.Close()
listener, err := net.Listen("tcp", "127.0.0.1:0") listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err) require.NoError(t, err)
server := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}))) 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) pb.RegisterNotificationsServiceServer(server, rpc)
go func() { _ = server.Serve(listener) }() go func() { _ = server.Serve(listener) }()
t.Cleanup(server.Stop) t.Cleanup(server.Stop)
ca := filepath.Join(t.TempDir(), "ca.pem") ca := filepath.Join(t.TempDir(), "ca.pem")
require.NoError(t, os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Certificate[0]}), 0600)) 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(), CAFile: ca})
client, err := Open(Config{Address: listener.Addr().String(), TokenEnv: "TEST_TLS_CALLER_TOKEN", CAFile: ca})
require.NoError(t, err) require.NoError(t, err)
t.Cleanup(func() { _ = client.Close() }) t.Cleanup(func() { _ = client.Close() })
require.NoError(t, client.Send(context.Background(), Message{ExpiresAt: time.Now().Add(time.Minute)})) 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") listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err) require.NoError(t, err)
server := grpc.NewServer() server := grpc.NewServer()
rpc := &tlsRPC{auth: make(chan string, 1)} rpc := &tlsRPC{auth: make(chan []string, 1)}
pb.RegisterNotificationsServiceServer(server, rpc) pb.RegisterNotificationsServiceServer(server, rpc)
go func() { _ = server.Serve(listener) }() go func() { _ = server.Serve(listener) }()
t.Cleanup(server.Stop) 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()})
client, err := Open(Config{Address: listener.Addr().String(), TokenEnv: "TEST_PLAIN_CALLER_TOKEN"})
require.NoError(t, err) require.NoError(t, err)
t.Cleanup(func() { _ = client.Close() }) t.Cleanup(func() { _ = client.Close() })
require.NoError(t, client.Send(context.Background(), Message{ExpiresAt: time.Now().Add(time.Minute)})) 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)
} }
+5 -7
View File
@@ -16,23 +16,21 @@ changes under a new revision rather than mutating deployed content.
Configure `api.notifications` in the product YAML: Configure `api.notifications` in the product YAML:
- `address`: internal notifications gRPC address. - `address`: internal notifications gRPC address.
- `token_env`: environment variable containing the configured caller's token - `ca_file`: optional trusted PEM CA bundle for explicit TLS. Omit both TLS settings for plaintext.
(at least 32 bytes); example `SXCHNGE_NOTIFICATIONS_TOKEN`.
- `ca_file`: trusted PEM CA bundle; omit to use system roots.
- `server_name`: optional TLS certificate hostname override, never a TLS bypass. - `server_name`: optional TLS certificate hostname override, never a TLS bypass.
- `activation_url`: absolute frontend `/verify-email` URL. HTTPS is required, - `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 except HTTP on localhost/127.0.0.1 for development. No user-supplied redirect is
accepted. The token is appended as a query parameter. accepted. The token is appended as a query parameter.
The caller token must map to the product caller in the notification service's The private notifications endpoint uses the existing product storage namespace
configuration. Keep Mongo, notification caller, TLS and provider credentials out without caller authentication. Keep Mongo, TLS and provider credentials out
of Git. JWT signing configuration must contain at least 32 bytes of private key 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 material; HMAC-derived issuance keys depend on this key. Rotation changes replay
keys and existing pending JWT validity. Omission of `api.notifications` preserves 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 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 provider configured. Product startup creates a lazy gRPC channel; dependency
outages appear as HTTP 503 when attempting delivery. A successful Send means outages appear as HTTP 503 when attempting delivery. A successful Send means
persisted queue acceptance, not inbox delivery or a provider receipt. persisted queue acceptance, not inbox delivery or a provider receipt.
+22
View File
@@ -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 `make up` intentionally fails for the still-missing API key. Verified sender must
also be configured in runtime YAML before rebuilding and starting notifications. also be configured in runtime YAML before rebuilding and starting notifications.
No backend source changed and no email was sent. 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).
+6 -4
View File
@@ -39,7 +39,6 @@ kv/
├── services/ ├── services/
│ ├── bff/jwt # jwt │ ├── bff/jwt # jwt
│ └── notifications/ │ └── notifications/
│ ├── callers/bff # token; read by BFF and notifications
│ └── email/resend # api_key only │ └── email/resend # api_key only
└── infrastructure/ └── infrastructure/
├── nats/auth # token; read by NATS and notifications ├── 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` There are no frontend certificates, notification certificates or generic `tls`
entries in KV. Browser certificates belong to Caddy; internal gRPC is plaintext 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 ## 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 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 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 port. Only frontend and Vault bind host loopback ports. TLS remains optional in
the reusable notification client/server for deployments that explicitly configure 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 ## 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`. hierarchy above, and moves `bootstrap/mongo` to `infrastructure/mongo/bootstrap`.
It copies with CAS protection, compares values, and refuses to It copies with CAS protection, compares values, and refuses to
overwrite conflicting destinations. After consumers use the new paths, it removes 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 transaction-backed HTTP responses. It restores TTLs and removes services it
started. No verification command sends email. Backend validation remains started. No verification command sends email. Backend validation remains
`make test` and `make lint-backend`, using the host SDKs documented in module guides. `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.
-1
View File
@@ -38,5 +38,4 @@ api:
min_length: 12 min_length: 12
notifications: notifications:
address: notifications:8083 address: notifications:8083
token_env: SXCHNGE_NOTIFICATIONS_TOKEN
activation_url: https://localhost:5126/verify-email activation_url: https://localhost:5126/verify-email
-2
View File
@@ -17,8 +17,6 @@ messaging:
max_reconnects: -1 max_reconnects: -1
reconnect_wait: 2 reconnect_wait: 2
templates: "/etc/sxchnge/templates.json" templates: "/etc/sxchnge/templates.json"
callers:
product: "SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN"
email: email:
driver: resend driver: resend
settings: settings:
+3 -3
View File
@@ -58,8 +58,8 @@ provision_agents() {
directory="$STATE/agents/$service" directory="$STATE/agents/$service"
mkdir -p "$directory"; chmod 755 "$directory" mkdir -p "$directory"; chmod 755 "$directory"
case "$service" in case "$service" in
bff) paths='services/bff/jwt services/notifications/callers/bff';; bff) paths='services/bff/jwt';;
notifications) paths='services/notifications/callers/bff infrastructure/nats/auth services/notifications/email/resend';; notifications) paths='infrastructure/nats/auth services/notifications/email/resend';;
nats) paths=infrastructure/nats/auth;; nats) paths=infrastructure/nats/auth;;
esac esac
: > "$WORK/policy" : > "$WORK/policy"
@@ -92,7 +92,7 @@ up() {
initialize initialize
mounts mounts
migrate_secrets 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 if [ "$(kv "$path")" = null ]; then
field=token; [ "$path" != services/bff/jwt ] || field=jwt field=token; [ "$path" != services/bff/jwt ] || field=jwt
random | jq -Rs --arg field "$field" '{($field):rtrimstr("\n")}' | put "$path" random | jq -Rs --arg field "$field" '{($field):rtrimstr("\n")}' | put "$path"
+2 -3
View File
@@ -1,8 +1,7 @@
# Canonical paths follow ownership. Shared callers live under the receiving service. # Canonical paths follow ownership.
secret_moves() { secret_moves() {
cat <<'PATHS' cat <<'PATHS'
bff services/bff/jwt bff services/bff/jwt
caller services/notifications/callers/bff
email services/notifications/email/resend email services/notifications/email/resend
nats infrastructure/nats/auth nats infrastructure/nats/auth
bootstrap/mongo infrastructure/mongo/bootstrap bootstrap/mongo infrastructure/mongo/bootstrap
@@ -47,7 +46,7 @@ retire_secrets() (
archive_secret "$old" archive_secret "$old"
printf '{}' | vault "kv/metadata/$old" DELETE >/dev/null printf '{}' | vault "kv/metadata/$old" DELETE >/dev/null
done < "$WORK/moves" 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" archive_secret "$old"
if [ "$(vault "kv/metadata/$old" GET yes)" != null ]; then printf '{}' | vault "kv/metadata/$old" DELETE >/dev/null; fi if [ "$(vault "kv/metadata/$old" GET yes)" != null ]; then printf '{}' | vault "kv/metadata/$old" DELETE >/dev/null; fi
done done
+19
View File
@@ -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 (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 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' 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'
+3 -3
View File
@@ -6,7 +6,7 @@ policies() (
directory="$STATE/agents/$service" 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) 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') 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') VAULT_TOKEN=$(printf '%s' "$login" | jq -er '.auth.client_token')
printf '{}' | vault auth/token/revoke-self POST >/dev/null printf '{}' | vault auth/token/revoke-self POST >/dev/null
VAULT_TOKEN=$root_token VAULT_TOKEN=$root_token
@@ -51,10 +51,10 @@ product() {
} }
layout() { layout() {
kv services/notifications/email/resend | jq -e 'keys==["api_key"]' >/dev/null || fail 'Non-secret email configuration remains in Vault' 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" [ "$(vault "kv/metadata/$old" GET yes)" = null ] || fail "Obsolete KV entry remains: $old"
done 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" vault "kv/metadata/$path" | jq -e '.data.current_version>0' >/dev/null || fail "Missing canonical KV entry: $path"
done done
echo 'PASS: canonical Vault hierarchy present; obsolete flat and TLS entries absent' echo 'PASS: canonical Vault hierarchy present; obsolete flat and TLS entries absent'
-4
View File
@@ -19,10 +19,6 @@ env_template "SXCHNGE_JWT_SECRET" {
error_on_missing_key = true 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 { exec {
command = ["/usr/local/bin/start-service"] command = ["/usr/local/bin/start-service"]
-4
View File
@@ -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" { env_template "SXCHNGE_NATS_URL" {
contents = "{{ with secret \"kv/data/infrastructure/nats/auth\" }}nats://{{ .Data.data.token }}@nats:4222{{ end }}" contents = "{{ with secret \"kv/data/infrastructure/nats/auth\" }}nats://{{ .Data.data.token }}@nats:4222{{ end }}"