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 <[email protected]>
Co-authored-by: Marco A. <[email protected]>
This commit is contained in:
Livio Spring
2026-02-12 08:44:34 +00:00
committed by GitHub
co-authored by Copilot Marco A.
parent ceffb9c536
commit b0609aa861
14 changed files with 65 additions and 20 deletions
+10 -1
View File
@@ -11,7 +11,10 @@ import (
"github.com/zitadel/zitadel/internal/feature" "github.com/zitadel/zitadel/internal/feature"
) )
var emptyInstance = &instance{} var (
emptyInstance = &instance{}
_ Instance = (*instance)(nil)
)
type Instance interface { type Instance interface {
InstanceID() string InstanceID() string
@@ -19,6 +22,7 @@ type Instance interface {
ManagementConsoleClientID() string ManagementConsoleClientID() string
ManagementConsoleApplicationID() string ManagementConsoleApplicationID() string
DefaultLanguage() language.Tag DefaultLanguage() language.Tag
AllowedLanguages() []language.Tag
DefaultOrganisationID() string DefaultOrganisationID() string
SecurityPolicyAllowedOrigins() []string SecurityPolicyAllowedOrigins() []string
EnableImpersonation() bool EnableImpersonation() bool
@@ -43,6 +47,7 @@ type instance struct {
clientID string clientID string
orgID string orgID string
defaultLanguage language.Tag defaultLanguage language.Tag
allowedLanguages []language.Tag
features feature.Features features feature.Features
executionTargets target.Router executionTargets target.Router
} }
@@ -75,6 +80,10 @@ func (i *instance) DefaultLanguage() language.Tag {
return i.defaultLanguage return i.defaultLanguage
} }
func (i *instance) AllowedLanguages() []language.Tag {
return i.allowedLanguages
}
func (i *instance) DefaultOrganisationID() string { func (i *instance) DefaultOrganisationID() string {
return i.orgID return i.orgID
} }
+4
View File
@@ -115,6 +115,10 @@ func (m *mockInstance) DefaultLanguage() language.Tag {
return language.English return language.English
} }
func (m *mockInstance) AllowedLanguages() []language.Tag {
return []language.Tag{language.English}
}
func (m *mockInstance) DefaultOrganisationID() string { func (m *mockInstance) DefaultOrganisationID() string {
return "orgID" return "orgID"
} }
@@ -1112,7 +1112,9 @@ func TestServer_ListPublicKeys(t *testing.T) {
func assertPublicKeys(ttt *assert.CollectT, want *action.ListPublicKeysResponse, got *action.ListPublicKeysResponse) { func assertPublicKeys(ttt *assert.CollectT, want *action.ListPublicKeysResponse, got *action.ListPublicKeysResponse) {
assert.EqualExportedValues(ttt, want.Pagination, got.Pagination) 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 { for i := range want.PublicKeys {
assert.Equal(ttt, want.PublicKeys[i].GetKeyId(), got.PublicKeys[i].GetKeyId()) assert.Equal(ttt, want.PublicKeys[i].GetKeyId(), got.PublicKeys[i].GetKeyId())
assert.Equal(ttt, want.PublicKeys[i].GetActive(), got.PublicKeys[i].GetActive()) assert.Equal(ttt, want.PublicKeys[i].GetActive(), got.PublicKeys[i].GetActive())
+1 -5
View File
@@ -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) { func (s *Server) GetAllowedLanguages(ctx context.Context, _ *admin_pb.GetAllowedLanguagesRequest) (*admin_pb.GetAllowedLanguagesResponse, error) {
restrictions, err := s.query.GetInstanceRestrictions(ctx) allowed := authz.GetInstance(ctx).AllowedLanguages()
if err != nil {
return nil, err
}
allowed := restrictions.AllowedLanguages
if len(allowed) == 0 { if len(allowed) == 0 {
allowed = i18n.SupportedLanguages() allowed = i18n.SupportedLanguages()
} }
@@ -281,6 +281,10 @@ func (m *mockInstance) DefaultLanguage() language.Tag {
return language.English return language.English
} }
func (m *mockInstance) AllowedLanguages() []language.Tag {
return []language.Tag{language.English}
}
func (m *mockInstance) DefaultOrganisationID() string { func (m *mockInstance) DefaultOrganisationID() string {
return "orgID" return "orgID"
} }
+5
View File
@@ -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) { func (s *Server) GetGeneralSettings(ctx context.Context, _ *connect.Request[settings.GetGeneralSettingsRequest]) (*connect.Response[settings.GetGeneralSettingsResponse], error) {
instance := authz.GetInstance(ctx) instance := authz.GetInstance(ctx)
allowedLanguages := instance.AllowedLanguages()
if len(allowedLanguages) == 0 {
allowedLanguages = i18n.SupportedLanguages()
}
return connect.NewResponse(&settings.GetGeneralSettingsResponse{ return connect.NewResponse(&settings.GetGeneralSettingsResponse{
SupportedLanguages: domain.LanguagesToStrings(i18n.SupportedLanguages()), SupportedLanguages: domain.LanguagesToStrings(i18n.SupportedLanguages()),
DefaultOrgId: instance.DefaultOrganisationID(), DefaultOrgId: instance.DefaultOrganisationID(),
DefaultLanguage: instance.DefaultLanguage().String(), DefaultLanguage: instance.DefaultLanguage().String(),
AllowedLanguages: domain.LanguagesToStrings(allowedLanguages),
}), nil }), nil
} }
@@ -338,6 +338,10 @@ func (m *mockInstance) DefaultLanguage() language.Tag {
return language.English return language.English
} }
func (m *mockInstance) AllowedLanguages() []language.Tag {
return []language.Tag{language.English}
}
func (m *mockInstance) DefaultOrganisationID() string { func (m *mockInstance) DefaultOrganisationID() string {
return "orgID" return "orgID"
} }
+1 -5
View File
@@ -120,11 +120,7 @@ func (s *Server) Discovery(ctx context.Context, r *op.Request[struct{}]) (_ *op.
err = oidcError(ctx, err) err = oidcError(ctx, err)
span.EndWithError(err) span.EndWithError(err)
}() }()
restrictions, err := s.query.GetInstanceRestrictions(ctx) allowedLanguages := authz.GetInstance(ctx).AllowedLanguages()
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
if len(allowedLanguages) == 0 { if len(allowedLanguages) == 0 {
allowedLanguages = i18n.SupportedLanguages() allowedLanguages = i18n.SupportedLanguages()
} }
+1 -5
View File
@@ -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 { func (l *Login) getTranslator(ctx context.Context, authReq *domain.AuthRequest) *i18n.Translator {
restrictions, err := l.query.GetInstanceRestrictions(ctx) translator := l.renderer.NewTranslator(ctx, authz.GetInstance(ctx).AllowedLanguages())
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)
if authReq != nil { if authReq != nil {
l.addLoginTranslations(translator, authReq.DefaultTranslations) l.addLoginTranslations(translator, authReq.DefaultTranslations)
l.addLoginTranslations(translator, authReq.OrgTranslations) l.addLoginTranslations(translator, authReq.OrgTranslations)
+4
View File
@@ -205,6 +205,10 @@ func (m *mockInstance) DefaultLanguage() language.Tag {
return AllowedLanguage return AllowedLanguage
} }
func (m *mockInstance) AllowedLanguages() []language.Tag {
return []language.Tag{language.English}
}
func (m *mockInstance) DefaultOrganisationID() string { func (m *mockInstance) DefaultOrganisationID() string {
return "defaultOrgID" return "defaultOrgID"
} }
+9
View File
@@ -16,6 +16,7 @@ import (
"github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/handler/v2" "github.com/zitadel/zitadel/internal/eventstore/handler/v2"
target_domain "github.com/zitadel/zitadel/internal/execution/target" target_domain "github.com/zitadel/zitadel/internal/execution/target"
@@ -472,6 +473,7 @@ type authzInstance struct {
ExternalDomains database.TextArray[string] `json:"external_domains,omitempty"` ExternalDomains database.TextArray[string] `json:"external_domains,omitempty"`
TrustedDomains database.TextArray[string] `json:"trusted_domains,omitempty"` TrustedDomains database.TextArray[string] `json:"trusted_domains,omitempty"`
ExecutionTargets target_domain.Router `json:"execution_targets,omitzero"` ExecutionTargets target_domain.Router `json:"execution_targets,omitzero"`
AllowedLangs []language.Tag `json:"allowed_langs,omitempty"`
} }
type csp struct { type csp struct {
@@ -499,6 +501,10 @@ func (i *authzInstance) DefaultLanguage() language.Tag {
return i.DefaultLang return i.DefaultLang
} }
func (i *authzInstance) AllowedLanguages() []language.Tag {
return i.AllowedLangs
}
func (i *authzInstance) DefaultOrganisationID() string { func (i *authzInstance) DefaultOrganisationID() string {
return i.DefaultOrgID return i.DefaultOrgID
} }
@@ -566,6 +572,7 @@ func scanAuthzInstance() (*authzInstance, func(row *sql.Row) error) {
block sql.NullBool block sql.NullBool
features []byte features []byte
executionTargetsBytes []byte executionTargetsBytes []byte
allowedLanguages database.TextArray[string]
) )
err := row.Scan( err := row.Scan(
&instance.ID, &instance.ID,
@@ -583,6 +590,7 @@ func scanAuthzInstance() (*authzInstance, func(row *sql.Row) error) {
&instance.ExternalDomains, &instance.ExternalDomains,
&instance.TrustedDomains, &instance.TrustedDomains,
&executionTargetsBytes, &executionTargetsBytes,
&allowedLanguages,
) )
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return zerrors.ThrowNotFound(nil, "QUERY-1kIjX", "Errors.Instance.NotFound") 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.ExecutionTargets = target_domain.NewRouter(targets)
} }
instance.AllowedLangs = domain.StringsToLanguages(allowedLanguages)
return nil return nil
} }
} }
+4 -2
View File
@@ -73,7 +73,8 @@ select
f.features, f.features,
ed.domains as external_domains, ed.domains as external_domains,
td.domains as trusted_domains, td.domains as trusted_domains,
et.execution_targets et.execution_targets,
r.allowed_languages
from domain d from domain d
join projections.instances i on i.id = d.instance_id join projections.instances i on i.id = d.instance_id
left join projections.security_policies2 s on i.id = s.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 features f on i.id = f.instance_id
left join external_domains ed on i.id = ed.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 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;
+3 -1
View File
@@ -63,7 +63,8 @@ select
f.features, f.features,
ed.domains as external_domains, ed.domains as external_domains,
td.domains as trusted_domains, td.domains as trusted_domains,
et.execution_targets et.execution_targets,
r.allowed_languages
from projections.instances i from projections.instances i
left join projections.security_policies2 s on i.id = s.instance_id left join projections.security_policies2 s on i.id = s.instance_id
left join projections.limits l on i.id = l.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 external_domains ed on i.id = ed.instance_id
left join trusted_domains td on i.id = td.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
where i.id = $1; where i.id = $1;
@@ -701,6 +701,8 @@ message GetGeneralSettingsResponse {
]; ];
// The list of supported languages. // 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"). // The format is a BCP 47 language tag (e.g. "en", "de", "fr-CH").
repeated string supported_languages = 3 [ repeated string supported_languages = 3 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
@@ -711,6 +713,16 @@ message GetGeneralSettingsResponse {
// The unique identifier of the default organization. // 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. // The default organization is used to assign new users to an organization if no other organization is specified.
string default_organization_id = 4; 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{} message GetSecuritySettingsRequest{}