feat(api): move organization settings endpoints to v2 (#10910)

# Which Problems Are Solved

As part of our efforts to simplify the structure and versions of our
APIs, were moving all existing v2beta endpoints to v2 and deprecate
them. They will be removed in Zitadel V5.

# How the Problems Are Solved

- This PR adds the organization settings endpoints to the settings
service v2. This was split from #10909, since that PR targets v4.x and
the corresponding feature is not yet available in v4.x, but only v5.
- The comments and have been improved and, where not already done, moved
from swagger annotations to proto.

# Additional Changes

None

# Additional Context

- relates to #10909 
- relates to #10772
This commit is contained in:
Livio Spring
2025-10-17 11:21:01 +02:00
committed by GitHub
parent dbf877e028
commit 8d5f92f80a
9 changed files with 903 additions and 3 deletions

View File

@@ -514,7 +514,7 @@ func startAPIs(
if err := apis.RegisterService(ctx, session_v2.CreateServer(commands, queries, permissionCheck)); err != nil {
return nil, err
}
if err := apis.RegisterService(ctx, settings_v2.CreateServer(commands, queries)); err != nil {
if err := apis.RegisterService(ctx, settings_v2.CreateServer(config.SystemDefaults, commands, queries, permissionCheck)); err != nil {
return nil, err
}
if err := apis.RegisterService(ctx, org_v2.CreateServer(commands, queries, permissionCheck)); err != nil {

View File

@@ -16,6 +16,7 @@ import (
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/integration"
"github.com/zitadel/zitadel/pkg/grpc/filter/v2"
"github.com/zitadel/zitadel/pkg/grpc/idp"
idp_pb "github.com/zitadel/zitadel/pkg/grpc/idp/v2"
object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2"
@@ -427,3 +428,267 @@ func TestServer_GetHostedLoginTranslation(t *testing.T) {
})
}
}
func TestServer_ListOrganizationSettings(t *testing.T) {
instance := integration.NewInstance(CTX)
iamOwnerCtx := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner)
type args struct {
ctx context.Context
dep func(*settings.ListOrganizationSettingsRequest, *settings.ListOrganizationSettingsResponse)
req *settings.ListOrganizationSettingsRequest
}
tests := []struct {
name string
args args
want *settings.ListOrganizationSettingsResponse
wantErr bool
}{
{
name: "list by id, unauthenticated",
args: args{
ctx: CTX,
dep: func(request *settings.ListOrganizationSettingsRequest, response *settings.ListOrganizationSettingsResponse) {
orgResp := instance.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email())
instance.SetOrganizationSettings(iamOwnerCtx, t, orgResp.GetOrganizationId(), true)
request.Filters[0].Filter = &settings.OrganizationSettingsSearchFilter_InOrganizationIdsFilter{
InOrganizationIdsFilter: &filter.InIDsFilter{
Ids: []string{orgResp.GetOrganizationId()},
},
}
},
req: &settings.ListOrganizationSettingsRequest{
Filters: []*settings.OrganizationSettingsSearchFilter{{}},
},
},
wantErr: true,
},
{
name: "list by id, no permission",
args: args{
ctx: instance.WithAuthorizationToken(CTX, integration.UserTypeNoPermission),
dep: func(request *settings.ListOrganizationSettingsRequest, response *settings.ListOrganizationSettingsResponse) {
orgResp := instance.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email())
instance.SetOrganizationSettings(iamOwnerCtx, t, orgResp.GetOrganizationId(), true)
request.Filters[0].Filter = &settings.OrganizationSettingsSearchFilter_InOrganizationIdsFilter{
InOrganizationIdsFilter: &filter.InIDsFilter{
Ids: []string{orgResp.GetOrganizationId()},
},
}
},
req: &settings.ListOrganizationSettingsRequest{
Filters: []*settings.OrganizationSettingsSearchFilter{{}},
},
},
want: &settings.ListOrganizationSettingsResponse{
Pagination: &filter.PaginationResponse{
TotalResult: 1,
AppliedLimit: 100,
},
OrganizationSettings: []*settings.OrganizationSettings{},
},
},
{
name: "list by id, missing permission",
args: args{
ctx: instance.WithAuthorizationToken(CTX, integration.UserTypeOrgOwner),
dep: func(request *settings.ListOrganizationSettingsRequest, response *settings.ListOrganizationSettingsResponse) {
orgResp := instance.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email())
instance.SetOrganizationSettings(iamOwnerCtx, t, orgResp.GetOrganizationId(), true)
request.Filters[0].Filter = &settings.OrganizationSettingsSearchFilter_InOrganizationIdsFilter{
InOrganizationIdsFilter: &filter.InIDsFilter{
Ids: []string{orgResp.GetOrganizationId()},
},
}
},
req: &settings.ListOrganizationSettingsRequest{
Filters: []*settings.OrganizationSettingsSearchFilter{{}},
},
},
want: &settings.ListOrganizationSettingsResponse{
Pagination: &filter.PaginationResponse{
TotalResult: 1,
AppliedLimit: 100,
},
OrganizationSettings: []*settings.OrganizationSettings{},
},
},
{
name: "list, not found",
args: args{
ctx: iamOwnerCtx,
req: &settings.ListOrganizationSettingsRequest{
Filters: []*settings.OrganizationSettingsSearchFilter{{
Filter: &settings.OrganizationSettingsSearchFilter_InOrganizationIdsFilter{
InOrganizationIdsFilter: &filter.InIDsFilter{
Ids: []string{"notexisting"},
},
},
}},
},
},
want: &settings.ListOrganizationSettingsResponse{
Pagination: &filter.PaginationResponse{
TotalResult: 0,
AppliedLimit: 100,
},
OrganizationSettings: []*settings.OrganizationSettings{},
},
},
{
name: "list single id",
args: args{
ctx: iamOwnerCtx,
dep: func(request *settings.ListOrganizationSettingsRequest, response *settings.ListOrganizationSettingsResponse) {
orgResp := instance.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email())
settingsResp := instance.SetOrganizationSettings(iamOwnerCtx, t, orgResp.GetOrganizationId(), true)
request.Filters[0].Filter = &settings.OrganizationSettingsSearchFilter_InOrganizationIdsFilter{
InOrganizationIdsFilter: &filter.InIDsFilter{
Ids: []string{orgResp.GetOrganizationId()},
},
}
response.OrganizationSettings[0] = &settings.OrganizationSettings{
OrganizationId: orgResp.GetOrganizationId(),
CreationDate: settingsResp.GetSetDate(),
ChangeDate: settingsResp.GetSetDate(),
OrganizationScopedUsernames: true,
}
},
req: &settings.ListOrganizationSettingsRequest{
Filters: []*settings.OrganizationSettingsSearchFilter{{}},
},
},
want: &settings.ListOrganizationSettingsResponse{
Pagination: &filter.PaginationResponse{
TotalResult: 1,
AppliedLimit: 100,
},
OrganizationSettings: []*settings.OrganizationSettings{{}},
},
},
{
name: "list multiple id",
args: args{
ctx: iamOwnerCtx,
dep: func(request *settings.ListOrganizationSettingsRequest, response *settings.ListOrganizationSettingsResponse) {
orgResp1 := instance.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email())
settingsResp1 := instance.SetOrganizationSettings(iamOwnerCtx, t, orgResp1.GetOrganizationId(), true)
orgResp2 := instance.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email())
settingsResp2 := instance.SetOrganizationSettings(iamOwnerCtx, t, orgResp2.GetOrganizationId(), true)
orgResp3 := instance.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email())
settingsResp3 := instance.SetOrganizationSettings(iamOwnerCtx, t, orgResp3.GetOrganizationId(), true)
request.Filters[0].Filter = &settings.OrganizationSettingsSearchFilter_InOrganizationIdsFilter{
InOrganizationIdsFilter: &filter.InIDsFilter{
Ids: []string{orgResp1.GetOrganizationId(), orgResp2.GetOrganizationId(), orgResp3.GetOrganizationId()},
},
}
response.OrganizationSettings[2] = &settings.OrganizationSettings{
OrganizationId: orgResp1.GetOrganizationId(),
CreationDate: settingsResp1.GetSetDate(),
ChangeDate: settingsResp1.GetSetDate(),
OrganizationScopedUsernames: true,
}
response.OrganizationSettings[1] = &settings.OrganizationSettings{
OrganizationId: orgResp2.GetOrganizationId(),
CreationDate: settingsResp2.GetSetDate(),
ChangeDate: settingsResp2.GetSetDate(),
OrganizationScopedUsernames: true,
}
response.OrganizationSettings[0] = &settings.OrganizationSettings{
OrganizationId: orgResp3.GetOrganizationId(),
CreationDate: settingsResp3.GetSetDate(),
ChangeDate: settingsResp3.GetSetDate(),
OrganizationScopedUsernames: true,
}
},
req: &settings.ListOrganizationSettingsRequest{
Filters: []*settings.OrganizationSettingsSearchFilter{{}},
},
},
want: &settings.ListOrganizationSettingsResponse{
Pagination: &filter.PaginationResponse{
TotalResult: 3,
AppliedLimit: 100,
},
OrganizationSettings: []*settings.OrganizationSettings{{}, {}, {}},
},
},
{
name: "list multiple id, only org scoped usernames",
args: args{
ctx: iamOwnerCtx,
dep: func(request *settings.ListOrganizationSettingsRequest, response *settings.ListOrganizationSettingsResponse) {
orgResp1 := instance.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email())
instance.SetOrganizationSettings(iamOwnerCtx, t, orgResp1.GetOrganizationId(), false)
orgResp2 := instance.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email())
settingsResp2 := instance.SetOrganizationSettings(iamOwnerCtx, t, orgResp2.GetOrganizationId(), true)
orgResp3 := instance.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email())
instance.SetOrganizationSettings(iamOwnerCtx, t, orgResp3.GetOrganizationId(), false)
request.Filters[0].Filter = &settings.OrganizationSettingsSearchFilter_InOrganizationIdsFilter{
InOrganizationIdsFilter: &filter.InIDsFilter{
Ids: []string{orgResp1.GetOrganizationId(), orgResp2.GetOrganizationId(), orgResp3.GetOrganizationId()},
},
}
request.Filters[1].Filter = &settings.OrganizationSettingsSearchFilter_OrganizationScopedUsernamesFilter{
OrganizationScopedUsernamesFilter: &settings.OrganizationScopedUsernamesFilter{
OrganizationScopedUsernames: true,
},
}
response.OrganizationSettings[0] = &settings.OrganizationSettings{
OrganizationId: orgResp2.GetOrganizationId(),
CreationDate: settingsResp2.GetSetDate(),
ChangeDate: settingsResp2.GetSetDate(),
OrganizationScopedUsernames: true,
}
},
req: &settings.ListOrganizationSettingsRequest{
Filters: []*settings.OrganizationSettingsSearchFilter{{}, {}},
},
},
want: &settings.ListOrganizationSettingsResponse{
Pagination: &filter.PaginationResponse{
TotalResult: 1,
AppliedLimit: 100,
},
OrganizationSettings: []*settings.OrganizationSettings{{}},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.args.dep != nil {
tt.args.dep(tt.args.req, tt.want)
}
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(iamOwnerCtx, time.Minute)
require.EventuallyWithT(t, func(ttt *assert.CollectT) {
got, listErr := instance.Client.SettingsV2.ListOrganizationSettings(tt.args.ctx, tt.args.req)
if tt.wantErr {
require.Error(ttt, listErr)
return
}
require.NoError(ttt, listErr)
// always first check length, otherwise its failed anyway
if assert.Len(ttt, got.OrganizationSettings, len(tt.want.OrganizationSettings)) {
for i := range tt.want.OrganizationSettings {
assert.EqualExportedValues(ttt, tt.want.OrganizationSettings[i], got.OrganizationSettings[i])
}
}
assertPaginationResponse(ttt, tt.want.Pagination, got.Pagination)
}, retryDuration, tick, "timeout waiting for expected execution result")
})
}
}
func assertPaginationResponse(t *assert.CollectT, expected *filter.PaginationResponse, actual *filter.PaginationResponse) {
assert.Equal(t, expected.AppliedLimit, actual.AppliedLimit)
assert.Equal(t, expected.TotalResult, actual.TotalResult)
}

View File

@@ -8,7 +8,9 @@ import (
"encoding/hex"
"fmt"
"testing"
"time"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
@@ -189,3 +191,279 @@ func TestSetHostedLoginTranslation(t *testing.T) {
})
}
}
func TestServer_SetOrganizationSettings(t *testing.T) {
instance := integration.NewInstance(CTX)
iamOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner)
type args struct {
ctx context.Context
req *settings.SetOrganizationSettingsRequest
}
type want struct {
set bool
setDate bool
}
tests := []struct {
name string
prepare func(req *settings.SetOrganizationSettingsRequest)
args args
want want
wantErr bool
}{
{
name: "permission error",
args: args{
ctx: instance.WithAuthorizationToken(CTX, integration.UserTypeOrgOwner),
req: &settings.SetOrganizationSettingsRequest{
OrganizationId: Instance.DefaultOrg.GetId(),
OrganizationScopedUsernames: gu.Ptr(true),
},
},
wantErr: true,
},
{
name: "org not provided",
args: args{
ctx: iamOwnerCTX,
req: &settings.SetOrganizationSettingsRequest{
OrganizationId: "",
OrganizationScopedUsernames: gu.Ptr(true),
},
},
wantErr: true,
},
{
name: "org not existing",
args: args{
ctx: iamOwnerCTX,
req: &settings.SetOrganizationSettingsRequest{
OrganizationId: "notexisting",
OrganizationScopedUsernames: gu.Ptr(true),
},
},
wantErr: true,
},
{
name: "success no changes",
prepare: func(req *settings.SetOrganizationSettingsRequest) {
orgResp := instance.CreateOrganization(iamOwnerCTX, integration.OrganizationName(), integration.Email())
req.OrganizationId = orgResp.GetOrganizationId()
},
args: args{
ctx: iamOwnerCTX,
req: &settings.SetOrganizationSettingsRequest{},
},
want: want{
set: false,
setDate: true,
},
},
{
name: "success user uniqueness",
prepare: func(req *settings.SetOrganizationSettingsRequest) {
orgResp := instance.CreateOrganization(iamOwnerCTX, integration.OrganizationName(), integration.Email())
req.OrganizationId = orgResp.GetOrganizationId()
},
args: args{
ctx: iamOwnerCTX,
req: &settings.SetOrganizationSettingsRequest{
OrganizationScopedUsernames: gu.Ptr(true),
},
},
want: want{
set: true,
setDate: true,
},
},
{
name: "success no change",
prepare: func(req *settings.SetOrganizationSettingsRequest) {
orgResp := instance.CreateOrganization(iamOwnerCTX, integration.OrganizationName(), integration.Email())
req.OrganizationId = orgResp.GetOrganizationId()
},
args: args{
ctx: iamOwnerCTX,
req: &settings.SetOrganizationSettingsRequest{
OrganizationScopedUsernames: gu.Ptr(false),
},
},
want: want{
set: false,
setDate: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
creationDate := time.Now().UTC()
if tt.prepare != nil {
tt.prepare(tt.args.req)
}
got, err := instance.Client.SettingsV2.SetOrganizationSettings(tt.args.ctx, tt.args.req)
if tt.wantErr {
assert.Error(t, err)
return
}
setDate := time.Time{}
if tt.want.set {
setDate = time.Now().UTC()
}
assert.NoError(t, err)
assertOrganizationSettingsResponse(t, creationDate, setDate, tt.want.setDate, got)
})
}
}
func assertOrganizationSettingsResponse(t *testing.T, creationDate, setDate time.Time, expectedSetDate bool, actualResp *settings.SetOrganizationSettingsResponse) {
if expectedSetDate {
if !setDate.IsZero() {
assert.WithinRange(t, actualResp.GetSetDate().AsTime(), creationDate, setDate)
} else {
assert.WithinRange(t, actualResp.GetSetDate().AsTime(), creationDate, time.Now().UTC())
}
} else {
assert.Nil(t, actualResp.SetDate)
}
}
func TestServer_DeleteOrganizationSettings(t *testing.T) {
instance := integration.NewInstance(CTX)
iamOwnerCTX := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner)
type args struct {
ctx context.Context
req *settings.DeleteOrganizationSettingsRequest
}
type want struct {
deletion bool
deletionDate bool
}
tests := []struct {
name string
prepare func(t *testing.T, req *settings.DeleteOrganizationSettingsRequest)
args args
want want
wantErr bool
}{
{
name: "permission error",
prepare: func(t *testing.T, req *settings.DeleteOrganizationSettingsRequest) {
orgResp := instance.CreateOrganization(iamOwnerCTX, integration.OrganizationName(), integration.Email())
req.OrganizationId = orgResp.GetOrganizationId()
instance.SetOrganizationSettings(iamOwnerCTX, t, orgResp.GetOrganizationId(), true)
},
args: args{
ctx: instance.WithAuthorizationToken(CTX, integration.UserTypeOrgOwner),
req: &settings.DeleteOrganizationSettingsRequest{
OrganizationId: Instance.DefaultOrg.GetId(),
},
},
wantErr: true,
},
{
name: "org not provided",
args: args{
ctx: iamOwnerCTX,
req: &settings.DeleteOrganizationSettingsRequest{
OrganizationId: "",
},
},
wantErr: true,
},
{
name: "org not existing",
args: args{
ctx: iamOwnerCTX,
req: &settings.DeleteOrganizationSettingsRequest{
OrganizationId: "notexisting",
},
},
want: want{
deletion: false,
deletionDate: false,
},
},
{
name: "success user uniqueness",
prepare: func(t *testing.T, req *settings.DeleteOrganizationSettingsRequest) {
orgResp := instance.CreateOrganization(iamOwnerCTX, integration.OrganizationName(), integration.Email())
req.OrganizationId = orgResp.GetOrganizationId()
instance.SetOrganizationSettings(iamOwnerCTX, t, orgResp.GetOrganizationId(), true)
},
args: args{
ctx: iamOwnerCTX,
req: &settings.DeleteOrganizationSettingsRequest{},
},
want: want{
deletion: true,
deletionDate: true,
},
},
{
name: "success no existing",
prepare: func(t *testing.T, req *settings.DeleteOrganizationSettingsRequest) {
orgResp := instance.CreateOrganization(iamOwnerCTX, integration.OrganizationName(), integration.Email())
req.OrganizationId = orgResp.GetOrganizationId()
},
args: args{
ctx: iamOwnerCTX,
req: &settings.DeleteOrganizationSettingsRequest{},
},
want: want{
deletion: false,
deletionDate: true,
},
},
{
name: "success already deleted",
prepare: func(t *testing.T, req *settings.DeleteOrganizationSettingsRequest) {
orgResp := instance.CreateOrganization(iamOwnerCTX, integration.OrganizationName(), integration.Email())
req.OrganizationId = orgResp.GetOrganizationId()
instance.SetOrganizationSettings(iamOwnerCTX, t, orgResp.GetOrganizationId(), true)
instance.DeleteOrganizationSettings(iamOwnerCTX, t, orgResp.GetOrganizationId())
},
args: args{
ctx: iamOwnerCTX,
req: &settings.DeleteOrganizationSettingsRequest{},
},
want: want{
deletion: false,
deletionDate: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
creationDate := time.Now().UTC()
if tt.prepare != nil {
tt.prepare(t, tt.args.req)
}
got, err := instance.Client.SettingsV2.DeleteOrganizationSettings(tt.args.ctx, tt.args.req)
if tt.wantErr {
assert.Error(t, err)
return
}
deletionDate := time.Time{}
if tt.want.deletion {
deletionDate = time.Now().UTC()
}
assert.NoError(t, err)
assertDeleteOrganizationSettingsResponse(t, creationDate, deletionDate, tt.want.deletionDate, got)
})
}
}
func assertDeleteOrganizationSettingsResponse(t *testing.T, creationDate, deletionDate time.Time, expectedDeletionDate bool, actualResp *settings.DeleteOrganizationSettingsResponse) {
if expectedDeletionDate {
if !deletionDate.IsZero() {
assert.WithinRange(t, actualResp.GetDeletionDate().AsTime(), creationDate, deletionDate)
} else {
assert.WithinRange(t, actualResp.GetDeletionDate().AsTime(), creationDate, time.Now().UTC())
}
} else {
assert.Nil(t, actualResp.DeletionDate)
}
}

View File

@@ -7,10 +7,13 @@ import (
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/grpc/filter/v2"
"github.com/zitadel/zitadel/internal/api/grpc/object/v2"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/i18n"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/zerrors"
filter_pb "github.com/zitadel/zitadel/pkg/grpc/filter/v2"
object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2"
"github.com/zitadel/zitadel/pkg/grpc/settings/v2"
)
@@ -208,3 +211,103 @@ func (s *Server) GetHostedLoginTranslation(ctx context.Context, req *connect.Req
return connect.NewResponse(translation), nil
}
func (s *Server) ListOrganizationSettings(ctx context.Context, req *connect.Request[settings.ListOrganizationSettingsRequest]) (*connect.Response[settings.ListOrganizationSettingsResponse], error) {
queries, err := s.listOrganizationSettingsRequestToModel(req.Msg)
if err != nil {
return nil, err
}
resp, err := s.query.SearchOrganizationSettings(ctx, queries, s.checkPermission)
if err != nil {
return nil, err
}
return connect.NewResponse(&settings.ListOrganizationSettingsResponse{
OrganizationSettings: organizationSettingsListToPb(resp.OrganizationSettingsList),
Pagination: filter.QueryToPaginationPb(queries.SearchRequest, resp.SearchResponse),
}), nil
}
func (s *Server) listOrganizationSettingsRequestToModel(req *settings.ListOrganizationSettingsRequest) (*query.OrganizationSettingsSearchQueries, error) {
offset, limit, asc, err := filter.PaginationPbToQuery(s.systemDefaults, req.Pagination)
if err != nil {
return nil, err
}
queries, err := organizationSettingsFiltersToQuery(req.Filters)
if err != nil {
return nil, err
}
return &query.OrganizationSettingsSearchQueries{
SearchRequest: query.SearchRequest{
Offset: offset,
Limit: limit,
Asc: asc,
SortingColumn: organizationSettingsFieldNameToSortingColumn(req.SortingColumn),
},
Queries: queries,
}, nil
}
func organizationSettingsFieldNameToSortingColumn(field *settings.OrganizationSettingsFieldName) query.Column {
if field == nil {
return query.OrganizationSettingsColumnCreationDate
}
switch *field {
case settings.OrganizationSettingsFieldName_ORGANIZATION_SETTINGS_FIELD_NAME_CREATION_DATE:
return query.OrganizationSettingsColumnCreationDate
case settings.OrganizationSettingsFieldName_ORGANIZATION_SETTINGS_FIELD_NAME_ORGANIZATION_ID:
return query.OrganizationSettingsColumnID
case settings.OrganizationSettingsFieldName_ORGANIZATION_SETTINGS_FIELD_NAME_CHANGE_DATE:
return query.OrganizationSettingsColumnChangeDate
case settings.OrganizationSettingsFieldName_ORGANIZATION_SETTINGS_FIELD_NAME_UNSPECIFIED:
return query.OrganizationSettingsColumnCreationDate
default:
return query.OrganizationSettingsColumnCreationDate
}
}
func organizationSettingsFiltersToQuery(queries []*settings.OrganizationSettingsSearchFilter) (_ []query.SearchQuery, err error) {
q := make([]query.SearchQuery, len(queries))
for i, qry := range queries {
q[i], err = organizationSettingsToModel(qry)
if err != nil {
return nil, err
}
}
return q, nil
}
func organizationSettingsToModel(filter *settings.OrganizationSettingsSearchFilter) (query.SearchQuery, error) {
switch q := filter.Filter.(type) {
case *settings.OrganizationSettingsSearchFilter_InOrganizationIdsFilter:
return organizationInIDsFilterToQuery(q.InOrganizationIdsFilter)
case *settings.OrganizationSettingsSearchFilter_OrganizationScopedUsernamesFilter:
return organizationScopedUsernamesFilterToQuery(q.OrganizationScopedUsernamesFilter)
default:
return nil, zerrors.ThrowInvalidArgument(nil, "SETTINGS-uvTDqZHlvS", "List.Query.Invalid")
}
}
func organizationInIDsFilterToQuery(q *filter_pb.InIDsFilter) (query.SearchQuery, error) {
return query.NewOrganizationSettingsOrganizationIDSearchQuery(q.Ids)
}
func organizationScopedUsernamesFilterToQuery(q *settings.OrganizationScopedUsernamesFilter) (query.SearchQuery, error) {
return query.NewOrganizationSettingsOrganizationScopedUsernamesSearchQuery(q.OrganizationScopedUsernames)
}
func organizationSettingsListToPb(settingsList []*query.OrganizationSettings) []*settings.OrganizationSettings {
o := make([]*settings.OrganizationSettings, len(settingsList))
for i, organizationSettings := range settingsList {
o[i] = organizationSettingsToPb(organizationSettings)
}
return o
}
func organizationSettingsToPb(organizationSettings *query.OrganizationSettings) *settings.OrganizationSettings {
return &settings.OrganizationSettings{
OrganizationId: organizationSettings.ID,
CreationDate: timestamppb.New(organizationSettings.CreationDate),
ChangeDate: timestamppb.New(organizationSettings.ChangeDate),
OrganizationScopedUsernames: organizationSettings.OrganizationScopedUsernames,
}
}

View File

@@ -11,6 +11,8 @@ import (
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/grpc/server"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/config/systemdefaults"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/pkg/grpc/settings/v2"
"github.com/zitadel/zitadel/pkg/grpc/settings/v2/settingsconnect"
@@ -19,19 +21,25 @@ import (
var _ settingsconnect.SettingsServiceHandler = (*Server)(nil)
type Server struct {
command *command.Commands
query *query.Queries
systemDefaults systemdefaults.SystemDefaults
command *command.Commands
query *query.Queries
checkPermission domain.PermissionCheck
assetsAPIDomain func(context.Context) string
}
func CreateServer(
systemDefaults systemdefaults.SystemDefaults,
command *command.Commands,
query *query.Queries,
checkPermission domain.PermissionCheck,
) *Server {
return &Server{
systemDefaults: systemDefaults,
command: command,
query: query,
checkPermission: checkPermission,
assetsAPIDomain: assets.AssetAPI(),
}
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"connectrpc.com/connect"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/api/grpc/object/v2"
"github.com/zitadel/zitadel/pkg/grpc/settings/v2"
@@ -27,3 +28,31 @@ func (s *Server) SetHostedLoginTranslation(ctx context.Context, req *connect.Req
return connect.NewResponse(res), nil
}
func (s *Server) SetOrganizationSettings(ctx context.Context, req *connect.Request[settings.SetOrganizationSettingsRequest]) (*connect.Response[settings.SetOrganizationSettingsResponse], error) {
details, err := s.command.SetOrganizationSettings(ctx, organizationSettingsToCommand(req.Msg))
if err != nil {
return nil, err
}
var setDate *timestamppb.Timestamp
if !details.EventDate.IsZero() {
setDate = timestamppb.New(details.EventDate)
}
return connect.NewResponse(&settings.SetOrganizationSettingsResponse{
SetDate: setDate,
}), nil
}
func (s *Server) DeleteOrganizationSettings(ctx context.Context, req *connect.Request[settings.DeleteOrganizationSettingsRequest]) (*connect.Response[settings.DeleteOrganizationSettingsResponse], error) {
details, err := s.command.DeleteOrganizationSettings(ctx, req.Msg.GetOrganizationId())
if err != nil {
return nil, err
}
var deletionDate *timestamppb.Timestamp
if !details.EventDate.IsZero() {
deletionDate = timestamppb.New(details.EventDate)
}
return connect.NewResponse(&settings.DeleteOrganizationSettingsResponse{
DeletionDate: deletionDate,
}), nil
}

View File

@@ -252,3 +252,10 @@ func securitySettingsToCommand(req *settings.SetSecuritySettingsRequest) *comman
EnableImpersonation: req.GetEnableImpersonation(),
}
}
func organizationSettingsToCommand(req *settings.SetOrganizationSettingsRequest) *command.SetOrganizationSettings {
return &command.SetOrganizationSettings{
OrganizationID: req.OrganizationId,
OrganizationScopedUsernames: req.OrganizationScopedUsernames,
}
}

View File

@@ -0,0 +1,58 @@
syntax = "proto3";
package zitadel.settings.v2;
option go_package = "github.com/zitadel/zitadel/pkg/grpc/settings/v2;settings";
import "google/api/field_behavior.proto";
import "protoc-gen-openapiv2/options/annotations.proto";
import "validate/validate.proto";
import "google/protobuf/timestamp.proto";
import "zitadel/filter/v2/filter.proto";
message OrganizationSettings {
// The unique identifier of the organization the settings belong to.
string organization_id = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"69629012906488334\"";
}
];
// The timestamp of the organization settings creation.
google.protobuf.Timestamp creation_date = 2[
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"2024-12-18T07:50:47.492Z\"";
}
];
// The timestamp of the last change to the organization settings.
google.protobuf.Timestamp change_date = 3 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"2025-01-23T10:34:18.051Z\"";
}
];
// Defines if the usernames have to be unique in the organization context.
bool organization_scoped_usernames = 4;
}
enum OrganizationSettingsFieldName {
ORGANIZATION_SETTINGS_FIELD_NAME_UNSPECIFIED = 0;
ORGANIZATION_SETTINGS_FIELD_NAME_ORGANIZATION_ID = 1;
ORGANIZATION_SETTINGS_FIELD_NAME_CREATION_DATE = 2;
ORGANIZATION_SETTINGS_FIELD_NAME_CHANGE_DATE = 3;
}
message OrganizationSettingsSearchFilter {
oneof filter {
option (validate.required) = true;
zitadel.filter.v2.InIDsFilter in_organization_ids_filter = 1;
OrganizationScopedUsernamesFilter organization_scoped_usernames_filter = 2;
}
}
// Query for organization settings with specific scopes usernames.
message OrganizationScopedUsernamesFilter {
bool organization_scoped_usernames = 1;
}

View File

@@ -19,6 +19,7 @@ import "google/protobuf/struct.proto";
import "zitadel/settings/v2/settings.proto";
import "google/protobuf/timestamp.proto";
import "zitadel/filter/v2/filter.proto";
import "zitadel/settings/v2/organization_settings.proto";
option go_package = "github.com/zitadel/zitadel/pkg/grpc/settings/v2;settings";
@@ -418,6 +419,99 @@ service SettingsService {
};
}
// Set Organization Settings
//
// Sets the settings specific to an organization.
// Organization scopes usernames defines that the usernames have to be unique in the organization scope,
// can only be changed if the usernames of the users are unique in the scope.
//
// Required permissions:
// - `iam.policy.write`
rpc SetOrganizationSettings(SetOrganizationSettingsRequest) returns (SetOrganizationSettingsResponse) {
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
responses: {
key: "200";
value: {
description: "The translations was successfully set.";
}
};
};
option (google.api.http) = {
post: "/v2/settings/organization";
body: "*"
};
option (zitadel.protoc_gen_zitadel.v2.options) = {
auth_option: {
permission: "authenticated"
}
};
}
// Delete Organization Settings
//
// Delete the settings specific to an organization.
//
// Required permissions:
// - `iam.policy.delete`
rpc DeleteOrganizationSettings(DeleteOrganizationSettingsRequest) returns (DeleteOrganizationSettingsResponse) {
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
responses: {
key: "200";
value: {
description: "The translations was successfully set.";
}
};
};
option (google.api.http) = {
delete: "/v2/settings/organization";
body: "*"
};
option (zitadel.protoc_gen_zitadel.v2.options) = {
auth_option: {
permission: "authenticated"
}
};
}
// List Organization Settings
//
// Returns a list of organization settings.
//
// Required permission:
// - `iam.policy.read`
// - `org.policy.read`
rpc ListOrganizationSettings(ListOrganizationSettingsRequest) returns (ListOrganizationSettingsResponse) {
option (google.api.http) = {
post: "/v2/settings/organization/search"
body: "*"
};
option (zitadel.protoc_gen_zitadel.v2.options) = {
auth_option: {
permission: "authenticated"
}
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
responses: {
key: "200";
value: {
description: "A list of all project grants matching the query";
};
};
responses: {
key: "400";
value: {
description: "invalid list query";
};
};
};
}
// Get Hosted Login Translation
//
// Returns the translations in the requested locale for the hosted login.
@@ -640,6 +734,64 @@ message SetSecuritySettingsResponse{
zitadel.object.v2.Details details = 1;
}
message SetOrganizationSettingsRequest {
// Organization ID in which this settings are set.
string organization_id = 1;
// Force the usernames in the organization to be unique,
// only possible to set if the existing users already have unique usernames in the organization context.
optional bool organization_scoped_usernames = 2;
}
message SetOrganizationSettingsResponse {
// The timestamp of the set of the organization settings.
google.protobuf.Timestamp set_date = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"2025-01-23T10:34:18.051Z\"";
}
];
}
message DeleteOrganizationSettingsRequest {
// Organization ID in which this settings are set.
string organization_id = 1;
}
message DeleteOrganizationSettingsResponse {
// The timestamp of the deletion of the organization settings.
google.protobuf.Timestamp deletion_date = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"2025-01-23T10:34:18.051Z\"";
}
];
}
message ListOrganizationSettingsRequest{
// List limitations and ordering.
optional zitadel.filter.v2.PaginationRequest pagination = 1;
// The field the result is sorted by. The default is the creation date.
// Beware that if you change this, your result pagination might be inconsistent.
optional OrganizationSettingsFieldName sorting_column = 2 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
default: "\"ORGANIZATION_SETTINGS_FIELD_NAME_CREATION_DATE\""
}
];
// Define the criteria to query for.
repeated OrganizationSettingsSearchFilter filters = 4;
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = {
example: "{\"pagination\":{\"offset\":0,\"limit\":0,\"asc\":true},\"sortingColumn\":\"ORGANIZATION_SETTINGS_FIELD_NAME_CREATION_DATE\",\"filters\":[{\"inOrganizationIdsFilter\":{\"ids\":[\"28746028909593987\"]}}]}";
};
}
message ListOrganizationSettingsResponse {
zitadel.filter.v2.PaginationResponse pagination = 1;
repeated OrganizationSettings organization_settings = 2;
}
message GetHostedLoginTranslationRequest {
// Specify the level from which the translation should be returned.
// If the requested level doesn't contain all translations, and ignore_inheritance is set to false