Skip to content

Commit 8395a50

Browse files
authored
feat: Add SecretStore port and EnvSecretStore adapter (operational-gateway) (#1302)
* feat: Add SecretStore port and EnvSecretStore adapter for operational-gateway Introduces the SecretStore port interface (ports.SecretStore) with ErrSecretNotFound sentinel error, and a Phase 1 environment variable adapter (adapters/secrets.EnvSecretStore). The adapter resolves secrets using the convention: TENANT_{SLUG}_{SECRET_REF} where slug and ref are uppercased with hyphens replaced by underscores. A TenantSlugResolver interface decouples slug lookup from the adapter, enabling test doubles and future caching without changing dispatch logic. Secret values are never logged. Environment variables are intended to be injected via Kubernetes Secrets. * docs: Add docstrings to test functions for docstring coverage threshold --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent 5be89dc commit 8395a50

4 files changed

Lines changed: 289 additions & 0 deletions

File tree

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Package secrets provides adapters for resolving tenant secrets.
2+
package secrets
3+
4+
import (
5+
"context"
6+
"fmt"
7+
"os"
8+
"strings"
9+
10+
"github.com/meridianhub/meridian/services/operational-gateway/ports"
11+
)
12+
13+
// TenantSlugResolver looks up the human-readable slug for a tenant ID.
14+
// The slug is used to construct environment variable names so that secrets
15+
// are namespaced per tenant without embedding UUIDs in variable names.
16+
type TenantSlugResolver interface {
17+
// GetSlug returns the slug for the given tenantID, e.g. "acme-corp".
18+
// Returns an error if the tenant cannot be found or the lookup fails.
19+
GetSlug(ctx context.Context, tenantID string) (string, error)
20+
}
21+
22+
// EnvSecretStore resolves tenant secrets from environment variables.
23+
//
24+
// Environment variable naming convention:
25+
//
26+
// TENANT_{SLUG}_{SECRET_REF}
27+
//
28+
// where SLUG and SECRET_REF are uppercased and hyphens are replaced with
29+
// underscores. For example, a tenant with slug "acme-corp" and secret
30+
// reference "STRIPE_API_KEY" resolves to:
31+
//
32+
// TENANT_ACME_CORP_STRIPE_API_KEY
33+
//
34+
// This adapter is intended for Phase 1 deployments where secrets are
35+
// injected as environment variables via Kubernetes Secrets. For production
36+
// workloads at scale, replace this adapter with one backed by a secrets
37+
// manager (e.g. AWS SSM Parameter Store, HashiCorp Vault).
38+
//
39+
// Security note: secret values are never logged.
40+
type EnvSecretStore struct {
41+
slugResolver TenantSlugResolver
42+
}
43+
44+
// NewEnvSecretStore creates a new EnvSecretStore with the given slug resolver.
45+
func NewEnvSecretStore(resolver TenantSlugResolver) *EnvSecretStore {
46+
return &EnvSecretStore{slugResolver: resolver}
47+
}
48+
49+
// Resolve looks up the secret value from an environment variable.
50+
// It implements ports.SecretStore.
51+
//
52+
// Returns ports.ErrSecretNotFound if the environment variable is not set.
53+
// Returns a wrapped error if the slug resolver fails.
54+
func (s *EnvSecretStore) Resolve(ctx context.Context, tenantID, secretRef string) (string, error) {
55+
slug, err := s.slugResolver.GetSlug(ctx, tenantID)
56+
if err != nil {
57+
return "", fmt.Errorf("failed to resolve slug for tenant %q: %w", tenantID, err)
58+
}
59+
60+
envName := buildEnvName(slug, secretRef)
61+
62+
value, ok := os.LookupEnv(envName)
63+
if !ok {
64+
return "", fmt.Errorf("%w: %s", ports.ErrSecretNotFound, envName)
65+
}
66+
67+
return value, nil
68+
}
69+
70+
// buildEnvName constructs the environment variable name for a given tenant slug
71+
// and secret reference using the convention TENANT_{SLUG}_{SECRET_REF}.
72+
func buildEnvName(slug, secretRef string) string {
73+
return "TENANT_" + toEnvName(slug) + "_" + toEnvName(secretRef)
74+
}
75+
76+
// toEnvName normalises a string for use in an environment variable name:
77+
// uppercases all characters and replaces hyphens with underscores.
78+
func toEnvName(s string) string {
79+
return strings.ToUpper(strings.ReplaceAll(s, "-", "_"))
80+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package secrets_test
2+
3+
import (
4+
"context"
5+
"errors"
6+
"testing"
7+
8+
"github.com/meridianhub/meridian/services/operational-gateway/adapters/secrets"
9+
"github.com/meridianhub/meridian/services/operational-gateway/ports"
10+
)
11+
12+
// staticSlugResolver returns a fixed slug for any tenant ID.
13+
type staticSlugResolver struct {
14+
slug string
15+
err error
16+
}
17+
18+
// GetSlug returns the configured slug and error for any input.
19+
func (r *staticSlugResolver) GetSlug(_ context.Context, _ string) (string, error) {
20+
return r.slug, r.err
21+
}
22+
23+
// errorSlugResolver always returns an error.
24+
type errorSlugResolver struct{ err error }
25+
26+
// GetSlug always returns an empty slug and the configured error.
27+
func (r *errorSlugResolver) GetSlug(_ context.Context, _ string) (string, error) {
28+
return "", r.err
29+
}
30+
31+
// TestEnvSecretStore_Resolve_Success verifies that Resolve returns the correct secret value
32+
// when the environment variable is set and the slug resolver succeeds.
33+
func TestEnvSecretStore_Resolve_Success(t *testing.T) {
34+
resolver := &staticSlugResolver{slug: "acme-corp"}
35+
store := secrets.NewEnvSecretStore(resolver)
36+
37+
// env var name: TENANT_ACME_CORP_STRIPE_API_KEY
38+
t.Setenv("TENANT_ACME_CORP_STRIPE_API_KEY", "sk_live_xyz")
39+
40+
got, err := store.Resolve(context.Background(), "tenant-001", "STRIPE_API_KEY")
41+
if err != nil {
42+
t.Fatalf("unexpected error: %v", err)
43+
}
44+
if got != "sk_live_xyz" {
45+
t.Fatalf("expected %q, got %q", "sk_live_xyz", got)
46+
}
47+
}
48+
49+
// TestEnvSecretStore_Resolve_SecretNotFound verifies that Resolve returns ErrSecretNotFound
50+
// when no matching environment variable is set.
51+
func TestEnvSecretStore_Resolve_SecretNotFound(t *testing.T) {
52+
resolver := &staticSlugResolver{slug: "acme"}
53+
store := secrets.NewEnvSecretStore(resolver)
54+
55+
// No environment variable set for this secret.
56+
_, err := store.Resolve(context.Background(), "tenant-001", "MISSING_SECRET")
57+
if !errors.Is(err, ports.ErrSecretNotFound) {
58+
t.Fatalf("expected ErrSecretNotFound, got: %v", err)
59+
}
60+
}
61+
62+
// TestEnvSecretStore_Resolve_SlugResolverError verifies that Resolve propagates and wraps
63+
// errors returned by the TenantSlugResolver.
64+
func TestEnvSecretStore_Resolve_SlugResolverError(t *testing.T) {
65+
slugErr := errors.New("slug lookup failed")
66+
resolver := &errorSlugResolver{err: slugErr}
67+
store := secrets.NewEnvSecretStore(resolver)
68+
69+
_, err := store.Resolve(context.Background(), "tenant-001", "SOME_KEY")
70+
if err == nil {
71+
t.Fatal("expected error, got nil")
72+
}
73+
if !errors.Is(err, slugErr) {
74+
t.Fatalf("expected wrapped slug error, got: %v", err)
75+
}
76+
}
77+
78+
// TestEnvSecretStore_Resolve_HyphensAndUnderscores verifies that hyphens in both the slug
79+
// and the secret reference are normalised to underscores when building the env var name.
80+
func TestEnvSecretStore_Resolve_HyphensAndUnderscores(t *testing.T) {
81+
// Slugs with hyphens and secret refs with underscores should both normalise correctly.
82+
// slug "my-tenant" → "MY_TENANT", ref "WEBHOOK_SECRET" → TENANT_MY_TENANT_WEBHOOK_SECRET
83+
resolver := &staticSlugResolver{slug: "my-tenant"}
84+
store := secrets.NewEnvSecretStore(resolver)
85+
86+
t.Setenv("TENANT_MY_TENANT_WEBHOOK_SECRET", "whsec_abc")
87+
88+
got, err := store.Resolve(context.Background(), "ignored", "WEBHOOK_SECRET")
89+
if err != nil {
90+
t.Fatalf("unexpected error: %v", err)
91+
}
92+
if got != "whsec_abc" {
93+
t.Fatalf("expected %q, got %q", "whsec_abc", got)
94+
}
95+
}
96+
97+
// TestEnvSecretStore_Resolve_SecretRefWithHyphens verifies that a secret reference
98+
// containing hyphens is correctly normalised to underscores when building the env var name.
99+
func TestEnvSecretStore_Resolve_SecretRefWithHyphens(t *testing.T) {
100+
// Secret references that contain hyphens should be normalised to underscores.
101+
resolver := &staticSlugResolver{slug: "acme"}
102+
store := secrets.NewEnvSecretStore(resolver)
103+
104+
// ref "stripe-api-key" → "STRIPE_API_KEY" → env var TENANT_ACME_STRIPE_API_KEY
105+
t.Setenv("TENANT_ACME_STRIPE_API_KEY", "sk_test_321")
106+
107+
got, err := store.Resolve(context.Background(), "ignored", "stripe-api-key")
108+
if err != nil {
109+
t.Fatalf("unexpected error: %v", err)
110+
}
111+
if got != "sk_test_321" {
112+
t.Fatalf("expected %q, got %q", "sk_test_321", got)
113+
}
114+
}
115+
116+
// TestNewEnvSecretStore_ImplementsSecretStore is a compile-time assertion that EnvSecretStore
117+
// satisfies the ports.SecretStore interface.
118+
func TestNewEnvSecretStore_ImplementsSecretStore(_ *testing.T) {
119+
// Compile-time assertion: EnvSecretStore must satisfy the SecretStore port.
120+
var _ ports.SecretStore = secrets.NewEnvSecretStore(&staticSlugResolver{})
121+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Package ports defines the interfaces (ports) for the operational-gateway service.
2+
package ports
3+
4+
import (
5+
"context"
6+
"errors"
7+
)
8+
9+
// ErrSecretNotFound is returned when a secret cannot be found for the given tenant and reference.
10+
var ErrSecretNotFound = errors.New("secret not found")
11+
12+
// SecretStore resolves tenant secrets by reference at dispatch time.
13+
// Implementations may read from environment variables, a secrets manager (e.g. AWS SSM,
14+
// HashiCorp Vault), or other backends. The interface is intentionally narrow so that
15+
// adapters can be swapped without changing the dispatch logic.
16+
type SecretStore interface {
17+
// Resolve returns the plaintext secret value for the given tenant and secret reference.
18+
// Returns ErrSecretNotFound if no value exists for the combination.
19+
Resolve(ctx context.Context, tenantID, secretRef string) (string, error)
20+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package ports_test
2+
3+
import (
4+
"context"
5+
"errors"
6+
"testing"
7+
8+
"github.com/meridianhub/meridian/services/operational-gateway/ports"
9+
)
10+
11+
// stubSecretStore is a minimal in-test implementation used to verify the interface contract.
12+
type stubSecretStore struct {
13+
secrets map[string]string
14+
}
15+
16+
// Resolve returns the secret for the given tenant and ref, or ErrSecretNotFound.
17+
func (s *stubSecretStore) Resolve(_ context.Context, tenantID, secretRef string) (string, error) {
18+
key := tenantID + ":" + secretRef
19+
if v, ok := s.secrets[key]; ok {
20+
return v, nil
21+
}
22+
return "", ports.ErrSecretNotFound
23+
}
24+
25+
// TestSecretStore_Interface is a compile-time assertion that stubSecretStore
26+
// satisfies the SecretStore interface.
27+
func TestSecretStore_Interface(_ *testing.T) {
28+
// Verify stubSecretStore satisfies the interface at compile time.
29+
var _ ports.SecretStore = &stubSecretStore{}
30+
}
31+
32+
// TestErrSecretNotFound_IsSentinel verifies that ErrSecretNotFound can be identified
33+
// via errors.Is for use in error handling chains.
34+
func TestErrSecretNotFound_IsSentinel(t *testing.T) {
35+
err := ports.ErrSecretNotFound
36+
if !errors.Is(err, ports.ErrSecretNotFound) {
37+
t.Fatalf("expected ErrSecretNotFound to be detectable via errors.Is")
38+
}
39+
}
40+
41+
// TestSecretStore_Resolve_ReturnsValue verifies that Resolve returns the correct secret
42+
// value when the tenant and ref key combination exists.
43+
func TestSecretStore_Resolve_ReturnsValue(t *testing.T) {
44+
store := &stubSecretStore{
45+
secrets: map[string]string{
46+
"tenant-abc:STRIPE_KEY": "sk_live_abc123",
47+
},
48+
}
49+
50+
got, err := store.Resolve(context.Background(), "tenant-abc", "STRIPE_KEY")
51+
if err != nil {
52+
t.Fatalf("unexpected error: %v", err)
53+
}
54+
if got != "sk_live_abc123" {
55+
t.Fatalf("expected %q, got %q", "sk_live_abc123", got)
56+
}
57+
}
58+
59+
// TestSecretStore_Resolve_ReturnsErrSecretNotFound verifies that Resolve returns
60+
// ErrSecretNotFound when no secret exists for the given tenant and ref.
61+
func TestSecretStore_Resolve_ReturnsErrSecretNotFound(t *testing.T) {
62+
store := &stubSecretStore{secrets: map[string]string{}}
63+
64+
_, err := store.Resolve(context.Background(), "tenant-abc", "MISSING_KEY")
65+
if !errors.Is(err, ports.ErrSecretNotFound) {
66+
t.Fatalf("expected ErrSecretNotFound, got: %v", err)
67+
}
68+
}

0 commit comments

Comments
 (0)