feat(config): provision settings from secret files
- Load IdPs and supported instance-setting groups as runtime overlays from /etc/secrets. - Reject API mutations of deployment-managed resources and serialize authentication safety checks across database drivers. - Preserve upgrade compatibility, demo SSO policy, stable IdP ordering, and driver-specific transaction retries.
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
@@ -29,6 +30,9 @@ func (s *APIV1Service) CreateIdentityProvider(ctx context.Context, request *v1pb
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.Store.IsIdentityProviderDeploymentConfigured(idpUID) {
|
||||
return nil, status.Errorf(codes.FailedPrecondition, "identity provider %q is configured by the deployment", idpUID)
|
||||
}
|
||||
|
||||
storeIdp := convertIdentityProviderToStore(request.IdentityProvider)
|
||||
storeIdp.Uid = idpUID
|
||||
@@ -93,9 +97,12 @@ func (s *APIV1Service) UpdateIdentityProvider(ctx context.Context, request *v1pb
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "invalid identity provider name: %v", err)
|
||||
}
|
||||
if s.Store.IsIdentityProviderDeploymentConfigured(uid) {
|
||||
return nil, status.Errorf(codes.FailedPrecondition, "identity provider %q is configured by the deployment", uid)
|
||||
}
|
||||
|
||||
// Look up the IdP by UID to get the internal ID for update.
|
||||
existing, err := s.Store.GetIdentityProvider(ctx, &store.FindIdentityProvider{UID: &uid})
|
||||
existing, err := s.Store.GetStoredIdentityProvider(ctx, &store.FindIdentityProvider{UID: &uid})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get identity provider, error: %+v", err)
|
||||
}
|
||||
@@ -152,9 +159,12 @@ func (s *APIV1Service) DeleteIdentityProvider(ctx context.Context, request *v1pb
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "invalid identity provider name: %v", err)
|
||||
}
|
||||
if s.Store.IsIdentityProviderDeploymentConfigured(uid) {
|
||||
return nil, status.Errorf(codes.FailedPrecondition, "identity provider %q is configured by the deployment", uid)
|
||||
}
|
||||
|
||||
// Look up the IdP by UID to get the internal ID for deletion.
|
||||
identityProvider, err := s.Store.GetIdentityProvider(ctx, &store.FindIdentityProvider{UID: &uid})
|
||||
identityProvider, err := s.Store.GetStoredIdentityProvider(ctx, &store.FindIdentityProvider{UID: &uid})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to check identity provider existence: %v", err)
|
||||
}
|
||||
@@ -162,7 +172,10 @@ func (s *APIV1Service) DeleteIdentityProvider(ctx context.Context, request *v1pb
|
||||
return nil, status.Errorf(codes.NotFound, "identity provider not found")
|
||||
}
|
||||
|
||||
if err := s.Store.DeleteIdentityProvider(ctx, &store.DeleteIdentityProvider{ID: identityProvider.Id}); err != nil {
|
||||
if err := s.Store.DeleteIdentityProviderSafely(ctx, &store.DeleteIdentityProvider{ID: identityProvider.Id}); err != nil {
|
||||
if errors.Is(err, store.ErrUnsafeAuthenticationConfiguration) {
|
||||
return nil, status.Error(codes.FailedPrecondition, err.Error())
|
||||
}
|
||||
return nil, status.Errorf(codes.Internal, "failed to delete identity provider, error: %+v", err)
|
||||
}
|
||||
return &emptypb.Empty{}, nil
|
||||
|
||||
@@ -99,21 +99,36 @@ func (s *APIV1Service) getInstanceSettingByName(ctx context.Context, name string
|
||||
|
||||
instanceSettingKey := storepb.InstanceSettingKey(storepb.InstanceSettingKey_value[instanceSettingKeyString])
|
||||
// Get instance setting from store with default value.
|
||||
var instanceSetting *storepb.InstanceSetting
|
||||
switch instanceSettingKey {
|
||||
case storepb.InstanceSettingKey_BASIC:
|
||||
_, err = s.Store.GetInstanceBasicSetting(ctx)
|
||||
var setting *storepb.InstanceBasicSetting
|
||||
setting, err = s.Store.GetInstanceBasicSetting(ctx)
|
||||
instanceSetting = &storepb.InstanceSetting{Key: instanceSettingKey, Value: &storepb.InstanceSetting_BasicSetting{BasicSetting: setting}}
|
||||
case storepb.InstanceSettingKey_GENERAL:
|
||||
_, err = s.Store.GetInstanceGeneralSetting(ctx)
|
||||
var setting *storepb.InstanceGeneralSetting
|
||||
setting, err = s.Store.GetInstanceGeneralSetting(ctx)
|
||||
instanceSetting = &storepb.InstanceSetting{Key: instanceSettingKey, Value: &storepb.InstanceSetting_GeneralSetting{GeneralSetting: setting}}
|
||||
case storepb.InstanceSettingKey_MEMO_RELATED:
|
||||
_, err = s.Store.GetInstanceMemoRelatedSetting(ctx)
|
||||
var setting *storepb.InstanceMemoRelatedSetting
|
||||
setting, err = s.Store.GetInstanceMemoRelatedSetting(ctx)
|
||||
instanceSetting = &storepb.InstanceSetting{Key: instanceSettingKey, Value: &storepb.InstanceSetting_MemoRelatedSetting{MemoRelatedSetting: setting}}
|
||||
case storepb.InstanceSettingKey_STORAGE:
|
||||
_, err = s.Store.GetInstanceStorageSetting(ctx)
|
||||
var setting *storepb.InstanceStorageSetting
|
||||
setting, err = s.Store.GetInstanceStorageSetting(ctx)
|
||||
instanceSetting = &storepb.InstanceSetting{Key: instanceSettingKey, Value: &storepb.InstanceSetting_StorageSetting{StorageSetting: setting}}
|
||||
case storepb.InstanceSettingKey_TAGS:
|
||||
_, err = s.Store.GetInstanceTagsSetting(ctx)
|
||||
var setting *storepb.InstanceTagsSetting
|
||||
setting, err = s.Store.GetInstanceTagsSetting(ctx)
|
||||
instanceSetting = &storepb.InstanceSetting{Key: instanceSettingKey, Value: &storepb.InstanceSetting_TagsSetting{TagsSetting: setting}}
|
||||
case storepb.InstanceSettingKey_NOTIFICATION:
|
||||
_, err = s.Store.GetInstanceNotificationSetting(ctx)
|
||||
var setting *storepb.InstanceNotificationSetting
|
||||
setting, err = s.Store.GetInstanceNotificationSetting(ctx)
|
||||
instanceSetting = &storepb.InstanceSetting{Key: instanceSettingKey, Value: &storepb.InstanceSetting_NotificationSetting{NotificationSetting: setting}}
|
||||
case storepb.InstanceSettingKey_AI:
|
||||
_, err = s.Store.GetInstanceAISetting(ctx)
|
||||
var setting *storepb.InstanceAISetting
|
||||
setting, err = s.Store.GetInstanceAISetting(ctx)
|
||||
instanceSetting = &storepb.InstanceSetting{Key: instanceSettingKey, Value: &storepb.InstanceSetting_AiSetting{AiSetting: setting}}
|
||||
default:
|
||||
return nil, status.Errorf(codes.InvalidArgument, "unsupported instance setting key: %v", instanceSettingKey)
|
||||
}
|
||||
@@ -121,16 +136,6 @@ func (s *APIV1Service) getInstanceSettingByName(ctx context.Context, name string
|
||||
return nil, status.Errorf(codes.Internal, "failed to get instance setting: %v", err)
|
||||
}
|
||||
|
||||
instanceSetting, err := s.Store.GetInstanceSetting(ctx, &store.FindInstanceSetting{
|
||||
Name: instanceSettingKey.String(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get instance setting: %v", err)
|
||||
}
|
||||
if instanceSetting == nil {
|
||||
return nil, status.Errorf(codes.NotFound, "instance setting not found")
|
||||
}
|
||||
|
||||
// Storage and notification settings contain credentials; restrict to admins only.
|
||||
if instanceSetting.Key == storepb.InstanceSettingKey_STORAGE ||
|
||||
instanceSetting.Key == storepb.InstanceSettingKey_NOTIFICATION {
|
||||
@@ -183,6 +188,17 @@ func (s *APIV1Service) UpdateInstanceSetting(ctx context.Context, request *v1pb.
|
||||
if user.Role != store.RoleAdmin {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
if request.Setting == nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "instance setting is required")
|
||||
}
|
||||
settingKeyString, err := ExtractInstanceSettingKeyFromName(request.Setting.Name)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "invalid instance setting name: %v", err)
|
||||
}
|
||||
settingKey := storepb.InstanceSettingKey(storepb.InstanceSettingKey_value[settingKeyString])
|
||||
if s.Store.IsInstanceSettingDeploymentConfigured(settingKey) {
|
||||
return nil, status.Errorf(codes.FailedPrecondition, "instance setting %q is configured by the deployment", settingKeyString)
|
||||
}
|
||||
|
||||
// TODO: Apply update_mask if specified
|
||||
_ = request.UpdateMask
|
||||
@@ -221,8 +237,16 @@ func (s *APIV1Service) UpdateInstanceSetting(ctx context.Context, request *v1pb.
|
||||
// No credential preservation needed for other setting types.
|
||||
}
|
||||
|
||||
instanceSetting, err := s.Store.UpsertInstanceSetting(ctx, updateSetting)
|
||||
var instanceSetting *storepb.InstanceSetting
|
||||
if updateSetting.Key == storepb.InstanceSettingKey_GENERAL {
|
||||
instanceSetting, err = s.Store.UpsertInstanceGeneralSettingSafely(ctx, updateSetting)
|
||||
} else {
|
||||
instanceSetting, err = s.Store.UpsertInstanceSetting(ctx, updateSetting)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrUnsafeAuthenticationConfiguration) {
|
||||
return nil, status.Error(codes.FailedPrecondition, err.Error())
|
||||
}
|
||||
return nil, status.Errorf(codes.Internal, "failed to upsert instance setting: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/fieldmaskpb"
|
||||
|
||||
v1pb "github.com/usememos/memos/proto/gen/api/v1"
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
)
|
||||
|
||||
func TestDeploymentConfiguredResourcesRejectMutations(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
admin, err := ts.CreateHostUser(ctx, "admin")
|
||||
require.NoError(t, err)
|
||||
adminCtx := ts.CreateUserContext(ctx, admin.ID)
|
||||
|
||||
dir := t.TempDir()
|
||||
writeDeploymentProto(t, filepath.Join(dir, "memos-idp-primary.json"), testStoreIdentityProvider("primary-sso"))
|
||||
writeDeploymentProto(t, filepath.Join(dir, "memos-instance-setting-general.json"), &storepb.InstanceSetting{
|
||||
Key: storepb.InstanceSettingKey_GENERAL,
|
||||
Value: &storepb.InstanceSetting_GeneralSetting{GeneralSetting: &storepb.InstanceGeneralSetting{WeekStartDayOffset: 1}},
|
||||
})
|
||||
writeDeploymentProto(t, filepath.Join(dir, "memos-instance-setting-storage.json"), &storepb.InstanceSetting{
|
||||
Key: storepb.InstanceSettingKey_STORAGE,
|
||||
Value: &storepb.InstanceSetting_StorageSetting{StorageSetting: &storepb.InstanceStorageSetting{
|
||||
StorageType: storepb.InstanceStorageSetting_LOCAL,
|
||||
}},
|
||||
})
|
||||
require.NoError(t, ts.Store.LoadDeploymentConfigurationDir(ctx, dir))
|
||||
|
||||
providers, err := ts.Service.ListIdentityProviders(ctx, &v1pb.ListIdentityProvidersRequest{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, providers.IdentityProviders, 1)
|
||||
require.Equal(t, "Primary SSO", providers.IdentityProviders[0].Title)
|
||||
|
||||
setting, err := ts.Service.GetInstanceSetting(ctx, &v1pb.GetInstanceSettingRequest{Name: "instance/settings/GENERAL"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int32(1), setting.GetGeneralSetting().WeekStartDayOffset)
|
||||
storageSetting, err := ts.Service.GetInstanceSetting(adminCtx, &v1pb.GetInstanceSettingRequest{Name: "instance/settings/STORAGE"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(30), storageSetting.GetStorageSetting().UploadSizeLimitMb)
|
||||
require.Equal(t, "assets/{timestamp}_{uuid}_{filename}", storageSetting.GetStorageSetting().FilepathTemplate)
|
||||
|
||||
_, err = ts.Service.CreateIdentityProvider(adminCtx, &v1pb.CreateIdentityProviderRequest{
|
||||
IdentityProviderId: "primary-sso",
|
||||
IdentityProvider: testAPIIdentityProvider("Replacement"),
|
||||
})
|
||||
require.Equal(t, codes.FailedPrecondition, status.Code(err))
|
||||
|
||||
_, err = ts.Service.UpdateIdentityProvider(adminCtx, &v1pb.UpdateIdentityProviderRequest{
|
||||
IdentityProvider: &v1pb.IdentityProvider{Name: "identity-providers/primary-sso", Title: "Changed"},
|
||||
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"title"}},
|
||||
})
|
||||
require.Equal(t, codes.FailedPrecondition, status.Code(err))
|
||||
|
||||
_, err = ts.Service.DeleteIdentityProvider(adminCtx, &v1pb.DeleteIdentityProviderRequest{Name: "identity-providers/primary-sso"})
|
||||
require.Equal(t, codes.FailedPrecondition, status.Code(err))
|
||||
|
||||
_, err = ts.Service.UpdateInstanceSetting(adminCtx, &v1pb.UpdateInstanceSettingRequest{
|
||||
Setting: &v1pb.InstanceSetting{
|
||||
Name: "instance/settings/GENERAL",
|
||||
Value: &v1pb.InstanceSetting_GeneralSetting_{GeneralSetting: &v1pb.InstanceSetting_GeneralSetting{}},
|
||||
},
|
||||
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"general_setting"}},
|
||||
})
|
||||
require.Equal(t, codes.FailedPrecondition, status.Code(err))
|
||||
}
|
||||
|
||||
func TestAuthenticationMutationAPIRejectsLockout(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("GENERAL update requires an effective identity provider", func(t *testing.T) {
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
admin, err := ts.CreateHostUser(ctx, "admin")
|
||||
require.NoError(t, err)
|
||||
adminCtx := ts.CreateUserContext(ctx, admin.ID)
|
||||
|
||||
_, err = ts.Service.UpdateInstanceSetting(adminCtx, &v1pb.UpdateInstanceSettingRequest{Setting: &v1pb.InstanceSetting{
|
||||
Name: "instance/settings/GENERAL",
|
||||
Value: &v1pb.InstanceSetting_GeneralSetting_{GeneralSetting: &v1pb.InstanceSetting_GeneralSetting{
|
||||
DisallowPasswordAuth: true,
|
||||
}},
|
||||
}})
|
||||
require.Equal(t, codes.FailedPrecondition, status.Code(err))
|
||||
})
|
||||
|
||||
t.Run("last IdP cannot be deleted while regular password auth is disabled", func(t *testing.T) {
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
admin, err := ts.CreateHostUser(ctx, "admin")
|
||||
require.NoError(t, err)
|
||||
adminCtx := ts.CreateUserContext(ctx, admin.ID)
|
||||
created, err := ts.Service.CreateIdentityProvider(adminCtx, &v1pb.CreateIdentityProviderRequest{
|
||||
IdentityProviderId: "primary-sso",
|
||||
IdentityProvider: testAPIIdentityProvider("Primary"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = ts.Service.UpdateInstanceSetting(adminCtx, &v1pb.UpdateInstanceSettingRequest{Setting: &v1pb.InstanceSetting{
|
||||
Name: "instance/settings/GENERAL",
|
||||
Value: &v1pb.InstanceSetting_GeneralSetting_{GeneralSetting: &v1pb.InstanceSetting_GeneralSetting{
|
||||
DisallowPasswordAuth: true,
|
||||
}},
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = ts.Service.DeleteIdentityProvider(adminCtx, &v1pb.DeleteIdentityProviderRequest{Name: created.Name})
|
||||
require.Equal(t, codes.FailedPrecondition, status.Code(err))
|
||||
})
|
||||
}
|
||||
|
||||
func testStoreIdentityProvider(uid string) *storepb.IdentityProvider {
|
||||
return &storepb.IdentityProvider{
|
||||
Uid: uid,
|
||||
Name: "Primary SSO",
|
||||
Type: storepb.IdentityProvider_OAUTH2,
|
||||
Config: &storepb.IdentityProviderConfig{Config: &storepb.IdentityProviderConfig_Oauth2Config{Oauth2Config: &storepb.OAuth2Config{
|
||||
ClientId: "client-id",
|
||||
ClientSecret: "client-secret",
|
||||
AuthUrl: "https://example.com/authorize",
|
||||
TokenUrl: "https://example.com/token",
|
||||
UserInfoUrl: "https://example.com/userinfo",
|
||||
Scopes: []string{"openid", "profile"},
|
||||
FieldMapping: &storepb.FieldMapping{Identifier: "sub"},
|
||||
}}},
|
||||
}
|
||||
}
|
||||
|
||||
func testAPIIdentityProvider(title string) *v1pb.IdentityProvider {
|
||||
return &v1pb.IdentityProvider{
|
||||
Title: title,
|
||||
Type: v1pb.IdentityProvider_OAUTH2,
|
||||
Config: &v1pb.IdentityProviderConfig{Config: &v1pb.IdentityProviderConfig_Oauth2Config{Oauth2Config: &v1pb.OAuth2Config{
|
||||
ClientId: "client-id",
|
||||
ClientSecret: "client-secret",
|
||||
AuthUrl: "https://example.com/authorize",
|
||||
TokenUrl: "https://example.com/token",
|
||||
UserInfoUrl: "https://example.com/userinfo",
|
||||
Scopes: []string{"openid", "profile"},
|
||||
FieldMapping: &v1pb.FieldMapping{Identifier: "sub"},
|
||||
}}},
|
||||
}
|
||||
}
|
||||
|
||||
func writeDeploymentProto(t *testing.T, path string, message proto.Message) {
|
||||
t.Helper()
|
||||
content, err := (protojson.MarshalOptions{Indent: " "}).Marshal(message)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(path, content, 0600))
|
||||
}
|
||||
Reference in New Issue
Block a user