diff --git a/internal/installer/argocd/install_and_apps.go b/internal/installer/argocd/install_and_apps.go index 035612f4..77795cd5 100644 --- a/internal/installer/argocd/install_and_apps.go +++ b/internal/installer/argocd/install_and_apps.go @@ -11,6 +11,7 @@ import ( "github.com/codesphere-cloud/oms/internal/installer/files" "github.com/codesphere-cloud/oms/internal/installer/secrets" "github.com/codesphere-cloud/oms/internal/installer/vault" + "github.com/codesphere-cloud/oms/internal/util" "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -69,12 +70,16 @@ func (i *AppInstaller) SyncVaultSecret(ctx context.Context) error { // InstallPCApps creates or updates the pc-applications app-of-apps ArgoCD // Application using the chart version from the supplied installer BOM. func (i *AppInstaller) InstallPCApps(ctx context.Context, bomPath string) error { + // Values derived from the install config form the base; an explicit pcApps block in + // config.yaml wins over them, and the --pc-apps-values files win over both. + values := util.DeepMergeMaps(installer.OpenFgaPcAppsValues(&i.cfg.Config, i.cfg.Vault), i.cfg.Config.PcApps) + pcApps, err := installer.NewPcAppsFromBom( i.cfg.KubeClient, bomPath, DefaultNamespace, i.cfg.PCAppsValues, - i.cfg.Config.PcApps, + values, ) if err != nil { return fmt.Errorf("failed to initialize pc-apps installer: %w", err) diff --git a/internal/installer/config_manager.go b/internal/installer/config_manager.go index 8d3eacaa..21081fb1 100644 --- a/internal/installer/config_manager.go +++ b/internal/installer/config_manager.go @@ -230,6 +230,39 @@ func (g *InstallConfig) ValidateInstallConfig() []string { } } + errors = append(errors, validateOpenFga(g.Config.Codesphere.OpenFga)...) + + return errors +} + +// validateOpenFga checks the codesphere.openFga block. A data center that does not deploy +// OpenFGA has nowhere to fall back to, so it must name the instance it uses; a data center that +// exposes one must say under which host. +func validateOpenFga(config *files.OpenFgaConfig) []string { + if config == nil { + return nil + } + + errors := []string{} + if !config.DeploysOpenFga() && config.APIURL == "" { + errors = append(errors, "OpenFGA apiUrl is required when codesphere.openFga.deploy is false") + } + + if config.APIURL != "" { + if _, err := url.ParseRequestURI(config.APIURL); err != nil { + errors = append(errors, "OpenFGA apiUrl must be a valid URL") + } + } + + if config.ExposesOpenFga() { + if config.Expose.Host == "" { + errors = append(errors, "OpenFGA expose host is required when codesphere.openFga.expose.enabled is true") + } + + if !config.DeploysOpenFga() { + errors = append(errors, "OpenFGA cannot be exposed by a data center that does not deploy it") + } + } return errors } diff --git a/internal/installer/config_manager_test.go b/internal/installer/config_manager_test.go index 3b6830b8..8aed5c64 100644 --- a/internal/installer/config_manager_test.go +++ b/internal/installer/config_manager_test.go @@ -342,6 +342,55 @@ var _ = Describe("ConfigManager", func() { }) }) + Context("openFga validation", func() { + It("should accept an absent openFga block", func() { + configManager.Config.Codesphere.OpenFga = nil + errors := configManager.ValidateInstallConfig() + Expect(errors).NotTo(ContainElement(ContainSubstring("OpenFGA"))) + }) + + It("should require an apiUrl when the data center does not deploy OpenFGA", func() { + deploy := false + configManager.Config.Codesphere.OpenFga = &files.OpenFgaConfig{Deploy: &deploy} + errors := configManager.ValidateInstallConfig() + Expect(errors).To(ContainElement(ContainSubstring("OpenFGA apiUrl is required"))) + }) + + It("should validate the apiUrl format", func() { + configManager.Config.Codesphere.OpenFga = &files.OpenFgaConfig{APIURL: "not-a-valid-url"} + errors := configManager.ValidateInstallConfig() + Expect(errors).To(ContainElement(ContainSubstring("OpenFGA apiUrl must be a valid URL"))) + }) + + It("should require a host when exposing OpenFGA", func() { + configManager.Config.Codesphere.OpenFga = &files.OpenFgaConfig{ + Expose: &files.OpenFgaExposeConfig{Enabled: true}, + } + errors := configManager.ValidateInstallConfig() + Expect(errors).To(ContainElement(ContainSubstring("OpenFGA expose host is required"))) + }) + + It("should reject exposing an OpenFGA the data center does not deploy", func() { + deploy := false + configManager.Config.Codesphere.OpenFga = &files.OpenFgaConfig{ + Deploy: &deploy, + APIURL: "https://openfga.1.cs.example.com", + Expose: &files.OpenFgaExposeConfig{Enabled: true, Host: "openfga.2.cs.example.com"}, + } + errors := configManager.ValidateInstallConfig() + Expect(errors).To(ContainElement(ContainSubstring("cannot be exposed by a data center that does not deploy it"))) + }) + + It("should accept a data center that deploys and exposes OpenFGA", func() { + configManager.Config.Codesphere.OpenFga = &files.OpenFgaConfig{ + APIURL: "https://openfga.1.cs.example.com", + Expose: &files.OpenFgaExposeConfig{Enabled: true, Host: "openfga.1.cs.example.com"}, + } + errors := configManager.ValidateInstallConfig() + Expect(errors).NotTo(ContainElement(ContainSubstring("OpenFGA"))) + }) + }) + Context("ceph validation", func() { It("should require at least one Ceph host", func() { configManager.Config.Ceph.Hosts = []files.CephHost{} diff --git a/internal/installer/files/config_yaml.go b/internal/installer/files/config_yaml.go index cce8757e..b91f8a56 100644 --- a/internal/installer/files/config_yaml.go +++ b/internal/installer/files/config_yaml.go @@ -361,6 +361,7 @@ type CodesphereConfig struct { ManagedServices []ManagedServiceConfig `yaml:"managedServices,omitempty"` OpenBao *OpenBaoConfig `yaml:"openBao,omitempty"` OpenfgaBackups *OpenfgaBackupsConfig `yaml:"openfgaBackups,omitempty"` + OpenFga *OpenFgaConfig `yaml:"openFga,omitempty"` Migration *MigrationConfig `yaml:"migration,omitempty"` TelemetryExport *TelemetryExport `yaml:"telemetryExport,omitempty"` Override ChartOverride `yaml:"override,omitempty"` @@ -398,6 +399,39 @@ type OpenfgaBackupsConfig struct { RetentionPolicy string `yaml:"retentionPolicy,omitempty"` } +// OpenFgaConfig configures the authorization store. One OpenFGA instance serves a whole +// installation, so in a multi-data-center setup exactly one data center deploys and exposes +// it (Deploy + Expose) and every other one only points at it (APIURL). +type OpenFgaConfig struct { + // Deploy controls whether pc-applications deploys OpenFGA in this data center. + // Defaults to true when unset, matching the pc-applications chart. + Deploy *bool `yaml:"deploy,omitempty"` + // APIURL is the URL the Codesphere services reach OpenFGA at. Defaults to the + // in-cluster service of a locally deployed OpenFGA; required when Deploy is false. + APIURL string `yaml:"apiUrl,omitempty"` + // Expose publishes the deployed OpenFGA through the Codesphere gateway so the other + // data centers can reach it. + Expose *OpenFgaExposeConfig `yaml:"expose,omitempty"` +} + +// OpenFgaExposeConfig publishes a locally deployed OpenFGA through the Codesphere gateway. +type OpenFgaExposeConfig struct { + Enabled bool `yaml:"enabled"` + // Host OpenFGA is served under. Must resolve to this data center's public IP and + // is what the other data centers put in their APIURL. + Host string `yaml:"host,omitempty"` +} + +// DeploysOpenFga reports whether pc-applications should deploy OpenFGA in this data center. +func (c *OpenFgaConfig) DeploysOpenFga() bool { + return c == nil || c.Deploy == nil || *c.Deploy +} + +// ExposesOpenFga reports whether the deployed OpenFGA is published through the gateway. +func (c *OpenFgaConfig) ExposesOpenFga() bool { + return c != nil && c.Expose != nil && c.Expose.Enabled +} + type OAuthProvidersConfig struct { Oidc *OidcOAuthProvider `yaml:"oidc,omitempty"` } diff --git a/internal/installer/openfga_pc_apps.go b/internal/installer/openfga_pc_apps.go new file mode 100644 index 00000000..00d29a86 --- /dev/null +++ b/internal/installer/openfga_pc_apps.go @@ -0,0 +1,116 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "log" + + "github.com/codesphere-cloud/oms/internal/installer/files" +) + +// openFgaPresharedKeysSecret is the Secret the openfga chart reads the preshared key from. +// Its content is synced out of the installation vault by the chart's own ExternalSecret, so +// oms only has to name it. +const openFgaPresharedKeysSecret = "openfga-preshared-keys" + +// OpenFgaPcAppsValues derives the pc-applications values for OpenFGA. They are the *base* of +// the pc-apps values: an explicit `pcApps` block in config.yaml and any --pc-apps-values file +// still override them. Returns nil when there is nothing to say, leaving the chart defaults +// untouched. +func OpenFgaPcAppsValues(config *files.RootConfig, vault *files.InstallVault) files.ChartValues { + application := files.ChartValues{} + chartValues := files.ChartValues{} + + if fga := config.Codesphere.OpenFga; fga != nil { + application["enabled"] = fga.DeploysOpenFga() + + if fga.Expose != nil { + chartValues["gateway"] = gatewayValues(config, fga.Expose) + } + } + + if authn := authnValues(vault); authn != nil { + chartValues["openfga"] = files.ChartValues{"authn": authn} + } + + if len(chartValues) > 0 { + application["valuesObject"] = chartValues + } + + if len(application) == 0 { + return nil + } + + return files.ChartValues{ + "applications": files.ChartValues{ + "openfga": application, + }, + } +} + +// authnValues makes OpenFGA require the preshared key exactly when the installation has one, +// so it stays in step with the Codesphere services: they read the same vault entry and treat +// it as optional too. Returns nil for an installation without the key, which runs an +// unauthenticated OpenFGA. Services that started before the key existed only send it once +// their pods roll. +func authnValues(vault *files.InstallVault) files.ChartValues { + if !hasOpenFgaPresharedKey(vault) { + log.Printf( + "OpenFGA: %s is not in the vault, deploying OpenFGA without authentication."+ + " Add the key with `oms update install-config` — a future version will require it.\n", + files.SecretOpenFgaPresharedKey, + ) + + return nil + } + + return files.ChartValues{ + "method": "preshared", + "preshared": files.ChartValues{ + "keysSecret": openFgaPresharedKeysSecret, + }, + } +} + +// gatewayValues publishes a locally deployed OpenFGA through the Codesphere gateway, so the +// Codesphere services of the other data centers can reach it. +func gatewayValues(config *files.RootConfig, expose *files.OpenFgaExposeConfig) files.ChartValues { + gateway := files.ChartValues{"enabled": expose.Enabled} + if expose.Host != "" { + gateway["host"] = expose.Host + } + + // The cert-manager ClusterIssuer the cluster step creates is named after the + // configured issuer type, so the gateway certificate follows the same issuer as + // the Codesphere frontend gateway. + gateway["tls"] = files.ChartValues{ + "certificate": files.ChartValues{ + "issuerRef": files.ChartValues{"name": certIssuerName(config)}, + }, + } + + return gateway +} + +// hasOpenFgaPresharedKey reports whether the vault holds a usable preshared key. A vault +// written by an older oms has no entry at all; `oms update install-config` adds one. +func hasOpenFgaPresharedKey(vault *files.InstallVault) bool { + if vault == nil { + return false + } + + secret := vault.GetSecret(files.SecretOpenFgaPresharedKey) + + return secret != nil && secret.Fields != nil && secret.Fields.Password != "" +} + +// certIssuerName returns the name of the ClusterIssuer for this installation, matching the +// naming the cluster step uses (the issuer type is the issuer name). +func certIssuerName(config *files.RootConfig) string { + if issuer := config.Codesphere.CertIssuer; issuer != nil && issuer.Type != "" { + return string(issuer.Type) + } + + return string(files.CertIssuerTypeSelfSigned) +} diff --git a/internal/installer/openfga_pc_apps_test.go b/internal/installer/openfga_pc_apps_test.go new file mode 100644 index 00000000..bc502a26 --- /dev/null +++ b/internal/installer/openfga_pc_apps_test.go @@ -0,0 +1,154 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/installer/files" +) + +var _ = Describe("OpenFgaPcAppsValues", func() { + configWith := func(fga *files.OpenFgaConfig, issuer files.CertIssuerType) *files.RootConfig { + config := &files.RootConfig{} + config.Codesphere.OpenFga = fga + config.Codesphere.CertIssuer = &files.CertIssuerConfig{Type: issuer} + + return config + } + + // A vault of an installation that has no preshared key, i.e. everything written + // before oms started generating one. + emptyVault := func() *files.InstallVault { + return &files.InstallVault{} + } + + vaultWithPresharedKey := func() *files.InstallVault { + return &files.InstallVault{Secrets: []files.SecretEntry{{ + Name: files.SecretOpenFgaPresharedKey, + Fields: &files.SecretFields{Password: "0123456789abcdef"}, + }}} + } + + // The application entry of the rendered values, or nil if there is none. + openfgaValues := func(values files.ChartValues) files.ChartValues { + apps, ok := values["applications"].(files.ChartValues) + Expect(ok).To(BeTrue(), "expected an applications map") + + return apps["openfga"].(files.ChartValues) + } + + // The authn block the openfga chart is configured with, or nil if there is none. + authnValues := func(values files.ChartValues) files.ChartValues { + chartValues, ok := openfgaValues(values)["valuesObject"].(files.ChartValues) + if !ok { + return nil + } + + openfga, ok := chartValues["openfga"].(files.ChartValues) + if !ok { + return nil + } + + return openfga["authn"].(files.ChartValues) + } + + It("leaves the chart defaults alone when the config says nothing", func() { + Expect(installer.OpenFgaPcAppsValues(configWith(nil, ""), emptyVault())).To(BeNil()) + }) + + It("disables the application for a data center that uses a remote OpenFGA", func() { + deploy := false + values := installer.OpenFgaPcAppsValues(configWith(&files.OpenFgaConfig{ + Deploy: &deploy, + APIURL: "https://openfga.1.cs.example.com", + }, files.CertIssuerTypeACME), emptyVault()) + + fga := openfgaValues(values) + Expect(fga["enabled"]).To(BeFalse()) + // Nothing to expose, so the gateway is not configured at all. + Expect(fga).NotTo(HaveKey("valuesObject")) + }) + + It("defaults to deploying when only the exposure is configured", func() { + values := installer.OpenFgaPcAppsValues(configWith(&files.OpenFgaConfig{ + Expose: &files.OpenFgaExposeConfig{Enabled: true, Host: "openfga.1.cs.example.com"}, + }, files.CertIssuerTypeACME), emptyVault()) + + fga := openfgaValues(values) + Expect(fga["enabled"]).To(BeTrue()) + + gateway := fga["valuesObject"].(files.ChartValues)["gateway"].(files.ChartValues) + Expect(gateway["enabled"]).To(BeTrue()) + Expect(gateway["host"]).To(Equal("openfga.1.cs.example.com")) + }) + + It("issues the gateway certificate with the installation's cert issuer", func() { + values := installer.OpenFgaPcAppsValues(configWith(&files.OpenFgaConfig{ + Expose: &files.OpenFgaExposeConfig{Enabled: true, Host: "openfga.1.cs.example.com"}, + }, files.CertIssuerTypeACME), emptyVault()) + + gateway := openfgaValues(values)["valuesObject"].(files.ChartValues)["gateway"].(files.ChartValues) + issuerRef := gateway["tls"].(files.ChartValues)["certificate"].(files.ChartValues)["issuerRef"].(files.ChartValues) + Expect(issuerRef["name"]).To(Equal("acme")) + }) + + It("falls back to the self-signed issuer when no cert issuer is configured", func() { + values := installer.OpenFgaPcAppsValues(configWith(&files.OpenFgaConfig{ + Expose: &files.OpenFgaExposeConfig{Enabled: true, Host: "openfga.1.cs.example.com"}, + }, ""), emptyVault()) + + gateway := openfgaValues(values)["valuesObject"].(files.ChartValues)["gateway"].(files.ChartValues) + issuerRef := gateway["tls"].(files.ChartValues)["certificate"].(files.ChartValues)["issuerRef"].(files.ChartValues) + Expect(issuerRef["name"]).To(Equal("self-signed")) + }) + + It("falls back to the self-signed issuer when the cert issuer block is absent", func() { + config := configWith(&files.OpenFgaConfig{ + Expose: &files.OpenFgaExposeConfig{Enabled: true, Host: "openfga.1.cs.example.com"}, + }, "") + config.Codesphere.CertIssuer = nil + + values := installer.OpenFgaPcAppsValues(config, emptyVault()) + + gateway := openfgaValues(values)["valuesObject"].(files.ChartValues)["gateway"].(files.ChartValues) + issuerRef := gateway["tls"].(files.ChartValues)["certificate"].(files.ChartValues)["issuerRef"].(files.ChartValues) + Expect(issuerRef["name"]).To(Equal("self-signed")) + }) + + It("leaves OpenFGA unauthenticated while the vault has no preshared key", func() { + values := installer.OpenFgaPcAppsValues(configWith(&files.OpenFgaConfig{}, ""), emptyVault()) + + Expect(authnValues(values)).To(BeNil()) + }) + + It("requires the preshared key once the vault holds one", func() { + values := installer.OpenFgaPcAppsValues(configWith(nil, ""), vaultWithPresharedKey()) + + authn := authnValues(values) + Expect(authn["method"]).To(Equal("preshared")) + Expect(authn["preshared"].(files.ChartValues)["keysSecret"]).To(Equal("openfga-preshared-keys")) + }) + + It("keeps the gateway configuration when authentication is derived as well", func() { + values := installer.OpenFgaPcAppsValues(configWith(&files.OpenFgaConfig{ + Expose: &files.OpenFgaExposeConfig{Enabled: true, Host: "openfga.1.cs.example.com"}, + }, files.CertIssuerTypeACME), vaultWithPresharedKey()) + + Expect(authnValues(values)["method"]).To(Equal("preshared")) + gateway := openfgaValues(values)["valuesObject"].(files.ChartValues)["gateway"].(files.ChartValues) + Expect(gateway["host"]).To(Equal("openfga.1.cs.example.com")) + }) + + It("ignores an entry that is present but empty", func() { + vault := &files.InstallVault{Secrets: []files.SecretEntry{{ + Name: files.SecretOpenFgaPresharedKey, + Fields: &files.SecretFields{Password: ""}, + }}} + + Expect(installer.OpenFgaPcAppsValues(configWith(nil, ""), vault)).To(BeNil()) + }) +})