From b0609aa861cba58f9176157c5f32d1494b6dae0c Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Thu, 12 Feb 2026 09:44:34 +0100 Subject: [PATCH] feat(api): return allowed_languages (#11553) # Which Problems Are Solved While Zitadel provides a possibility to restrict certain languages to be used, the corresponding list could not be retrieved in the settings service (v2). This blocked login v2 implementations from respecting the list and they would always use all available languages. # How the Problems Are Solved - Retrieve the list when checking the instance and pass it into the context. - Return it as part of the existing `GetGeneralSettingsResponse` - This allows us to remove an additional query in some other cases / endpoints. # Additional Changes none # Additional Context - required for https://github.com/zitadel/zitadel/pull/11372 - backport to v4.x --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Marco A. --- internal/api/authz/instance.go | 11 ++++++++++- internal/api/authz/instance_test.go | 4 ++++ .../grpc/action/v2/integration_test/query_test.go | 4 +++- internal/api/grpc/admin/language.go | 6 +----- .../server/middleware/instance_interceptor_test.go | 4 ++++ internal/api/grpc/settings/v2/query.go | 5 +++++ .../api/http/middleware/instance_interceptor_test.go | 4 ++++ internal/api/oidc/server.go | 6 +----- internal/api/ui/login/renderer.go | 6 +----- internal/command/main_test.go | 4 ++++ internal/query/instance.go | 9 +++++++++ internal/query/instance_by_domain.sql | 6 ++++-- internal/query/instance_by_id.sql | 4 +++- proto/zitadel/settings/v2/settings_service.proto | 12 ++++++++++++ 14 files changed, 65 insertions(+), 20 deletions(-) diff --git a/internal/api/authz/instance.go b/internal/api/authz/instance.go index 2639ea124..9829ce2eb 100644 --- a/internal/api/authz/instance.go +++ b/internal/api/authz/instance.go @@ -11,7 +11,10 @@ import ( "github.com/zitadel/zitadel/internal/feature" ) -var emptyInstance = &instance{} +var ( + emptyInstance = &instance{} + _ Instance = (*instance)(nil) +) type Instance interface { InstanceID() string @@ -19,6 +22,7 @@ type Instance interface { ManagementConsoleClientID() string ManagementConsoleApplicationID() string DefaultLanguage() language.Tag + AllowedLanguages() []language.Tag DefaultOrganisationID() string SecurityPolicyAllowedOrigins() []string EnableImpersonation() bool @@ -43,6 +47,7 @@ type instance struct { clientID string orgID string defaultLanguage language.Tag + allowedLanguages []language.Tag features feature.Features executionTargets target.Router } @@ -75,6 +80,10 @@ func (i *instance) DefaultLanguage() language.Tag { return i.defaultLanguage } +func (i *instance) AllowedLanguages() []language.Tag { + return i.allowedLanguages +} + func (i *instance) DefaultOrganisationID() string { return i.orgID } diff --git a/internal/api/authz/instance_test.go b/internal/api/authz/instance_test.go index 303e68a9c..769bd8861 100644 --- a/internal/api/authz/instance_test.go +++ b/internal/api/authz/instance_test.go @@ -115,6 +115,10 @@ func (m *mockInstance) DefaultLanguage() language.Tag { return language.English } +func (m *mockInstance) AllowedLanguages() []language.Tag { + return []language.Tag{language.English} +} + func (m *mockInstance) DefaultOrganisationID() string { return "orgID" } diff --git a/internal/api/grpc/action/v2/integration_test/query_test.go b/internal/api/grpc/action/v2/integration_test/query_test.go index 2af0b627b..5276854cc 100644 --- a/internal/api/grpc/action/v2/integration_test/query_test.go +++ b/internal/api/grpc/action/v2/integration_test/query_test.go @@ -1112,7 +1112,9 @@ func TestServer_ListPublicKeys(t *testing.T) { func assertPublicKeys(ttt *assert.CollectT, want *action.ListPublicKeysResponse, got *action.ListPublicKeysResponse) { assert.EqualExportedValues(ttt, want.Pagination, got.Pagination) - assert.Len(ttt, got.PublicKeys, len(want.PublicKeys)) + if !assert.Len(ttt, got.PublicKeys, len(want.PublicKeys)) { + return + } for i := range want.PublicKeys { assert.Equal(ttt, want.PublicKeys[i].GetKeyId(), got.PublicKeys[i].GetKeyId()) assert.Equal(ttt, want.PublicKeys[i].GetActive(), got.PublicKeys[i].GetActive()) diff --git a/internal/api/grpc/admin/language.go b/internal/api/grpc/admin/language.go index fd6f19f57..f6d2beb31 100644 --- a/internal/api/grpc/admin/language.go +++ b/internal/api/grpc/admin/language.go @@ -33,11 +33,7 @@ func (s *Server) GetDefaultLanguage(ctx context.Context, _ *admin_pb.GetDefaultL } func (s *Server) GetAllowedLanguages(ctx context.Context, _ *admin_pb.GetAllowedLanguagesRequest) (*admin_pb.GetAllowedLanguagesResponse, error) { - restrictions, err := s.query.GetInstanceRestrictions(ctx) - if err != nil { - return nil, err - } - allowed := restrictions.AllowedLanguages + allowed := authz.GetInstance(ctx).AllowedLanguages() if len(allowed) == 0 { allowed = i18n.SupportedLanguages() } diff --git a/internal/api/grpc/server/middleware/instance_interceptor_test.go b/internal/api/grpc/server/middleware/instance_interceptor_test.go index 7382879e4..a57baada6 100644 --- a/internal/api/grpc/server/middleware/instance_interceptor_test.go +++ b/internal/api/grpc/server/middleware/instance_interceptor_test.go @@ -281,6 +281,10 @@ func (m *mockInstance) DefaultLanguage() language.Tag { return language.English } +func (m *mockInstance) AllowedLanguages() []language.Tag { + return []language.Tag{language.English} +} + func (m *mockInstance) DefaultOrganisationID() string { return "orgID" } diff --git a/internal/api/grpc/settings/v2/query.go b/internal/api/grpc/settings/v2/query.go index 26f8b1e26..41983a2d6 100644 --- a/internal/api/grpc/settings/v2/query.go +++ b/internal/api/grpc/settings/v2/query.go @@ -186,10 +186,15 @@ func activeIdentityProvidersToQuery(req *settings.GetActiveIdentityProvidersRequ func (s *Server) GetGeneralSettings(ctx context.Context, _ *connect.Request[settings.GetGeneralSettingsRequest]) (*connect.Response[settings.GetGeneralSettingsResponse], error) { instance := authz.GetInstance(ctx) + allowedLanguages := instance.AllowedLanguages() + if len(allowedLanguages) == 0 { + allowedLanguages = i18n.SupportedLanguages() + } return connect.NewResponse(&settings.GetGeneralSettingsResponse{ SupportedLanguages: domain.LanguagesToStrings(i18n.SupportedLanguages()), DefaultOrgId: instance.DefaultOrganisationID(), DefaultLanguage: instance.DefaultLanguage().String(), + AllowedLanguages: domain.LanguagesToStrings(allowedLanguages), }), nil } diff --git a/internal/api/http/middleware/instance_interceptor_test.go b/internal/api/http/middleware/instance_interceptor_test.go index 625fc78a7..a3174e4b7 100644 --- a/internal/api/http/middleware/instance_interceptor_test.go +++ b/internal/api/http/middleware/instance_interceptor_test.go @@ -338,6 +338,10 @@ func (m *mockInstance) DefaultLanguage() language.Tag { return language.English } +func (m *mockInstance) AllowedLanguages() []language.Tag { + return []language.Tag{language.English} +} + func (m *mockInstance) DefaultOrganisationID() string { return "orgID" } diff --git a/internal/api/oidc/server.go b/internal/api/oidc/server.go index 08d5a5a1c..e2a6f3b30 100644 --- a/internal/api/oidc/server.go +++ b/internal/api/oidc/server.go @@ -120,11 +120,7 @@ func (s *Server) Discovery(ctx context.Context, r *op.Request[struct{}]) (_ *op. err = oidcError(ctx, err) span.EndWithError(err) }() - restrictions, err := s.query.GetInstanceRestrictions(ctx) - if err != nil { - return nil, op.NewStatusError(oidc.ErrServerError().WithParent(err).WithReturnParentToClient(authz.GetFeatures(ctx).DebugOIDCParentError).WithDescription("internal server error"), http.StatusInternalServerError) - } - allowedLanguages := restrictions.AllowedLanguages + allowedLanguages := authz.GetInstance(ctx).AllowedLanguages() if len(allowedLanguages) == 0 { allowedLanguages = i18n.SupportedLanguages() } diff --git a/internal/api/ui/login/renderer.go b/internal/api/ui/login/renderer.go index e1b482d8e..d0238ce6e 100644 --- a/internal/api/ui/login/renderer.go +++ b/internal/api/ui/login/renderer.go @@ -433,11 +433,7 @@ func (l *Login) getBaseData(r *http.Request, authReq *domain.AuthRequest, transl } func (l *Login) getTranslator(ctx context.Context, authReq *domain.AuthRequest) *i18n.Translator { - restrictions, err := l.query.GetInstanceRestrictions(ctx) - if err != nil { - logging.OnError(err).Warn("cannot load instance restrictions to retrieve allowed languages for creating the translator") - } - translator := l.renderer.NewTranslator(ctx, restrictions.AllowedLanguages) + translator := l.renderer.NewTranslator(ctx, authz.GetInstance(ctx).AllowedLanguages()) if authReq != nil { l.addLoginTranslations(translator, authReq.DefaultTranslations) l.addLoginTranslations(translator, authReq.OrgTranslations) diff --git a/internal/command/main_test.go b/internal/command/main_test.go index 2a2a58108..cd67a8615 100644 --- a/internal/command/main_test.go +++ b/internal/command/main_test.go @@ -205,6 +205,10 @@ func (m *mockInstance) DefaultLanguage() language.Tag { return AllowedLanguage } +func (m *mockInstance) AllowedLanguages() []language.Tag { + return []language.Tag{language.English} +} + func (m *mockInstance) DefaultOrganisationID() string { return "defaultOrgID" } diff --git a/internal/query/instance.go b/internal/query/instance.go index ad3c5e4e1..0614527e4 100644 --- a/internal/query/instance.go +++ b/internal/query/instance.go @@ -16,6 +16,7 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/handler/v2" target_domain "github.com/zitadel/zitadel/internal/execution/target" @@ -472,6 +473,7 @@ type authzInstance struct { ExternalDomains database.TextArray[string] `json:"external_domains,omitempty"` TrustedDomains database.TextArray[string] `json:"trusted_domains,omitempty"` ExecutionTargets target_domain.Router `json:"execution_targets,omitzero"` + AllowedLangs []language.Tag `json:"allowed_langs,omitempty"` } type csp struct { @@ -499,6 +501,10 @@ func (i *authzInstance) DefaultLanguage() language.Tag { return i.DefaultLang } +func (i *authzInstance) AllowedLanguages() []language.Tag { + return i.AllowedLangs +} + func (i *authzInstance) DefaultOrganisationID() string { return i.DefaultOrgID } @@ -566,6 +572,7 @@ func scanAuthzInstance() (*authzInstance, func(row *sql.Row) error) { block sql.NullBool features []byte executionTargetsBytes []byte + allowedLanguages database.TextArray[string] ) err := row.Scan( &instance.ID, @@ -583,6 +590,7 @@ func scanAuthzInstance() (*authzInstance, func(row *sql.Row) error) { &instance.ExternalDomains, &instance.TrustedDomains, &executionTargetsBytes, + &allowedLanguages, ) if errors.Is(err, sql.ErrNoRows) { return zerrors.ThrowNotFound(nil, "QUERY-1kIjX", "Errors.Instance.NotFound") @@ -611,6 +619,7 @@ func scanAuthzInstance() (*authzInstance, func(row *sql.Row) error) { } instance.ExecutionTargets = target_domain.NewRouter(targets) } + instance.AllowedLangs = domain.StringsToLanguages(allowedLanguages) return nil } } diff --git a/internal/query/instance_by_domain.sql b/internal/query/instance_by_domain.sql index c7065c028..aaf1052e6 100644 --- a/internal/query/instance_by_domain.sql +++ b/internal/query/instance_by_domain.sql @@ -73,7 +73,8 @@ select f.features, ed.domains as external_domains, td.domains as trusted_domains, - et.execution_targets + et.execution_targets, + r.allowed_languages from domain d join projections.instances i on i.id = d.instance_id left join projections.security_policies2 s on i.id = s.instance_id @@ -81,4 +82,5 @@ left join projections.limits l on i.id = l.instance_id left join features f on i.id = f.instance_id left join external_domains ed on i.id = ed.instance_id left join trusted_domains td on i.id = td.instance_id -left join execution_targets et on i.id = et.instance_id; +left join execution_targets et on i.id = et.instance_id +left join projections.restrictions2 r on i.id = r.instance_id; diff --git a/internal/query/instance_by_id.sql b/internal/query/instance_by_id.sql index 2b9118c59..c6e8a0a12 100644 --- a/internal/query/instance_by_id.sql +++ b/internal/query/instance_by_id.sql @@ -63,7 +63,8 @@ select f.features, ed.domains as external_domains, td.domains as trusted_domains, - et.execution_targets + et.execution_targets, + r.allowed_languages from projections.instances i left join projections.security_policies2 s on i.id = s.instance_id left join projections.limits l on i.id = l.instance_id @@ -71,4 +72,5 @@ left join features f on i.id = f.instance_id left join external_domains ed on i.id = ed.instance_id left join trusted_domains td on i.id = td.instance_id left join execution_targets et on i.id = et.instance_id +left join projections.restrictions2 r on i.id = r.instance_id where i.id = $1; diff --git a/proto/zitadel/settings/v2/settings_service.proto b/proto/zitadel/settings/v2/settings_service.proto index 9e191f9f9..8d5fe8eea 100644 --- a/proto/zitadel/settings/v2/settings_service.proto +++ b/proto/zitadel/settings/v2/settings_service.proto @@ -701,6 +701,8 @@ message GetGeneralSettingsResponse { ]; // The list of supported languages. + // Note that the instance might restrict the languages further + // only allowing a subset of these languages to be used. // The format is a BCP 47 language tag (e.g. "en", "de", "fr-CH"). repeated string supported_languages = 3 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -711,6 +713,16 @@ message GetGeneralSettingsResponse { // The unique identifier of the default organization. // The default organization is used to assign new users to an organization if no other organization is specified. string default_organization_id = 4; + + // The list of allowed languages for the instance. + // This is a subset of the supported languages to be used in the instance + // e.g. for user selection during registration or language detection in the UI. + // The format is a BCP 47 language tag (e.g. "en", "de", "fr-CH"). + repeated string allowed_languages = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"en\", \"de\", \"it\"]" + } + ]; } message GetSecuritySettingsRequest{}