Skip to content

Commit

Permalink
fix: validate config and secret sets against the schema (#2034)
Browse files Browse the repository at this point in the history
Fixes #1972 by re-reverting #1978, passing validation for decl's that
aren't found, and adding integration tests around validation pre- and
post-deploy.
  • Loading branch information
safeer authored Jul 12, 2024
1 parent ebc6b1e commit df93625
Show file tree
Hide file tree
Showing 23 changed files with 1,097 additions and 279 deletions.
116 changes: 95 additions & 21 deletions backend/controller/admin/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,29 @@ import (

ftlv1 "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1"
"github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1/ftlv1connect"
"github.com/TBD54566975/ftl/backend/schema"
cf "github.com/TBD54566975/ftl/common/configuration"
"github.com/TBD54566975/ftl/go-runtime/encoding"
"github.com/TBD54566975/ftl/internal/log"
)

type AdminService struct {
cm *cf.Manager[cf.Configuration]
sm *cf.Manager[cf.Secrets]
schr SchemaRetriever
cm *cf.Manager[cf.Configuration]
sm *cf.Manager[cf.Secrets]
}

var _ ftlv1connect.AdminServiceHandler = (*AdminService)(nil)

func NewAdminService(cm *cf.Manager[cf.Configuration], sm *cf.Manager[cf.Secrets]) *AdminService {
type SchemaRetriever interface {
GetActiveSchema(ctx context.Context) (*schema.Schema, error)
}

func NewAdminService(cm *cf.Manager[cf.Configuration], sm *cf.Manager[cf.Secrets], schr SchemaRetriever) *AdminService {
return &AdminService{
cm: cm,
sm: sm,
schr: schr,
cm: cm,
sm: sm,
}
}

Expand All @@ -34,7 +43,7 @@ func (s *AdminService) Ping(ctx context.Context, req *connect.Request[ftlv1.Ping
func (s *AdminService) ConfigList(ctx context.Context, req *connect.Request[ftlv1.ListConfigRequest]) (*connect.Response[ftlv1.ListConfigResponse], error) {
listing, err := s.cm.List(ctx)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to list configs: %w", err)
}

configs := []*ftlv1.ListConfigResponse_Config{}
Expand Down Expand Up @@ -73,13 +82,13 @@ func (s *AdminService) ConfigList(ctx context.Context, req *connect.Request[ftlv
// ConfigGet returns the configuration value for a given ref string.
func (s *AdminService) ConfigGet(ctx context.Context, req *connect.Request[ftlv1.GetConfigRequest]) (*connect.Response[ftlv1.GetConfigResponse], error) {
var value any
err := s.cm.Get(ctx, cf.NewRef(*req.Msg.Ref.Module, req.Msg.Ref.Name), &value)
err := s.cm.Get(ctx, refFromConfigRef(req.Msg.GetRef()), &value)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to get from config manager: %w", err)
}
vb, err := json.MarshalIndent(value, "", " ")
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to marshal value: %w", err)
}
return connect.NewResponse(&ftlv1.GetConfigResponse{Value: vb}), nil
}
Expand All @@ -101,20 +110,25 @@ func configProviderKey(p *ftlv1.ConfigProvider) string {

// ConfigSet sets the configuration at the given ref to the provided value.
func (s *AdminService) ConfigSet(ctx context.Context, req *connect.Request[ftlv1.SetConfigRequest]) (*connect.Response[ftlv1.SetConfigResponse], error) {
pkey := configProviderKey(req.Msg.Provider)
err := s.cm.SetJSON(ctx, pkey, cf.NewRef(*req.Msg.Ref.Module, req.Msg.Ref.Name), req.Msg.Value)
err := s.validateAgainstSchema(ctx, false, refFromConfigRef(req.Msg.GetRef()), req.Msg.Value)
if err != nil {
return nil, err
}

pkey := configProviderKey(req.Msg.Provider)
err = s.cm.SetJSON(ctx, pkey, refFromConfigRef(req.Msg.GetRef()), req.Msg.Value)
if err != nil {
return nil, fmt.Errorf("failed to set config: %w", err)
}
return connect.NewResponse(&ftlv1.SetConfigResponse{}), nil
}

// ConfigUnset unsets the config value at the given ref.
func (s *AdminService) ConfigUnset(ctx context.Context, req *connect.Request[ftlv1.UnsetConfigRequest]) (*connect.Response[ftlv1.UnsetConfigResponse], error) {
pkey := configProviderKey(req.Msg.Provider)
err := s.cm.Unset(ctx, pkey, cf.NewRef(*req.Msg.Ref.Module, req.Msg.Ref.Name))
err := s.cm.Unset(ctx, pkey, refFromConfigRef(req.Msg.GetRef()))
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to unset config: %w", err)
}
return connect.NewResponse(&ftlv1.UnsetConfigResponse{}), nil
}
Expand All @@ -123,7 +137,7 @@ func (s *AdminService) ConfigUnset(ctx context.Context, req *connect.Request[ftl
func (s *AdminService) SecretsList(ctx context.Context, req *connect.Request[ftlv1.ListSecretsRequest]) (*connect.Response[ftlv1.ListSecretsResponse], error) {
listing, err := s.sm.List(ctx)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to list secrets: %w", err)
}
secrets := []*ftlv1.ListSecretsResponse_Secret{}
for _, secret := range listing {
Expand Down Expand Up @@ -162,13 +176,13 @@ func (s *AdminService) SecretsList(ctx context.Context, req *connect.Request[ftl
// SecretGet returns the secret value for a given ref string.
func (s *AdminService) SecretGet(ctx context.Context, req *connect.Request[ftlv1.GetSecretRequest]) (*connect.Response[ftlv1.GetSecretResponse], error) {
var value any
err := s.sm.Get(ctx, cf.NewRef(*req.Msg.Ref.Module, req.Msg.Ref.Name), &value)
err := s.sm.Get(ctx, refFromConfigRef(req.Msg.GetRef()), &value)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to get from secret manager: %w", err)
}
vb, err := json.MarshalIndent(value, "", " ")
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to marshal value: %w", err)
}
return connect.NewResponse(&ftlv1.GetSecretResponse{Value: vb}), nil
}
Expand All @@ -194,20 +208,80 @@ func secretProviderKey(p *ftlv1.SecretProvider) string {

// SecretSet sets the secret at the given ref to the provided value.
func (s *AdminService) SecretSet(ctx context.Context, req *connect.Request[ftlv1.SetSecretRequest]) (*connect.Response[ftlv1.SetSecretResponse], error) {
pkey := secretProviderKey(req.Msg.Provider)
err := s.sm.SetJSON(ctx, pkey, cf.NewRef(*req.Msg.Ref.Module, req.Msg.Ref.Name), req.Msg.Value)
err := s.validateAgainstSchema(ctx, true, refFromConfigRef(req.Msg.GetRef()), req.Msg.Value)
if err != nil {
return nil, err
}

pkey := secretProviderKey(req.Msg.Provider)
err = s.sm.SetJSON(ctx, pkey, refFromConfigRef(req.Msg.GetRef()), req.Msg.Value)
if err != nil {
return nil, fmt.Errorf("failed to set secret: %w", err)
}
return connect.NewResponse(&ftlv1.SetSecretResponse{}), nil
}

// SecretUnset unsets the secret value at the given ref.
func (s *AdminService) SecretUnset(ctx context.Context, req *connect.Request[ftlv1.UnsetSecretRequest]) (*connect.Response[ftlv1.UnsetSecretResponse], error) {
pkey := secretProviderKey(req.Msg.Provider)
err := s.sm.Unset(ctx, pkey, cf.NewRef(*req.Msg.Ref.Module, req.Msg.Ref.Name))
err := s.sm.Unset(ctx, pkey, refFromConfigRef(req.Msg.GetRef()))
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to unset secret: %w", err)
}
return connect.NewResponse(&ftlv1.UnsetSecretResponse{}), nil
}

func refFromConfigRef(cr *ftlv1.ConfigRef) cf.Ref {
return cf.NewRef(cr.GetModule(), cr.GetName())
}

func (s *AdminService) validateAgainstSchema(ctx context.Context, isSecret bool, ref cf.Ref, value json.RawMessage) error {
logger := log.FromContext(ctx)

// Globals aren't in the module schemas, so we have nothing to validate against.
if !ref.Module.Ok() {
return nil
}

// If we can't retrieve an active schema, skip validation.
sch, err := s.schr.GetActiveSchema(ctx)
if err != nil {
logger.Debugf("skipping validation; could not get the active schema: %v", err)
return nil
}

r := schema.RefKey{Module: ref.Module.Default(""), Name: ref.Name}.ToRef()
decl, ok := sch.Resolve(r).Get()
if !ok {
logger.Debugf("skipping validation; declaration %q not found", ref.Name)
return nil
}

var fieldType schema.Type
if isSecret {
decl, ok := decl.(*schema.Secret)
if !ok {
return fmt.Errorf("%q is not a secret declaration", ref.Name)
}
fieldType = decl.Type
} else {
decl, ok := decl.(*schema.Config)
if !ok {
return fmt.Errorf("%q is not a config declaration", ref.Name)
}
fieldType = decl.Type
}

var v any
err = encoding.Unmarshal(value, &v)
if err != nil {
return fmt.Errorf("could not unmarshal JSON value: %w", err)
}

err = schema.ValidateJSONValue(fieldType, []string{ref.Name}, v, sch)
if err != nil {
return fmt.Errorf("JSON validation failed: %w", err)
}

return nil
}
130 changes: 129 additions & 1 deletion backend/controller/admin/admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/alecthomas/types/optional"

ftlv1 "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1"
"github.com/TBD54566975/ftl/backend/schema"
cf "github.com/TBD54566975/ftl/common/configuration"
"github.com/TBD54566975/ftl/internal/log"
)
Expand All @@ -31,7 +32,7 @@ func TestAdminService(t *testing.T) {
cf.InlineProvider[cf.Secrets]{},
})
assert.NoError(t, err)
admin := NewAdminService(cm, sm)
admin := NewAdminService(cm, sm, &diskSchemaRetriever{})
assert.NotZero(t, admin)

expectedEnvarValue, err := json.MarshalIndent(map[string]string{"bar": "barfoo"}, "", " ")
Expand Down Expand Up @@ -140,3 +141,130 @@ func testAdminSecrets(
assert.Equal(t, entry.Value, string(resp.Msg.Value))
}
}

var testSchema = schema.MustValidate(&schema.Schema{
Modules: []*schema.Module{
{
Name: "batmobile",
Comments: []string{"A batmobile comment"},
Decls: []schema.Decl{
&schema.Secret{
Comments: []string{"top secret"},
Name: "owner",
Type: &schema.String{},
},
&schema.Secret{
Comments: []string{"ultra secret"},
Name: "horsepower",
Type: &schema.Int{},
},
&schema.Config{
Comments: []string{"car color"},
Name: "color",
Type: &schema.Ref{Module: "batmobile", Name: "Color"},
},
&schema.Config{
Comments: []string{"car capacity"},
Name: "capacity",
Type: &schema.Ref{Module: "batmobile", Name: "Capacity"},
},
&schema.Enum{
Comments: []string{"Car colors"},
Name: "Color",
Type: &schema.String{},
Variants: []*schema.EnumVariant{
{Name: "Black", Value: &schema.StringValue{Value: "Black"}},
{Name: "Blue", Value: &schema.StringValue{Value: "Blue"}},
{Name: "Green", Value: &schema.StringValue{Value: "Green"}},
},
},
&schema.Enum{
Comments: []string{"Car capacities"},
Name: "Capacity",
Type: &schema.Int{},
Variants: []*schema.EnumVariant{
{Name: "One", Value: &schema.IntValue{Value: int(1)}},
{Name: "Two", Value: &schema.IntValue{Value: int(2)}},
{Name: "Four", Value: &schema.IntValue{Value: int(4)}},
},
},
},
},
},
})

type mockSchemaRetriever struct {
}

func (d *mockSchemaRetriever) GetActiveSchema(ctx context.Context) (*schema.Schema, error) {
return testSchema, nil
}

func TestAdminValidation(t *testing.T) {
config := tempConfigPath(t, "testdata/ftl-project.toml", "admin")
ctx := log.ContextWithNewDefaultLogger(context.Background())

cm, err := cf.NewConfigurationManager(ctx, cf.ProjectConfigResolver[cf.Configuration]{Config: config})
assert.NoError(t, err)

sm, err := cf.New(ctx,
cf.ProjectConfigResolver[cf.Secrets]{Config: config},
[]cf.Provider[cf.Secrets]{
cf.EnvarProvider[cf.Secrets]{},
cf.InlineProvider[cf.Secrets]{},
})
assert.NoError(t, err)
admin := NewAdminService(cm, sm, &mockSchemaRetriever{})
assert.NotZero(t, admin)

testSetConfig(t, ctx, admin, "batmobile", "color", "Black", "")
testSetConfig(t, ctx, admin, "batmobile", "color", "Red", "JSON validation failed: Red is not a valid variant of enum batmobile.Color")
testSetConfig(t, ctx, admin, "batmobile", "capacity", 2, "")
testSetConfig(t, ctx, admin, "batmobile", "capacity", 3, "JSON validation failed: %!s(float64=3) is not a valid variant of enum batmobile.Capacity")

testSetSecret(t, ctx, admin, "batmobile", "owner", "Bruce Wayne", "")
testSetSecret(t, ctx, admin, "batmobile", "owner", 99, "JSON validation failed: owner has wrong type, expected String found float64")
testSetSecret(t, ctx, admin, "batmobile", "horsepower", 1000, "")
testSetSecret(t, ctx, admin, "batmobile", "horsepower", "thousand", "JSON validation failed: horsepower has wrong type, expected Int found string")

testSetConfig(t, ctx, admin, "", "city", "Gotham", "")
testSetSecret(t, ctx, admin, "", "universe", "DC", "")
}

// nolint
func testSetConfig(t testing.TB, ctx context.Context, admin *AdminService, module string, name string, jsonVal any, expectedError string) {
t.Helper()
buffer, err := json.Marshal(jsonVal)
assert.NoError(t, err)

configRef := &ftlv1.ConfigRef{Name: name}
if module != "" {
configRef.Module = &module
}

_, err = admin.ConfigSet(ctx, connect.NewRequest(&ftlv1.SetConfigRequest{
Provider: ftlv1.ConfigProvider_CONFIG_INLINE.Enum(),
Ref: configRef,
Value: buffer,
}))
assert.EqualError(t, err, expectedError)
}

// nolint
func testSetSecret(t testing.TB, ctx context.Context, admin *AdminService, module string, name string, jsonVal any, expectedError string) {
t.Helper()
buffer, err := json.Marshal(jsonVal)
assert.NoError(t, err)

configRef := &ftlv1.ConfigRef{Name: name}
if module != "" {
configRef.Module = &module
}

_, err = admin.SecretSet(ctx, connect.NewRequest(&ftlv1.SetSecretRequest{
Provider: ftlv1.SecretProvider_SECRET_INLINE.Enum(),
Ref: configRef,
Value: buffer,
}))
assert.EqualError(t, err, expectedError)
}
Loading

0 comments on commit df93625

Please sign in to comment.