diff --git a/apps/login/src/lib/zitadel.ts b/apps/login/src/lib/zitadel.ts index 753de1b19..071dc9e97 100644 --- a/apps/login/src/lib/zitadel.ts +++ b/apps/login/src/lib/zitadel.ts @@ -587,6 +587,8 @@ export type ListUsersCommand = WithServiceConfig<{ organizationId?: string; }>; +const userLookupQuery = { limit: 2 }; + export async function listUsers({ serviceConfig, loginName, userName, phone, email, organizationId }: ListUsersCommand) { const queries: SearchQuery[] = []; @@ -672,7 +674,7 @@ export async function listUsers({ serviceConfig, loginName, userName, phone, ema const userService: Client = await createServiceForHost(UserService, serviceConfig); - return userService.listUsers({ queries }); + return userService.listUsers({ query: userLookupQuery, queries }); } export type SearchUsersCommand = WithServiceConfig<{ @@ -755,7 +757,7 @@ export async function searchUsers({ const userService: Client = await createServiceForHost(UserService, serviceConfig); - const loginNameResult = await userService.listUsers({ queries }); + const loginNameResult = await userService.listUsers({ query: userLookupQuery, queries }); if (!loginNameResult || !loginNameResult.details) { return { error: t("errors.errorOccured") }; @@ -817,6 +819,7 @@ export async function searchUsers({ } const emailOrPhoneResult = await userService.listUsers({ + query: userLookupQuery, queries: emailAndPhoneQueries, }); diff --git a/cmd/setup/72.go b/cmd/setup/72.go new file mode 100644 index 000000000..5d4d537c3 --- /dev/null +++ b/cmd/setup/72.go @@ -0,0 +1,27 @@ +package setup + +import ( + "context" + _ "embed" + + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/eventstore" +) + +var ( + //go:embed 72.sql + addColumnsToLoginNamesView string +) + +type AddColumnsToLoginNamesView struct { + dbClient *database.DB +} + +func (mig *AddColumnsToLoginNamesView) Execute(ctx context.Context, _ eventstore.Event) error { + _, err := mig.dbClient.ExecContext(ctx, addColumnsToLoginNamesView) + return err +} + +func (mig *AddColumnsToLoginNamesView) String() string { + return "72_add_columns_to_login_names_view" +} diff --git a/cmd/setup/72.sql b/cmd/setup/72.sql new file mode 100644 index 000000000..59f0e3877 --- /dev/null +++ b/cmd/setup/72.sql @@ -0,0 +1,42 @@ +CREATE OR REPLACE VIEW projections.login_names3 AS ( + SELECT + u.id AS user_id + , CASE + WHEN p.must_be_domain THEN CONCAT(u.user_name, '@', d.name) + ELSE u.user_name + END AS login_name + , COALESCE(d.is_primary, TRUE) AS is_primary + , u.instance_id + , u.resource_owner + , CASE + WHEN p.must_be_domain THEN CONCAT(u.user_name_lower, '@', d.name_lower) + ELSE u.user_name_lower + END AS login_name_lower + FROM + projections.login_names3_users AS u + LEFT JOIN LATERAL ( + SELECT + must_be_domain + , is_default + FROM + projections.login_names3_policies AS p + WHERE + ( + p.instance_id = u.instance_id + AND NOT p.is_default + AND p.resource_owner = u.resource_owner + ) OR ( + p.instance_id = u.instance_id + AND p.is_default + ) + ORDER BY + p.is_default -- custom first + LIMIT 1 + ) AS p ON TRUE + LEFT JOIN + projections.login_names3_domains d + ON + p.must_be_domain + AND u.resource_owner = d.resource_owner + AND u.instance_id = d.instance_id +); \ No newline at end of file diff --git a/cmd/setup/config.go b/cmd/setup/config.go index 295f2b479..fd01c03c2 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -191,6 +191,7 @@ type Steps struct { s69CacheTablesLogged *CacheTablesLogged s70AddEventStoreCommandEnforceOwner *AddEventStoreCommandEnforceOwnerColumn s71JWTProvideAddAudienceColumn *JWTProvideAddAudienceColumn + s72AddColumnsToLoginNamesView *AddColumnsToLoginNamesView RelationalTables *TransactionalTables } diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index e2fe0467d..2992bc044 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -254,6 +254,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s69CacheTablesLogged = &CacheTablesLogged{dbClient: dbClient} steps.s70AddEventStoreCommandEnforceOwner = &AddEventStoreCommandEnforceOwnerColumn{dbClient: dbClient} steps.s71JWTProvideAddAudienceColumn = &JWTProvideAddAudienceColumn{dbClient: dbClient} + steps.s72AddColumnsToLoginNamesView = &AddColumnsToLoginNamesView{dbClient: dbClient} err = projection.Create(ctx, dbClient, eventstoreClient, config.Projections, nil, nil, nil) if err != nil { @@ -378,6 +379,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s66SessionRecoveryCodeCheckedAt, steps.s68TargetAddPayloadTypeColumn, steps.s71JWTProvideAddAudienceColumn, + steps.s72AddColumnsToLoginNamesView, } { setupErr = executeMigration(ctx, eventstoreClient, step, "migration failed") if setupErr != nil { diff --git a/internal/eventstore/handler/v2/init.go b/internal/eventstore/handler/v2/init.go index 2c32e5a2a..63c206767 100644 --- a/internal/eventstore/handler/v2/init.go +++ b/internal/eventstore/handler/v2/init.go @@ -70,6 +70,8 @@ type InitColumn struct { nullable bool defaultValue interface{} deleteCascade string + generated string + isStored bool } type ColumnOption func(*InitColumn) @@ -105,6 +107,13 @@ func DeleteCascade(column string) ColumnOption { } } +func Generated(stmt string, isStored bool) ColumnOption { + return func(c *InitColumn) { + c.generated = stmt + c.isStored = isStored + } +} + type PrimaryKey []string func NewPrimaryKey(columnNames ...string) PrimaryKey { @@ -381,6 +390,12 @@ func createColumnsStatement(cols []*InitColumn, tableName string) string { if col.defaultValue != nil { column += " DEFAULT " + defaultValue(col.defaultValue) } + if col.generated != "" { + column += " GENERATED ALWAYS AS (" + col.generated + ")" + if col.isStored { + column += " STORED" + } + } if len(col.deleteCascade) != 0 { column += fmt.Sprintf(" REFERENCES %s (%s) ON DELETE CASCADE", tableName, col.deleteCascade) } diff --git a/internal/query/login_name.go b/internal/query/login_name.go index 77a7d8e67..4a0194db4 100644 --- a/internal/query/login_name.go +++ b/internal/query/login_name.go @@ -15,6 +15,10 @@ var ( name: projection.LoginNameCol, table: loginNameTable, } + LoginNameNameLowerCol = Column{ + name: projection.LoginNameLowerCol, + table: loginNameTable, + } LoginNameIsPrimaryCol = Column{ name: projection.LoginNameIsPrimaryCol, table: loginNameTable, @@ -23,4 +27,8 @@ var ( name: projection.LoginNameInstanceIDCol, table: loginNameTable, } + LoginNameResourceOwnerCol = Column{ + name: projection.LoginNameResourceOwnerCol, + table: loginNameTable, + } ) diff --git a/internal/query/projection/login_name.go b/internal/query/projection/login_name.go index e60f725dc..81b8e1d4c 100644 --- a/internal/query/projection/login_name.go +++ b/internal/query/projection/login_name.go @@ -22,10 +22,12 @@ const ( LoginNamePolicyProjectionTable = LoginNameProjectionTable + "_" + loginNamePolicySuffix LoginNameDomainProjectionTable = LoginNameProjectionTable + "_" + loginNameDomainSuffix - LoginNameCol = "login_name" - LoginNameUserCol = "user_id" - LoginNameIsPrimaryCol = "is_primary" - LoginNameInstanceIDCol = "instance_id" + LoginNameCol = "login_name" + LoginNameUserCol = "user_id" + LoginNameIsPrimaryCol = "is_primary" + LoginNameInstanceIDCol = "instance_id" + LoginNameLowerCol = "login_name_lower" + LoginNameResourceOwnerCol = "resource_owner" usersAlias = "users" policyCustomAlias = "policy_custom" @@ -76,8 +78,7 @@ func (*loginNameProjection) Init() *old_handler.Check { []*handler.InitColumn{ handler.NewColumn(LoginNameUserIDCol, handler.ColumnTypeText), handler.NewColumn(LoginNameUserUserNameCol, handler.ColumnTypeText), - // TODO: implement computed columns - // handler.NewComputedColumn(loginNameUserUserNameLowerCol, handler.ColumnTypeText), + handler.NewColumn(loginNameUserUserNameLowerCol, handler.ColumnTypeText, handler.Generated("lower("+LoginNameUserUserNameCol+")", true)), handler.NewColumn(LoginNameUserResourceOwnerCol, handler.ColumnTypeText), handler.NewColumn(LoginNameUserInstanceIDCol, handler.ColumnTypeText), }, @@ -93,33 +94,30 @@ func (*loginNameProjection) Init() *old_handler.Check { ), ), ), - // TODO: uncomment the following line when login_names4 will be created - // handler.WithIndex( - // handler.NewIndex("search", []string{LoginNameUserInstanceIDCol, loginNameUserUserNameLowerCol}, - // handler.WithInclude(LoginNameUserResourceOwnerCol), - // ), - // ), + handler.WithIndex( + handler.NewIndex("search", []string{LoginNameUserInstanceIDCol, loginNameUserUserNameLowerCol}, + handler.WithInclude(LoginNameUserResourceOwnerCol), + ), + ), ), handler.NewSuffixedTable( []*handler.InitColumn{ handler.NewColumn(LoginNameDomainNameCol, handler.ColumnTypeText), - // TODO: implement computed columns - // handler.NewComputedColumn(loginNameDomainNameLowerCol, handler.ColumnTypeText), + handler.NewColumn(loginNameDomainNameLowerCol, handler.ColumnTypeText, handler.Generated("lower("+LoginNameDomainNameCol+")", true)), handler.NewColumn(LoginNameDomainIsPrimaryCol, handler.ColumnTypeBool, handler.Default(false)), handler.NewColumn(LoginNameDomainResourceOwnerCol, handler.ColumnTypeText), handler.NewColumn(LoginNameDomainInstanceIDCol, handler.ColumnTypeText), }, handler.NewPrimaryKey(LoginNameDomainInstanceIDCol, LoginNameDomainResourceOwnerCol, LoginNameDomainNameCol), loginNameDomainSuffix, - // TODO: uncomment the following line when login_names4 will be created - // handler.WithIndex( - // handler.NewIndex("search", []string{LoginNameDomainInstanceIDCol, LoginNameDomainResourceOwnerCol, loginNameDomainNameLowerCol}), - // ), - // handler.WithIndex( - // handler.NewIndex("search_result", []string{LoginNameDomainInstanceIDCol, LoginNameDomainResourceOwnerCol}, - // handler.WithInclude(LoginNameDomainIsPrimaryCol), - // ), - // ), + handler.WithIndex( + handler.NewIndex("search", []string{LoginNameDomainInstanceIDCol, LoginNameDomainResourceOwnerCol, loginNameDomainNameLowerCol}), + ), + handler.WithIndex( + handler.NewIndex("search_result", []string{LoginNameDomainInstanceIDCol, LoginNameDomainResourceOwnerCol}, + handler.WithInclude(LoginNameDomainIsPrimaryCol), + ), + ), ), handler.NewSuffixedTable( []*handler.InitColumn{ diff --git a/internal/query/projection/login_name_query.sql b/internal/query/projection/login_name_query.sql index 89dc803fe..7347d0c96 100644 --- a/internal/query/projection/login_name_query.sql +++ b/internal/query/projection/login_name_query.sql @@ -6,6 +6,11 @@ SELECT END AS login_name , COALESCE(d.is_primary, TRUE) AS is_primary , u.instance_id + , u.resource_owner + , CASE + WHEN p.must_be_domain THEN CONCAT(u.user_name_lower, '@', d.name_lower) + ELSE u.user_name_lower + END AS login_name_lower FROM projections.login_names3_users AS u LEFT JOIN LATERAL ( diff --git a/internal/query/projection/projection.go b/internal/query/projection/projection.go index feaca113e..f528d509e 100644 --- a/internal/query/projection/projection.go +++ b/internal/query/projection/projection.go @@ -221,6 +221,7 @@ func Projections() []projection { func Init(ctx context.Context) error { for _, p := range projections { if err := p.Init(ctx); err != nil { + logging.WithError(err).WithField("projection", p.ProjectionName()).Error("failed to initialize projection") return err } } diff --git a/internal/query/user.go b/internal/query/user.go index ed18e48a2..5abb16e53 100644 --- a/internal/query/user.go +++ b/internal/query/user.go @@ -803,13 +803,21 @@ func NewUserLoginNameExistsQuery(value string, comparison TextComparison) (Searc if err != nil { return nil, err } + resourceOwnerQuery, err := NewColumnComparisonQuery(LoginNameResourceOwnerCol, UserResourceOwnerCol, ColumnEquals) + if err != nil { + return nil, err + } // text query to select data from the linked sub select - loginNameQuery, err := NewTextQuery(LoginNameNameCol, value, comparison) + var loginNameQuery SearchQuery + loginNameQuery, err = NewTextQuery(LoginNameNameCol, value, comparison) + if comparison == TextEqualsIgnoreCase { + loginNameQuery, err = NewTextQuery(LoginNameNameLowerCol, strings.ToLower(value), TextEquals) + } if err != nil { return nil, err } // full definition of the sub select - subSelect, err := NewSubSelect(LoginNameUserIDCol, []SearchQuery{instanceQuery, userIDQuery, loginNameQuery}) + subSelect, err := NewSubSelect(LoginNameUserIDCol, []SearchQuery{instanceQuery, userIDQuery, resourceOwnerQuery, loginNameQuery}) if err != nil { return nil, err }