Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions internal/api/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ type Handler struct {
analyticsCollector AnalyticsCollectorInterface // Optional: snapshot collector
analyticsSnapshots AnalyticsSnapshotStoreInterface // Optional: savings-snapshot time-series store
signer oidc.Signer // Optional: OIDC issuer signer (backed by cloud KMS)
insurance InsuranceProvider // Optional: Archera comparison (nil = off)
issuerURL string // Canonical OIDC issuer URL (falls back to dashboardURL / request domain)

awsCfgOnce sync.Once // guards one-time loading of the base AWS config
Expand Down Expand Up @@ -185,6 +186,7 @@ func NewHandler(cfg HandlerConfig) *Handler {
analyticsSnapshots: cfg.AnalyticsSnapshots,
signer: cfg.OIDCSigner,
issuerURL: cfg.OIDCIssuerURL,
insurance: cfg.Insurance,
commitmentOpts: cfg.CommitmentOpts,
encryptionKeySource: cfg.EncryptionKeySource,
}
Expand Down
46 changes: 46 additions & 0 deletions internal/api/handler_insurance.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package api

import (
"context"

"github.com/aws/aws-lambda-go/events"

"github.com/LeanerCloud/cloud-commitments-platform/internal/archera"
)

// InsuranceProvider is the Archera comparison surface. nil means not wired.
type InsuranceProvider interface {
Status() archera.Status
}

// getInsuranceStatus reports whether the Archera comparison settings are
// present (names only, no values, no outbound call, no secret read).
//
// Gate: view:recommendations plus an unrestricted account scope. The Archera
// plan is deployment-global and cannot be attributed to one cloud account, so
// a scoped session gets 404 (same non-leaky shape as requireAccountAccess).
func (h *Handler) getInsuranceStatus(ctx context.Context, req *events.LambdaFunctionURLRequest) (*archera.Status, error) {
if err := h.requireInsuranceAccess(ctx, req); err != nil {
return nil, err
}
status := archera.Settings{}.Status()
if h.insurance != nil {
status = h.insurance.Status()
}
return &status, nil
}

func (h *Handler) requireInsuranceAccess(ctx context.Context, req *events.LambdaFunctionURLRequest) error {
session, err := h.requirePermission(ctx, req, "view", "recommendations")
if err != nil {
return err
}
scope, err := h.getAccountScope(ctx, session)
if err != nil {
return err
}
if !scope.AllowsAll() {
return errNotFound
}
return nil
}
133 changes: 133 additions & 0 deletions internal/api/handler_insurance_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package api

import (
"context"
"fmt"
"net/http"
"testing"

"github.com/aws/aws-lambda-go/events"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"

"github.com/LeanerCloud/cloud-commitments-platform/internal/archera"
"github.com/LeanerCloud/cloud-commitments-platform/internal/secrets"
)

const (
insTestKey = "synthetic-archera-key-0123456789"
insTestOrg = "11111111-1111-4111-8111-111111111111"
insTestPlan = "44444444-4444-4444-8444-444444444444"
insTestRef = "SYNTHETIC_ARCHERA_KEY_REF"
)

type panicTransport struct{ t *testing.T }

func (p panicTransport) RoundTrip(*http.Request) (*http.Response, error) {
p.t.Fatal("status must make no outbound request")
return nil, nil
}

func insuranceProvider(t *testing.T, s archera.Settings) *archera.Provider {
return archera.NewProvider(s, nil, &http.Client{Transport: panicTransport{t}})
}

func sessionPrincipalCtx(userID string) context.Context {
return contextWithPrincipal(context.Background(), &Principal{
Kind: PrincipalSession, Session: &Session{UserID: userID}, UserID: userID,
})
}

func TestInsuranceStatus_AdminAPIKey_ReportsPresenceOnly(t *testing.T) {
full := archera.Settings{OrgID: insTestOrg, PlanID: insTestPlan, KeySecretRef: insTestRef}
for name, tc := range map[string]struct {
s archera.Settings
ok bool
missing []string
}{
"off": {archera.Settings{}, false, []string{archera.EnvKeySecret, archera.EnvOrgID, archera.EnvPlanID}},
"complete": {full, true, []string{}},
} {
t.Run(name, func(t *testing.T) {
h := &Handler{apiKey: "admin-key", insurance: insuranceProvider(t, tc.s)}
req := &events.LambdaFunctionURLRequest{Headers: map[string]string{"x-api-key": "admin-key"}}
st, err := h.getInsuranceStatus(context.Background(), req)
require.NoError(t, err)
assert.Equal(t, tc.ok, st.Configured)
assert.Equal(t, tc.missing, st.Missing)
assert.NotContains(t, fmt.Sprintf("%+v", st), insTestOrg, "status must never carry IDs")
})
}
}

func TestInsuranceStatus_UnwiredIsOff(t *testing.T) {
h := &Handler{apiKey: "admin-key"}
req := &events.LambdaFunctionURLRequest{Headers: map[string]string{"x-api-key": "admin-key"}}
st, err := h.getInsuranceStatus(context.Background(), req)
require.NoError(t, err)
assert.False(t, st.Configured)
}

func TestInsuranceStatus_DeniedUserGets403(t *testing.T) {
m := new(MockAuthService)
m.On("HasPermissionAPI", mock.Anything, "u1", "view", "recommendations").Return(false, nil)
h := &Handler{auth: m, insurance: insuranceProvider(t, archera.Settings{})}
_, err := h.getInsuranceStatus(sessionPrincipalCtx("u1"), &events.LambdaFunctionURLRequest{})
ce, ok := IsClientError(err)
require.True(t, ok, "got %v", err)
assert.Equal(t, 403, ce.code)
}

func TestInsuranceStatus_ScopedSessionGets404(t *testing.T) {
m := new(MockAuthService)
m.On("HasPermissionAPI", mock.Anything, "u1", "view", "recommendations").Return(true, nil)
m.On("GetAllowedAccountsAPI", mock.Anything, "u1").Return([]string{"acct-1"}, nil)
h := &Handler{auth: m, insurance: insuranceProvider(t, archera.Settings{})}
_, err := h.getInsuranceStatus(sessionPrincipalCtx("u1"), &events.LambdaFunctionURLRequest{})
assert.ErrorIs(t, err, errNotFound)
}

func TestInsuranceStatus_UnrestrictedSessionAllowed(t *testing.T) {
m := new(MockAuthService)
m.On("HasPermissionAPI", mock.Anything, "u1", "view", "recommendations").Return(true, nil)
m.On("GetAllowedAccountsAPI", mock.Anything, "u1").Return([]string(nil), nil)
h := &Handler{auth: m, insurance: insuranceProvider(t, archera.Settings{})}
st, err := h.getInsuranceStatus(sessionPrincipalCtx("u1"), &events.LambdaFunctionURLRequest{})
require.NoError(t, err)
assert.False(t, st.Configured)
}

func TestInsuranceStatus_ScopedUserAPIKeyGets404(t *testing.T) {
m := new(MockAuthService)
m.On("HasAPIKeyPermissionAPI", mock.Anything, "user-key", "view", "recommendations").Return("u2", "key-1", true, nil)
m.On("GetAllowedAccountsAPI", mock.Anything, "u2").Return([]string{"acct-1"}, nil)
h := &Handler{auth: m, insurance: insuranceProvider(t, archera.Settings{})}
ctx := contextWithPrincipal(context.Background(), &Principal{Kind: PrincipalUserAPIKey, UserID: "u2", APIKeyID: "key-1"})
req := &events.LambdaFunctionURLRequest{Headers: map[string]string{"x-api-key": "user-key"}}
_, err := h.getInsuranceStatus(ctx, req)
assert.ErrorIs(t, err, errNotFound)
}

func TestInsuranceStatus_RouteIsAuthenticated(t *testing.T) {
h := &Handler{auth: new(MockAuthService), insurance: insuranceProvider(t, archera.Settings{})}
_, err := NewRouter(h).Route(context.Background(), "GET", "/api/insurance/status", &events.LambdaFunctionURLRequest{})
require.Error(t, err, "an unauthenticated request must not reach the handler")
}

// The Handler holds a lazily built *insurance.Client behind the provider; no
// fmt verb applied to the Handler may print the key.
func TestInsurance_HandlerFormattingNeverPrintsKey(t *testing.T) {
t.Setenv(insTestRef, insTestKey)
p := archera.NewProvider(archera.Settings{OrgID: insTestOrg, PlanID: insTestPlan, KeySecretRef: insTestRef}, secrets.NewEnvResolver(), nil)
h := &Handler{insurance: p}
check := func(label string) {
for _, verb := range []string{"%v", "%+v", "%#v", "%s"} {
assert.NotContains(t, fmt.Sprintf(verb, h), insTestKey, label+" "+verb)
}
}
check("before the client is built")
_, err := p.Client(context.Background())
require.NoError(t, err)
check("after the client is built")
}
5 changes: 5 additions & 0 deletions internal/api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ func (r *Router) registerRoutes() {
// recommendation feed (handlers still scope rows by account
// permission grants).
{ExactPath: "/api/recommendations", Method: "GET", Handler: r.getRecommendationsHandler, Auth: AuthUser},
{ExactPath: "/api/insurance/status", Method: "GET", Handler: r.getInsuranceStatusHandler, Auth: AuthUser},
{ExactPath: "/api/recommendations/freshness", Method: "GET", Handler: r.getRecommendationsFreshnessHandler, Auth: AuthUser},
// AuthUser: any signed-in user can trigger refresh; the handler
// then enforces requirePermission(view, recommendations) so
Expand Down Expand Up @@ -509,6 +510,10 @@ func (r *Router) refreshRecommendationsHandler(ctx context.Context, req *events.
return r.h.postRefreshRecommendations(ctx, req)
}

func (r *Router) getInsuranceStatusHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (any, error) {
return r.h.getInsuranceStatus(ctx, req)
}

func (r *Router) getRecommendationsFreshnessHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (any, error) {
return r.h.getRecommendationsFreshness(ctx, req)
}
Expand Down
1 change: 1 addition & 0 deletions internal/api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ type HandlerConfig struct {
AuthService AuthServiceInterface
CommitmentOpts CommitmentOptsInterface
OIDCSigner oidc.Signer
Insurance InsuranceProvider
AnalyticsSnapshots AnalyticsSnapshotStoreInterface
CredentialStore credentials.CredentialStore
EmailNotifier email.SenderInterface
Expand Down
145 changes: 145 additions & 0 deletions internal/archera/provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// Package archera wires the explicit Archera comparison (pkg/insurance) into
// the server. The surface is off unless all three settings are present, and
// the API key is resolved lazily on the first explicit request.
package archera

import (
"context"
"errors"
"fmt"
"net/http"
"os"
"sync"
"sync/atomic"

"github.com/LeanerCloud/cloud-commitments-go/pkg/insurance"
"github.com/LeanerCloud/cloud-commitments-go/pkg/logging"

"github.com/LeanerCloud/cloud-commitments-platform/internal/secrets"
)

// Setting names, shared with the CLI and MCP consumers.
const (
EnvOrgID = "ARCHERA_ORG_ID"
EnvPlanID = "ARCHERA_PLAN_ID"
EnvKeySecret = "ARCHERA_API_KEY_SECRET" // #nosec G101 -- setting name, not a credential value
)

// ErrNotConfigured is returned (wrapped, naming the missing settings) when an
// explicit request arrives and one or more settings are absent.
var ErrNotConfigured = errors.New("archera comparison is not configured")

// ErrKeyUnavailable is returned when the API key secret cannot be resolved or
// yields an unusable client. The message is fixed so no vendor or secret text
// can reach a response.
var ErrKeyUnavailable = errors.New("archera API key could not be resolved from " + EnvKeySecret)

// Settings are the non-secret Archera settings. KeySecretRef is a reference to
// the secret (name, ARN or URI), not the key itself.
type Settings struct {
OrgID string
PlanID string
KeySecretRef string
}

// SettingsFromEnv reads the settings from the process environment.
func SettingsFromEnv() Settings {
return Settings{
OrgID: os.Getenv(EnvOrgID),
PlanID: os.Getenv(EnvPlanID),
KeySecretRef: os.Getenv(EnvKeySecret),
}
}

// Missing lists the names (never values) of absent settings, in a fixed order.
func (s Settings) Missing() []string {
missing := []string{}
if s.KeySecretRef == "" {
missing = append(missing, EnvKeySecret)
}
if s.OrgID == "" {
missing = append(missing, EnvOrgID)
}
if s.PlanID == "" {
missing = append(missing, EnvPlanID)
}
return missing
}

// Status reports whether the three settings are present. It resolves nothing
// and never carries a value.
type Status struct {
Missing []string `json:"missing"`
Configured bool `json:"configured"`
}

// Status returns the presence-only status for the settings.
func (s Settings) Status() Status {
m := s.Missing()
return Status{Configured: len(m) == 0, Missing: m}
}

// Provider builds the insurance client on first use. It holds the secret
// resolver and a *insurance.Client only, never the raw key or a Config value.
type Provider struct {
resolver secrets.Resolver
hc *http.Client // nil in production (SSRF-hardened default); tests inject
// client is an atomic pointer so fmt prints an address, never the Client's
// fields: fmt dereferences a plain *Client held in an unexported field for
// verbs such as %s, which would print the key.
client atomic.Pointer[insurance.Client]
settings Settings
mu sync.Mutex
}

// NewProvider returns a Provider. hc must be nil in production.
func NewProvider(settings Settings, resolver secrets.Resolver, hc *http.Client) *Provider {
return &Provider{settings: settings, resolver: resolver, hc: hc}
}

// Status reports setting presence without any outbound call or secret read.
func (p *Provider) Status() Status {
if p == nil {
return Settings{}.Status()
}
return p.settings.Status()
}

// Format keeps the provider's contents out of every fmt verb.
func (p *Provider) Format(s fmt.State, _ rune) {
_, _ = fmt.Fprint(s, "archera.Provider{}")
}

// PlanID is the configured Archera plan ID.
func (p *Provider) PlanID() string { return p.settings.PlanID }

// Client returns the lazily built client. A failure is not cached: the next
// call retries (a mutex, not sync.Once, guards the build).
func (p *Provider) Client(ctx context.Context) (insurance.QuoteClient, error) {
if p == nil {
return nil, fmt.Errorf("%w: missing %v", ErrNotConfigured, Settings{}.Missing())
}
if missing := p.settings.Missing(); len(missing) > 0 {
return nil, fmt.Errorf("%w: missing %v", ErrNotConfigured, missing)
}
p.mu.Lock()
defer p.mu.Unlock()
if c := p.client.Load(); c != nil {
return c, nil
}
if p.resolver == nil {
return nil, ErrKeyUnavailable
}
key, err := p.resolver.GetSecret(ctx, p.settings.KeySecretRef)
if err != nil {
logging.Warnf("archera: resolving %s failed: %v", EnvKeySecret, err)
return nil, ErrKeyUnavailable
}
c, err := insurance.NewClient(insurance.Config{APIKey: key, OrgID: p.settings.OrgID}, p.hc)
if err != nil {
logging.Warnf("archera: building client failed: %v", err)
return nil, ErrKeyUnavailable
}
p.client.Store(c)
return c, nil
}
Loading
Loading