feat(backend): add optional features with an enabledFeatures setting

This commit is contained in:
Elias Schneider
2026-10-27 23:32:53 +01:00
parent c0fd85740c
commit 52aab275ba
8 changed files with 304 additions and 0 deletions
+5
View File
@@ -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: "",
}
}
@@ -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()
@@ -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,
@@ -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
}
+1
View File
@@ -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 {
+49
View File
@@ -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
}
+151
View File
@@ -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
}
+91
View File
@@ -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])
}