mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-29 12:09:05 +01:00
feat(backend): add optional features with an enabledFeatures setting
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
Reference in New Issue
Block a user