diff --git a/backend/internal/appconfig/model.go b/backend/internal/appconfig/model.go index e6b7267b..0a7bdc66 100644 --- a/backend/internal/appconfig/model.go +++ b/backend/internal/appconfig/model.go @@ -81,6 +81,8 @@ type AppConfigModel struct { // OIDC CIMDURLAllowlist AppConfigValue `json:"cimdUrlAllowlist" env:"CIMD_URL_ALLOWLIST"` // JSON-encoded array of strings AutoCreateOIDCClientSecret AppConfigValue `json:"autoCreateOidcClientSecret" env:"AUTO_CREATE_OIDC_CLIENT_SECRET" type:"bool"` + // Features + EnabledFeatures AppConfigValue `json:"enabledFeatures" env:"ENABLED_FEATURES"` // JSON-encoded array of feature ids } // appConfigEnvName returns the explicit environment variable name for a JSON configuration field @@ -187,6 +189,9 @@ func getDefaultConfig() *AppConfigModel { // OIDC CIMDURLAllowlist: "[]", AutoCreateOIDCClientSecret: "true", + // Features + // Left unset so the feature module can tell a saved value from one it still has to derive from usage + EnabledFeatures: "", } } diff --git a/backend/internal/bootstrap/route_coverage_test.go b/backend/internal/bootstrap/route_coverage_test.go index 57e58b6c..3f4cba81 100644 --- a/backend/internal/bootstrap/route_coverage_test.go +++ b/backend/internal/bootstrap/route_coverage_test.go @@ -13,6 +13,7 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/devicelogin" "github.com/pocket-id/pocket-id/backend/internal/emailverification" "github.com/pocket-id/pocket-id/backend/internal/environment" + "github.com/pocket-id/pocket-id/backend/internal/feature" "github.com/pocket-id/pocket-id/backend/internal/ldapsync" "github.com/pocket-id/pocket-id/backend/internal/logopreset" "github.com/pocket-id/pocket-id/backend/internal/middleware" @@ -42,6 +43,7 @@ func TestEveryAPIRouteDeclaresItsAccess(t *testing.T) { apiModule: &api.Module{}, environmentModule: &environment.Module{}, logoPresetModule: &logopreset.Module{}, + featureModule: &feature.Module{}, } engine := gin.New() diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index 6b3d326a..788dfed0 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -181,6 +181,7 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services, auth *authz.Middl controller.NewCustomClaimController(apiRouter, svc.customClaimService) svc.environmentModule.RegisterRoutes(apiRouter) svc.logoPresetModule.RegisterRoutes(apiRouter) + svc.featureModule.RegisterRoutes(apiRouter) svc.scimSyncModule.RegisterRoutes(apiRouter) svc.userSignUpModule.RegisterRoutes(apiRouter, rateLimitMiddleware.Add(middleware.RateLimitSignup)) svc.oneTimeAccessModule.RegisterRoutes(apiRouter, diff --git a/backend/internal/bootstrap/services_bootstrap.go b/backend/internal/bootstrap/services_bootstrap.go index 0a09dd12..7a646caa 100644 --- a/backend/internal/bootstrap/services_bootstrap.go +++ b/backend/internal/bootstrap/services_bootstrap.go @@ -18,6 +18,7 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/email" "github.com/pocket-id/pocket-id/backend/internal/emailverification" "github.com/pocket-id/pocket-id/backend/internal/environment" + "github.com/pocket-id/pocket-id/backend/internal/feature" "github.com/pocket-id/pocket-id/backend/internal/geolite" "github.com/pocket-id/pocket-id/backend/internal/iplocation" "github.com/pocket-id/pocket-id/backend/internal/ldapsync" @@ -58,6 +59,7 @@ type services struct { apiModule *api.Module environmentModule *environment.Module logoPresetModule *logopreset.Module + featureModule *feature.Module actors francishost.Host } @@ -283,6 +285,8 @@ func initServices( BaseURL: iconLibraryURL, }) + svc.featureModule = feature.New(feature.Dependencies{DB: db, AppConfig: svc.appConfigService}) + return svc, nil } diff --git a/backend/internal/dto/app_config_dto.go b/backend/internal/dto/app_config_dto.go index 54e24c81..2c4f73ab 100644 --- a/backend/internal/dto/app_config_dto.go +++ b/backend/internal/dto/app_config_dto.go @@ -62,6 +62,7 @@ type AppConfigUpdateDto struct { EmailVerification string `json:"emailVerification" binding:"required,oneof=disabled optional required,email_verification"` CIMDURLAllowlist string `json:"cimdUrlAllowlist" binding:"omitempty,cimd_url_allowlist"` AutoCreateOIDCClientSecret string `json:"autoCreateOidcClientSecret" binding:"required,boolean_string"` + EnabledFeatures string `json:"enabledFeatures" binding:"omitempty,json_string_array"` } func (a AppConfigUpdateDto) Validate() error { diff --git a/backend/internal/feature/module.go b/backend/internal/feature/module.go new file mode 100644 index 00000000..458896e5 --- /dev/null +++ b/backend/internal/feature/module.go @@ -0,0 +1,49 @@ +package feature + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "github.com/pocket-id/pocket-id/backend/internal/authz" + "github.com/pocket-id/pocket-id/backend/internal/httpserver" +) + +type Dependencies struct { + DB *gorm.DB + AppConfig appConfigStore +} + +// Module reports which optional features are on, so the admin UI shows only their pages and tabs +// Admins turn features on and off through the enabledFeatures application configuration value +type Module struct { + service *Service +} + +func New(deps Dependencies) *Module { + return &Module{service: &Service{db: deps.DB, appConfig: deps.AppConfig}} +} + +// RegisterRoutes mounts the feature endpoints +func (m *Module) RegisterRoutes(r *authz.Router) { + r.GET("/features", authz.ConfigRead, httpserver.Handle(m.list)) +} + +// list godoc +// @Summary List optional features +// @Description Get whether each optional feature is on and whether anything uses it; a feature in use is always on +// @Tags Features +// @Produce json +// @Success 200 {object} map[string]feature.Status +// @Failure default {object} dto.ErrorDto "Error" +// @Router /api/features [get] +func (m *Module) list(c *gin.Context) error { + statuses, err := m.service.List(c.Request.Context()) + if err != nil { + return err + } + + c.JSON(http.StatusOK, statuses) + return nil +} diff --git a/backend/internal/feature/service.go b/backend/internal/feature/service.go new file mode 100644 index 00000000..d8db1746 --- /dev/null +++ b/backend/internal/feature/service.go @@ -0,0 +1,151 @@ +package feature + +import ( + "context" + "encoding/json" + "fmt" + + "gorm.io/gorm" + + "github.com/pocket-id/pocket-id/backend/internal/api" + "github.com/pocket-id/pocket-id/backend/internal/apikey" + "github.com/pocket-id/pocket-id/backend/internal/appconfig" + "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/model" + "github.com/pocket-id/pocket-id/backend/internal/scimsync" +) + +// Feature is an optional part of Pocket ID whose pages and tabs the admin UI only shows once it is on +type Feature string + +const ( + LDAP Feature = "ldap" + SCIM Feature = "scim" + CustomClaims Feature = "customClaims" + APIs Feature = "apis" + APIKeys Feature = "apiKeys" + CIMD Feature = "cimd" + FederatedCredentials Feature = "federatedCredentials" +) + +var features = []Feature{LDAP, SCIM, CustomClaims, APIs, APIKeys, CIMD, FederatedCredentials} + +type Status struct { + Enabled bool `json:"enabled"` + InUse bool `json:"inUse"` +} + +type appConfigStore interface { + GetConfig(ctx context.Context) (*appconfig.AppConfigModel, error) + UpdateAppConfigValues(ctx context.Context, keysAndValues ...string) error +} + +type Service struct { + db *gorm.DB + appConfig appConfigStore +} + +// List reports every feature as enabled when an admin turned it on or when something uses it +// Something created through the API while its feature was off stays visible, and a feature in use cannot be turned off +func (s *Service) List(ctx context.Context) (map[Feature]Status, error) { + cfg, err := s.appConfig.GetConfig(ctx) + if err != nil { + return nil, err + } + usage, err := s.usage(ctx, cfg) + if err != nil { + return nil, err + } + + var enabled []Feature + if cfg.EnabledFeatures != "" { + if err := json.Unmarshal([]byte(cfg.EnabledFeatures), &enabled); err != nil { + return nil, fmt.Errorf("failed to parse the enabled features: %w", err) + } + } else if !common.EnvConfig.UiConfigDisabled { + // Instances from before features turn on what they use once, so those features stay on after their last object is deleted + // This runs on the first read instead of at startup because the configuration actor is bootstrapped after the host is ready + enabled = []Feature{} + for _, feature := range features { + if usage[feature] > 0 { + enabled = append(enabled, feature) + } + } + value, err := json.Marshal(enabled) + if err != nil { + return nil, err + } + if err := s.appConfig.UpdateAppConfigValues(ctx, "enabledFeatures", string(value)); err != nil { + return nil, fmt.Errorf("failed to save the enabled features: %w", err) + } + } + + statuses := map[Feature]Status{} + for _, feature := range features { + statuses[feature] = Status{Enabled: usage[feature] > 0, InUse: usage[feature] > 0} + } + for _, feature := range enabled { + if status, ok := statuses[feature]; ok { + status.Enabled = true + statuses[feature] = status + } + } + + return statuses, nil +} + +func (s *Service) usage(ctx context.Context, cfg *appconfig.AppConfigModel) (map[Feature]int64, error) { + usage := map[Feature]int64{} + if cfg.LdapEnabled.IsTrue() { + usage[LDAP] = 1 + } + + var signupClaims []json.RawMessage + if err := json.Unmarshal([]byte(cfg.SignupDefaultCustomClaims), &signupClaims); err != nil { + return nil, fmt.Errorf("failed to parse the sign-up default custom claims: %w", err) + } + usage[CustomClaims] = int64(len(signupClaims)) + + var cimdURLs []string + if err := json.Unmarshal([]byte(cfg.CIMDURLAllowlist), &cimdURLs); err != nil { + return nil, fmt.Errorf("failed to parse the CIMD URL allowlist: %w", err) + } + usage[CIMD] = int64(len(cimdURLs)) + + type query struct { + feature Feature + db *gorm.DB + } + db := s.db.WithContext(ctx) + queries := []query{ + {SCIM, db.Model(&scimsync.ServiceProvider{})}, + {CustomClaims, db.Model(&model.CustomClaim{})}, + {APIs, db.Model(&api.API{})}, + {APIKeys, db.Model(&apikey.ApiKey{})}, + // An API that admits metadata document clients keeps its CIMD access tab even while no such client exists + {CIMD, db.Model(&model.OidcClient{}).Where("client_type = ?", model.OidcClientTypeCIMD)}, + {CIMD, db.Model(&api.API{}).Where("allow_cimd_clients = ?", true)}, + } + for _, q := range queries { + var count int64 + if err := q.db.Count(&count).Error; err != nil { + return nil, fmt.Errorf("failed to count the usage of %s: %w", q.feature, err) + } + usage[q.feature] += count + } + + // Federated identities live in each client's credentials document, which differs between SQLite and Postgres, so they are counted here + // A metadata document client's identities come from its document, so only the ones an admin set count + var clients []model.OidcClient + err := db.Select("credentials").Where("client_type <> ?", model.OidcClientTypeCIMD).Find(&clients).Error + if err != nil { + return nil, fmt.Errorf("failed to load the client credentials: %w", err) + } + for _, c := range clients { + if len(c.Credentials.FederatedIdentities) > 0 { + usage[FederatedCredentials]++ + } + } + + return usage, nil +} diff --git a/backend/internal/feature/service_test.go b/backend/internal/feature/service_test.go new file mode 100644 index 00000000..528b37d3 --- /dev/null +++ b/backend/internal/feature/service_test.go @@ -0,0 +1,91 @@ +package feature + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + + "github.com/pocket-id/pocket-id/backend/internal/api" + "github.com/pocket-id/pocket-id/backend/internal/appconfig" + "github.com/pocket-id/pocket-id/backend/internal/model" + testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" +) + +type fakeAppConfig struct { + cfg *appconfig.AppConfigModel +} + +func (f *fakeAppConfig) GetConfig(context.Context) (*appconfig.AppConfigModel, error) { + return f.cfg, nil +} + +func (f *fakeAppConfig) UpdateAppConfigValues(_ context.Context, keysAndValues ...string) error { + f.cfg.EnabledFeatures = appconfig.AppConfigValue(keysAndValues[1]) + return nil +} + +func TestListCombinesEnabledFeaturesWithUsage(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + cfg := appconfig.NewTestConfig(nil) + cfg.EnabledFeatures = `["apiKeys","unknown"]` + cfg.SignupDefaultCustomClaims = `[{"key":"team","value":"photo"}]` + cfg.LdapEnabled = "true" + svc := New(Dependencies{DB: db, AppConfig: &fakeAppConfig{cfg: cfg}}).service + + require.NoError(t, db.Create(&api.API{Name: "Orders", Audience: "https://orders.example.com"}).Error) + userID := "user-1" + require.NoError(t, db.Create(&model.CustomClaim{Key: "level", Value: "1", UserID: &userID}).Error) + + require.NoError(t, db.Create(&model.OidcClient{ + Base: model.Base{ID: "client-1"}, + Name: "Grafana", + Credentials: model.OidcClientCredentials{FederatedIdentities: []model.OidcClientFederatedIdentity{{Issuer: "https://ci.example.com"}}}, + }).Error) + + statuses, err := svc.List(t.Context()) + require.NoError(t, err) + assert.Equal(t, map[Feature]Status{ + LDAP: {Enabled: true, InUse: true}, + SCIM: {Enabled: false, InUse: false}, + CustomClaims: {Enabled: true, InUse: true}, + APIs: {Enabled: true, InUse: true}, + APIKeys: {Enabled: true, InUse: false}, + CIMD: {Enabled: false, InUse: false}, + FederatedCredentials: {Enabled: true, InUse: true}, + }, statuses) + + // A metadata document client alone puts CIMD in use, and its own federated identities do not count + require.NoError(t, db.Create(&model.OidcClient{ + Base: model.Base{ID: "https://app.example.com/oauth-client.json"}, + Name: "Example app", + ClientType: model.OidcClientTypeCIMD, + Credentials: model.OidcClientCredentials{FederatedIdentities: []model.OidcClientFederatedIdentity{{Issuer: "https://app.example.com"}}}, + }).Error) + // Clients created before the credentials column existed still have NULL there + require.NoError(t, db.Model(&model.OidcClient{}).Where("id = ?", "client-1").Update("credentials", gorm.Expr("NULL")).Error) + statuses, err = svc.List(t.Context()) + require.NoError(t, err) + assert.Equal(t, Status{Enabled: true, InUse: true}, statuses[CIMD]) + assert.Equal(t, Status{Enabled: false, InUse: false}, statuses[FederatedCredentials]) +} + +func TestListSavesFeaturesInUseOnce(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + cfg := appconfig.NewTestConfig(nil) + svc := New(Dependencies{DB: db, AppConfig: &fakeAppConfig{cfg: cfg}}).service + + orders := api.API{Name: "Orders", Audience: "https://orders.example.com"} + require.NoError(t, db.Create(&orders).Error) + _, err := svc.List(t.Context()) + require.NoError(t, err) + assert.JSONEq(t, `["apis"]`, cfg.EnabledFeatures.String()) + + // Deleting the last API leaves the feature on + require.NoError(t, db.Delete(&orders).Error) + statuses, err := svc.List(t.Context()) + require.NoError(t, err) + assert.Equal(t, Status{Enabled: true, InUse: false}, statuses[APIs]) +}