mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-29 20:19:04 +01:00
152 lines
4.9 KiB
Go
152 lines
4.9 KiB
Go
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
|
|
}
|