Path: blob/main/components/gitpod-db/go/oidc_client_config.go
2497 views
// Copyright (c) 2022 Gitpod GmbH. All rights reserved.1// Licensed under the GNU Affero General Public License (AGPL).2// See License.AGPL.txt in the project root for license information.34package db56import (7"context"8"errors"9"fmt"10"sort"11"time"1213"github.com/google/uuid"14"gorm.io/gorm"15)1617type OIDCClientConfig struct {18ID uuid.UUID `gorm:"primary_key;column:id;type:char;size:36;" json:"id"`1920OrganizationID uuid.UUID `gorm:"column:organizationId;type:char;size:36;" json:"organizationId"`2122Issuer string `gorm:"column:issuer;type:char;size:255;" json:"issuer"`2324Data EncryptedJSON[OIDCSpec] `gorm:"column:data;type:text;size:65535" json:"data"`2526Active bool `gorm:"column:active;type:tinyint;default:0;" json:"active"`2728Verified *bool `gorm:"column:verified;type:tinyint;default:0;" json:"verified"`2930LastModified time.Time `gorm:"column:_lastModified;type:timestamp;default:CURRENT_TIMESTAMP(6);" json:"_lastModified"`31// deleted is reserved for use by periodic deleter.32_ bool `gorm:"column:deleted;type:tinyint;default:0;" json:"deleted"`33}3435func BoolPointer(b bool) *bool {36return &b37}3839func (c *OIDCClientConfig) TableName() string {40return "d_b_oidc_client_config"41}4243// It feels wrong to have to define re-define all of these fields.44// However, I could not find a Go library which would include json annotations on the structs to guarantee the fields45// will remain consistent over time (and resilient to rename). If we find one, we can change this.46type OIDCSpec struct {47// ClientID is the application's ID.48ClientID string `json:"clientId"`4950// ClientSecret is the application's secret.51ClientSecret string `json:"clientSecret"`5253// RedirectURL is the URL to redirect users going through54// the OAuth flow, after the resource owner's URLs.55RedirectURL string `json:"redirectUrl"`5657// Scope specifies optional requested permissions.58Scopes []string `json:"scopes"`5960// CelExpression is an optional expression that can be used to determine if the client should be allowed to authenticate.61CelExpression string `json:"celExpression"`6263// UsePKCE specifies if the client should use PKCE for the OAuth flow.64UsePKCE bool `json:"usePKCE"`65}6667func CreateOIDCClientConfig(ctx context.Context, conn *gorm.DB, cfg OIDCClientConfig) (OIDCClientConfig, error) {68if cfg.ID == uuid.Nil {69return OIDCClientConfig{}, errors.New("ID must be set")70}7172if cfg.OrganizationID == uuid.Nil {73return OIDCClientConfig{}, errors.New("organization ID must be set")74}7576if cfg.Issuer == "" {77return OIDCClientConfig{}, errors.New("issuer must be set")78}7980tx := conn.81WithContext(ctx).82Create(&cfg)83if tx.Error != nil {84return OIDCClientConfig{}, fmt.Errorf("failed to create oidc client config: %w", tx.Error)85}8687return cfg, nil88}8990func GetOIDCClientConfig(ctx context.Context, conn *gorm.DB, id uuid.UUID) (OIDCClientConfig, error) {91var config OIDCClientConfig9293if id == uuid.Nil {94return OIDCClientConfig{}, fmt.Errorf("OIDC Client Config ID is a required argument")95}9697tx := conn.98WithContext(ctx).99Where("id = ?", id).100Where("deleted = ?", 0).101First(&config)102if tx.Error != nil {103if errors.Is(tx.Error, gorm.ErrRecordNotFound) {104return OIDCClientConfig{}, fmt.Errorf("OIDC Client Config with ID %s does not exist: %w", id.String(), ErrorNotFound)105}106return OIDCClientConfig{}, fmt.Errorf("Failed to retrieve OIDC client config: %v", tx.Error)107}108109return config, nil110}111112func GetOIDCClientConfigForOrganization(ctx context.Context, conn *gorm.DB, id, organizationID uuid.UUID) (OIDCClientConfig, error) {113var config OIDCClientConfig114115if id == uuid.Nil {116return OIDCClientConfig{}, fmt.Errorf("OIDC Client Config ID is a required argument")117}118119if organizationID == uuid.Nil {120return OIDCClientConfig{}, fmt.Errorf("organization id is a required argument")121}122123tx := conn.124WithContext(ctx).125Where("id = ?", id).126Where("organizationId = ?", organizationID).127Where("deleted = ?", 0).128First(&config)129if tx.Error != nil {130if errors.Is(tx.Error, gorm.ErrRecordNotFound) {131return OIDCClientConfig{}, fmt.Errorf("OIDC Client Config with ID %s for Organization ID %s does not exist: %w", id.String(), organizationID.String(), ErrorNotFound)132}133134return OIDCClientConfig{}, fmt.Errorf("Failed to retrieve OIDC client config %s for Organization ID %s: %v", id.String(), organizationID.String(), tx.Error)135}136137return config, nil138}139140func ListOIDCClientConfigsForOrganization(ctx context.Context, conn *gorm.DB, organizationID uuid.UUID) ([]OIDCClientConfig, error) {141if organizationID == uuid.Nil {142return nil, errors.New("organization ID is a required argument")143}144145var results []OIDCClientConfig146147tx := conn.148WithContext(ctx).149Where("organizationId = ?", organizationID.String()).150Where("deleted = ?", 0).151Order("id").152Find(&results)153if tx.Error != nil {154return nil, fmt.Errorf("failed to list oidc client configs for organization %s: %w", organizationID.String(), tx.Error)155}156157return results, nil158}159160func DeleteOIDCClientConfig(ctx context.Context, conn *gorm.DB, id, organizationID uuid.UUID) error {161if id == uuid.Nil {162return fmt.Errorf("id is a required argument")163}164165if organizationID == uuid.Nil {166return fmt.Errorf("organization id is a required argument")167}168169tx := conn.170WithContext(ctx).171Table((&OIDCClientConfig{}).TableName()).172Where("id = ?", id).173Where("organizationId = ?", organizationID).174Where("deleted = ?", 0).175Update("deleted", 1)176177if tx.Error != nil {178return fmt.Errorf("failed to delete oidc client config (ID: %s): %v", id.String(), tx.Error)179}180181if tx.RowsAffected == 0 {182return fmt.Errorf("oidc client config ID: %s for organization ID: %s does not exist: %w", id.String(), organizationID.String(), ErrorNotFound)183}184185return nil186}187188func GetActiveOIDCClientConfigByOrgSlug(ctx context.Context, conn *gorm.DB, slug string) (OIDCClientConfig, error) {189var config OIDCClientConfig190191if slug == "" {192return OIDCClientConfig{}, fmt.Errorf("slug is a required argument")193}194195tx := conn.196WithContext(ctx).197Table(fmt.Sprintf("%s as config", (&OIDCClientConfig{}).TableName())).198Joins(fmt.Sprintf("JOIN %s AS team ON team.id = config.organizationId", (&Organization{}).TableName())).199Where("team.slug = ?", slug).200Where("config.deleted = ?", 0).201Where("config.active = ?", 1).202First(&config)203204if tx.Error != nil {205if errors.Is(tx.Error, gorm.ErrRecordNotFound) {206return OIDCClientConfig{}, fmt.Errorf("OIDC Client Config for Organization (slug: %s) does not exist: %w", slug, ErrorNotFound)207}208return OIDCClientConfig{}, fmt.Errorf("Failed to retrieve OIDC client config: %v", tx.Error)209}210211return config, nil212}213214// UpdateOIDCClientConfig performs an update of the OIDC Client config.215// Only non-zero fields specified in the struct are updated.216// When updating the encrypted contents of the specUpdate, you can specify them in the update to have re-encrypted in a transaction.217func UpdateOIDCClientConfig(ctx context.Context, conn *gorm.DB, cipher Cipher, update OIDCClientConfig, specUpdate *OIDCSpec) error {218if update.ID == uuid.Nil {219return errors.New("id is a required field")220}221222txErr := conn.223WithContext(ctx).224Transaction(func(tx *gorm.DB) error {225if specUpdate != nil {226// we also need to update the contents of the encrypted spec.227existing, err := GetOIDCClientConfig(ctx, conn, update.ID)228if err != nil {229return err230}231232decrypted, err := existing.Data.Decrypt(cipher)233if err != nil {234return fmt.Errorf("failed to decrypt oidc spec: %w", err)235}236237updatedSpec := partialUpdateOIDCSpec(decrypted, *specUpdate)238239encrypted, err := EncryptJSON(cipher, updatedSpec)240if err != nil {241return fmt.Errorf("failed to encrypt oidc spec: %w", err)242}243244// Set the serialized contents on our desired update object245update.Data = encrypted246247// Each update should unverify the entry248update.Verified = BoolPointer(false)249}250251updateTx := tx.252Model(&OIDCClientConfig{}).253Where("id = ?", update.ID.String()).254Where("deleted = ?", 0).255Updates(update)256if updateTx.Error != nil {257return fmt.Errorf("failed to update OIDC client: %w", updateTx.Error)258}259260if updateTx.RowsAffected == 0 {261// FIXME(at) this should not return an error in case of empty update262return fmt.Errorf("OIDC client config ID: %s does not exist: %w", update.ID.String(), ErrorNotFound)263}264265// return nil will commit the whole transaction266return nil267})268269if txErr != nil {270return fmt.Errorf("failed to update oidc spec ID: %s: %w", update.ID.String(), txErr)271}272273return nil274}275276func SetClientConfigActiviation(ctx context.Context, conn *gorm.DB, id uuid.UUID, active bool) error {277config, err := GetOIDCClientConfig(ctx, conn, id)278if err != nil {279return err280}281282value := 0283if active {284value = 1285}286287tx := conn.288WithContext(ctx).289Table((&OIDCClientConfig{}).TableName()).290Where("id = ?", id.String()).291Update("active", value)292if tx.Error != nil {293return fmt.Errorf("failed to set oidc client config as active to %d (id: %s): %v", value, id.String(), tx.Error)294}295296if active {297tx := conn.298WithContext(ctx).299Table((&OIDCClientConfig{}).TableName()).300Where("id != ?", id.String()).301Where("organizationId = ?", config.OrganizationID).302Where("deleted = ?", 0).303Update("active", 0)304if tx.Error != nil {305return fmt.Errorf("failed to set other oidc client configs as inactive: %v", tx.Error)306}307}308309return nil310}311312func VerifyClientConfig(ctx context.Context, conn *gorm.DB, id uuid.UUID) error {313return setClientConfigVerifiedFlag(ctx, conn, id, true)314}315316func UnverifyClientConfig(ctx context.Context, conn *gorm.DB, id uuid.UUID) error {317return setClientConfigVerifiedFlag(ctx, conn, id, false)318}319320func setClientConfigVerifiedFlag(ctx context.Context, conn *gorm.DB, id uuid.UUID, verified bool) error {321_, err := GetOIDCClientConfig(ctx, conn, id)322if err != nil {323return err324}325326value := 0327if verified {328value = 1329}330331tx := conn.332WithContext(ctx).333Table((&OIDCClientConfig{}).TableName()).334Where("id = ?", id.String()).335Update("verified", value)336if tx.Error != nil {337return fmt.Errorf("failed to set oidc client config as active to %d (id: %s): %v", value, id.String(), tx.Error)338}339340return nil341}342343func partialUpdateOIDCSpec(old, new OIDCSpec) OIDCSpec {344if new.ClientID != "" {345old.ClientID = new.ClientID346}347348if new.ClientSecret != "" {349old.ClientSecret = new.ClientSecret350}351352if new.RedirectURL != "" {353old.RedirectURL = new.RedirectURL354}355356old.CelExpression = new.CelExpression357old.UsePKCE = new.UsePKCE358359if !oidcScopesEqual(old.Scopes, new.Scopes) {360old.Scopes = new.Scopes361}362363return old364}365366func oidcScopesEqual(old, new []string) bool {367if len(old) != len(new) {368return false369}370371sort.Strings(old)372sort.Strings(new)373374for i := 0; i < len(old); i++ {375if old[i] != new[i] {376return false377}378}379380return true381}382383384