diff --git a/conf/config.yaml b/conf/config.yaml index b0b2399d..83b82114 100644 --- a/conf/config.yaml +++ b/conf/config.yaml @@ -220,6 +220,23 @@ auth: lockDuration: 60s # inmemory | redis backend: inmemory + # master key used to encrypt source code credentials at rest. + # when empty, sigma generates one on first start and stores it in the settings table. + # generate the key with: openssl rand -base64 32 + credentialKey: "" + +scm: + # source code management providers available for code repository sync and image builds. + # this section is independent of auth.oauth2, which only decides whether a provider can be + # used to sign in. when empty, the providers are derived from the enabled oauth2 providers. + providers: [] + # providers: + # - provider: github + # baseUrl: https://github.com + # - provider: gitlab + # baseUrl: https://gitlab.com + # - provider: gitea + # baseUrl: https://gitea.example.com audit: # record completed authenticated business HTTP requests diff --git a/deploy/sigma/templates/configmap.yaml b/deploy/sigma/templates/configmap.yaml index aa209cbc..37214c06 100644 --- a/deploy/sigma/templates/configmap.yaml +++ b/deploy/sigma/templates/configmap.yaml @@ -146,3 +146,7 @@ data: maxFailures: {{ .Values.config.auth.loginRateLimit.maxFailures | default 5 }} window: {{ .Values.config.auth.loginRateLimit.window | default "5m" | quote }} delay: {{ .Values.config.auth.loginRateLimit.delay | default "1s" | quote }} + credentialKey: {{ .Values.config.auth.credentialKey | default "" | quote }} + scm: + providers: + {{- toYaml (.Values.config.scm.providers | default list) | nindent 8 }} diff --git a/deploy/sigma/values.yaml b/deploy/sigma/values.yaml index 2dd46cc7..183bcb9f 100644 --- a/deploy/sigma/values.yaml +++ b/deploy/sigma/values.yaml @@ -238,6 +238,19 @@ config: maxFailures: 5 window: 5m delay: 1s + # master key used to encrypt source code credentials at rest. + # when empty, sigma generates one on first start and stores it in the database. + credentialKey: "" + scm: + # source code management providers available for code repository sync and builds, + # independent of the oauth2 login providers. When empty, they are derived from the + # enabled oauth2 providers. + # providers: + # - provider: github + # baseUrl: https://github.com + # - provider: gitea + # baseUrl: https://gitea.example.com + providers: [] external: postgresql: diff --git a/docs/configuration.md b/docs/configuration.md index d9788b24..fea48d6c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -205,6 +205,28 @@ auth: window: 5m # fixed delay applied to every authentication attempt after maxFailures delay: 1s + # master key used to encrypt source code credentials (access tokens, ssh keys, + # passwords) at rest. when empty, sigma generates one on first start and stores it in + # the settings table. rotating it makes the stored credentials unreadable, so users have + # to enter them again. + # generate the key with: openssl rand -base64 32 + credentialKey: "" + +scm: + # source code management providers available for code repository sync and image builds. + # this section is independent of auth.oauth2, which only decides whether a provider can be + # used to sign in. when empty, the providers are derived from the enabled oauth2 providers, + # so an existing configuration keeps working. + providers: [] + # providers: + # - provider: github + # baseUrl: https://github.com + # - provider: gitlab + # # self-hosted gitlab + # baseUrl: https://gitlab.example.com + # - provider: gitea + # # gitea has no default base url and is skipped when it is empty + # baseUrl: https://gitea.example.com audit: # record completed authenticated business HTTP requests diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index 10f94bd9..cafe60ec 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -209,6 +209,28 @@ auth: window: 5m # fixed delay applied to every authentication attempt after maxFailures delay: 1s + # master key used to encrypt source code credentials (access tokens, ssh keys, + # passwords) at rest. when empty, sigma generates one on first start and stores it in + # the settings table. rotating it makes the stored credentials unreadable, so users have + # to enter them again. + # generate the key with: openssl rand -base64 32 + credentialKey: "" + +scm: + # source code management providers available for code repository sync and image builds. + # this section is independent of auth.oauth2, which only decides whether a provider can be + # used to sign in. when empty, the providers are derived from the enabled oauth2 providers, + # so an existing configuration keeps working. + providers: [] + # providers: + # - provider: github + # baseUrl: https://github.com + # - provider: gitlab + # # self-hosted gitlab + # baseUrl: https://gitlab.example.com + # - provider: gitea + # # gitea has no default base url and is skipped when it is empty + # baseUrl: https://gitea.example.com audit: # record completed authenticated business HTTP requests diff --git a/pkg/config/configs_test.go b/pkg/config/configs_test.go index 52153913..d53b1e34 100644 --- a/pkg/config/configs_test.go +++ b/pkg/config/configs_test.go @@ -21,6 +21,8 @@ import ( "github.com/stretchr/testify/require" "gopkg.in/yaml.v3" + + "github.com/go-sigma/sigma/pkg/api/enums" ) func errChecker(config Configuration) error { @@ -199,3 +201,91 @@ func TestConfigurationApplyEnvOverridesKeepsUnsetValues(t *testing.T) { require.Equal(t, "oss-ak-yaml", cfg.Storage.Oss.Ak) require.Equal(t, "oss-sk-yaml", cfg.Storage.Oss.Sk) } + +func TestConfigurationScmWithDefaults(t *testing.T) { + tests := []struct { + name string + providers []ConfigurationScmProvider + want []ConfigurationScmProvider + }{ + { + name: "fills hosted defaults and trims slash", + providers: []ConfigurationScmProvider{ + {Provider: enums.ScmProviderGithub, BaseURL: "https://github.example.com/"}, + {Provider: enums.ScmProviderGitlab}, + }, + want: []ConfigurationScmProvider{ + {Provider: enums.ScmProviderGithub, BaseURL: "https://github.example.com"}, + {Provider: enums.ScmProviderGitlab, BaseURL: defaultScmBaseURLGitlab}, + }, + }, + { + name: "drops gitea without base url", + providers: []ConfigurationScmProvider{ + {Provider: enums.ScmProviderGitea}, + {Provider: enums.ScmProviderGithub}, + }, + want: []ConfigurationScmProvider{ + {Provider: enums.ScmProviderGithub, BaseURL: defaultScmBaseURLGithub}, + }, + }, + { + name: "drops unknown and duplicated providers", + providers: []ConfigurationScmProvider{ + {Provider: enums.ScmProviderNone}, + {Provider: enums.ScmProviderGithub}, + {Provider: enums.ScmProviderGithub, BaseURL: "https://github.example.com"}, + }, + want: []ConfigurationScmProvider{ + {Provider: enums.ScmProviderGithub, BaseURL: defaultScmBaseURLGithub}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scm := ConfigurationScm{Providers: tt.providers} + scm.WithDefaults() + require.Equal(t, tt.want, scm.Providers) + }) + } +} + +func TestConfigurationResolveScmProviders(t *testing.T) { + t.Run("configured providers win", func(t *testing.T) { + cfg := &Configuration{ + Auth: ConfigurationAuth{Oauth2: ConfigurationAuthOauth2{Github: ConfigurationAuthOauth2Github{Enabled: true}}}, + Scm: ConfigurationScm{Providers: []ConfigurationScmProvider{{Provider: enums.ScmProviderGitea, BaseURL: "https://gitea.example.com"}}}, + } + require.Equal(t, cfg.Scm.Providers, cfg.ResolveScmProviders()) + }) + + t.Run("derives from oauth2 login providers", func(t *testing.T) { + cfg := &Configuration{ + Auth: ConfigurationAuth{Oauth2: ConfigurationAuthOauth2{ + Github: ConfigurationAuthOauth2Github{Enabled: true}, + Gitlab: ConfigurationAuthOauth2Gitlab{Enabled: true}, + }}, + } + require.Equal(t, []ConfigurationScmProvider{ + {Provider: enums.ScmProviderGithub, BaseURL: defaultScmBaseURLGithub}, + {Provider: enums.ScmProviderGitlab, BaseURL: defaultScmBaseURLGitlab}, + }, cfg.ResolveScmProviders()) + }) + + t.Run("no oauth2 login provider means no scm provider", func(t *testing.T) { + cfg := &Configuration{} + require.Empty(t, cfg.ResolveScmProviders()) + }) + + t.Run("ScmProvider reports enablement", func(t *testing.T) { + cfg := &Configuration{ + Scm: ConfigurationScm{Providers: []ConfigurationScmProvider{{Provider: enums.ScmProviderGithub, BaseURL: defaultScmBaseURLGithub}}}, + } + provider, ok := cfg.ScmProvider(enums.ProviderGithub) + require.True(t, ok) + require.Equal(t, defaultScmBaseURLGithub, provider.BaseURL) + + _, ok = cfg.ScmProvider(enums.ProviderGitlab) + require.False(t, ok) + }) +} diff --git a/pkg/config/configuration.go b/pkg/config/configuration.go index 20d3a305..2fc3f69f 100644 --- a/pkg/config/configuration.go +++ b/pkg/config/configuration.go @@ -15,6 +15,8 @@ package config import ( + "log/slog" + "slices" "strings" "sync" "time" @@ -64,6 +66,7 @@ type Configuration struct { Proxy ConfigurationProxy `yaml:"proxy"` Daemon ConfigurationDaemon `yaml:"daemon"` Auth ConfigurationAuth `yaml:"auth"` + Scm ConfigurationScm `yaml:"scm"` Audit ConfigurationAudit `yaml:"audit"` Trace ConfigurationTrace `yaml:"trace"` Analytics ConfigurationAnalytics `yaml:"analytics"` @@ -81,6 +84,7 @@ func (c *Configuration) WithDefaults() { c.Cache.WithDefaults() c.Locker.WithDefaults() c.Database.WithDefaults() + c.Scm.WithDefaults() } // ConfigurationAudit configures HTTP audit recording. @@ -707,6 +711,97 @@ func (c *ConfigurationDaemon) WithDefaults() { c.SizeReconcile.WithDefaults() } +// Default base URLs of the hosted source-code management providers. Gitea has no +// default because self-hosted instances are the only sensible target. +const ( + defaultScmBaseURLGithub = "https://github.com" + defaultScmBaseURLGitlab = "https://gitlab.com" +) + +// ConfigurationScmProvider configures one source-code management provider: which +// provider is available for code repository sync and image builds, and where it lives. +// +// It is intentionally separate from auth.oauth2, which only decides whether a provider +// can be used to sign in. +type ConfigurationScmProvider struct { + Provider enums.ScmProvider `yaml:"provider"` + BaseURL string `yaml:"baseUrl"` +} + +// ConfigurationScm configures the source-code management providers available to users. +type ConfigurationScm struct { + Providers []ConfigurationScmProvider `yaml:"providers"` +} + +// WithDefaults normalizes the configured providers: it trims trailing slashes, fills the +// hosted default base URLs and drops entries that are not usable. +func (c *ConfigurationScm) WithDefaults() { + providers := make([]ConfigurationScmProvider, 0, len(c.Providers)) + for _, provider := range c.Providers { + provider.BaseURL = strings.TrimSuffix(strings.TrimSpace(provider.BaseURL), "/") + switch provider.Provider { + case enums.ScmProviderGithub: + if provider.BaseURL == "" { + provider.BaseURL = defaultScmBaseURLGithub + } + case enums.ScmProviderGitlab: + if provider.BaseURL == "" { + provider.BaseURL = defaultScmBaseURLGitlab + } + case enums.ScmProviderGitea: + if provider.BaseURL == "" { + slog.Warn("scm provider gitea is ignored because baseUrl is empty", "provider", provider.Provider.String()) + continue + } + default: + slog.Warn("scm provider is ignored because it is unknown", "provider", provider.Provider.String()) + continue + } + if slices.ContainsFunc(providers, func(item ConfigurationScmProvider) bool { return item.Provider == provider.Provider }) { + slog.Warn("duplicated scm provider is ignored", "provider", provider.Provider.String()) + continue + } + providers = append(providers, provider) + } + c.Providers = providers +} + +// ResolveScmProviders returns the effective provider list. +// +// When scm.providers is empty it is derived from the enabled OAuth2 login providers, so +// existing configurations keep working: a deployment that could sign in with GitHub or +// GitLab could already list and clone code repositories. +func (c *Configuration) ResolveScmProviders() []ConfigurationScmProvider { + if len(c.Scm.Providers) > 0 { + return c.Scm.Providers + } + var providers []ConfigurationScmProvider + if c.Auth.Oauth2.Github.Enabled { + providers = append(providers, ConfigurationScmProvider{ + Provider: enums.ScmProviderGithub, + BaseURL: defaultScmBaseURLGithub, + }) + } + if c.Auth.Oauth2.Gitlab.Enabled { + providers = append(providers, ConfigurationScmProvider{ + Provider: enums.ScmProviderGitlab, + BaseURL: defaultScmBaseURLGitlab, + }) + } + return providers +} + +// ScmProvider returns the configuration of one provider and whether it is enabled. +func (c *Configuration) ScmProvider(provider enums.Provider) (ConfigurationScmProvider, bool) { + scmProvider := enums.ScmProvider(provider.String()) + for _, item := range c.ResolveScmProviders() { + if item.Provider == scmProvider { + return item, true + } + } + return ConfigurationScmProvider{}, false +} + // ConfigurationAuthInternalUser configures the built-in internal user account used by system components. type ConfigurationAuthInternalUser struct { Username string `yaml:"username"` @@ -783,6 +878,11 @@ type ConfigurationAuth struct { Oauth2 ConfigurationAuthOauth2 `yaml:"oauth2"` Jwt ConfigurationAuthJwt `yaml:"jwt"` LoginRateLimit ConfigurationAuthLoginRateLimit `yaml:"loginRateLimit"` + // CredentialKey is the master key used to encrypt SCM credentials at rest + // (AES-256-GCM). When empty, sigma generates a random key on first start and + // stores it in the settings table. Rotating it makes all stored SCM + // credentials unreadable, so they must be re-entered afterwards. + CredentialKey string `yaml:"credentialKey"` } // ConfigurationAuthLoginRateLimit configures failed-login throttling by username. diff --git a/pkg/consts/setting.go b/pkg/consts/setting.go index a18eb24d..17993f15 100644 --- a/pkg/consts/setting.go +++ b/pkg/consts/setting.go @@ -19,4 +19,8 @@ const ( SettingSignPrivateKey = "signing.private_key" // SettingSignPublicKey is the public key for signing SettingSignPublicKey = "signing.public_key" + // SettingCredentialMasterKey is the settings key holding the master key that encrypts + // SCM credentials at rest. The value is generated on first start when + // auth.credentialKey is not configured. + SettingCredentialMasterKey = "credential.master_key" // #nosec G101 -- settings key name, not a credential )