From 76fe032b5f50b6a334124d76c897a5373f83fd16 Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Wed, 22 Nov 2023 10:29:38 +0100 Subject: [PATCH] feat: option to disallow public org registration (#6917) * feat: return 404 or 409 if org reg disallowed * fix: system limit permissions * feat: add iam limits api * feat: disallow public org registrations on default instance * add integration test * test: integration * fix test * docs: describe public org registrations * avoid updating docs deps * fix system limits integration test * silence integration tests * fix linting * ignore strange linter complaints * review * improve reset properties naming * redefine the api * use restrictions aggregate * test query * simplify and test projection * test commands * fix unit tests * move integration test * support restrictions on default instance * also test GetRestrictions * self review * lint * abstract away resource owner * fix tests * lint --- Makefile | 2 +- cmd/defaults.yaml | 7 + .../guides/manage/customize/restrictions.md | 14 ++ docs/sidebars.js | 1 + .../admin/information_integration_test.go | 21 +- internal/api/grpc/admin/restrictions.go | 30 +++ .../admin/restrictions_integration_test.go | 106 ++++++++++ .../api/grpc/admin/server_integration_test.go | 32 +++ internal/api/ui/login/register_org_handler.go | 16 ++ internal/command/command.go | 2 + internal/command/instance.go | 34 ++-- internal/command/limits.go | 8 +- internal/command/main_test.go | 2 + internal/command/restrictions.go | 81 ++++++++ internal/command/restrictions_model.go | 55 +++++ internal/command/restrictions_test.go | 189 ++++++++++++++++++ internal/eventstore/handler/v2/statement.go | 48 ++++- .../eventstore/handler/v2/statement_test.go | 62 +++++- internal/integration/client.go | 4 +- internal/integration/integration.go | 12 +- internal/query/projection/main_test.go | 61 ------ internal/query/projection/projection.go | 3 + internal/query/projection/restrictions.go | 96 +++++++++ .../query/projection/restrictions_test.go | 96 +++++++++ internal/query/query.go | 2 + internal/query/restrictions.go | 108 ++++++++++ internal/query/restrictions_test.go | 111 ++++++++++ internal/repository/restrictions/aggregate.go | 26 +++ internal/repository/restrictions/events.go | 53 +++++ .../repository/restrictions/eventstore.go | 9 + internal/static/i18n/bg.yaml | 2 + internal/static/i18n/cs.yaml | 2 + internal/static/i18n/de.yaml | 2 + internal/static/i18n/en.yaml | 2 + internal/static/i18n/es.yaml | 2 + internal/static/i18n/fr.yaml | 2 + internal/static/i18n/it.yaml | 2 + internal/static/i18n/ja.yaml | 2 + internal/static/i18n/mk.yaml | 2 + internal/static/i18n/pl.yaml | 2 + internal/static/i18n/pt.yaml | 2 + internal/static/i18n/ru.yaml | 5 + internal/static/i18n/zh.yaml | 2 + proto/zitadel/admin.proto | 77 +++++++ proto/zitadel/system.proto | 6 +- 45 files changed, 1280 insertions(+), 123 deletions(-) create mode 100644 docs/docs/guides/manage/customize/restrictions.md create mode 100644 internal/api/grpc/admin/restrictions.go create mode 100644 internal/api/grpc/admin/restrictions_integration_test.go create mode 100644 internal/api/grpc/admin/server_integration_test.go create mode 100644 internal/command/restrictions.go create mode 100644 internal/command/restrictions_model.go create mode 100644 internal/command/restrictions_test.go delete mode 100644 internal/query/projection/main_test.go create mode 100644 internal/query/projection/restrictions.go create mode 100644 internal/query/projection/restrictions_test.go create mode 100644 internal/query/restrictions.go create mode 100644 internal/query/restrictions_test.go create mode 100644 internal/repository/restrictions/aggregate.go create mode 100644 internal/repository/restrictions/events.go create mode 100644 internal/repository/restrictions/eventstore.go diff --git a/Makefile b/Makefile index 2b225c24b..d75e8e0f6 100644 --- a/Makefile +++ b/Makefile @@ -100,7 +100,7 @@ core_integration_setup: .PHONY: core_integration_test core_integration_test: core_integration_setup - go test -tags=integration -race -p 1 -v -coverprofile=profile.cov -coverpkg=./internal/...,./cmd/... ./internal/integration ./internal/api/grpc/... ./internal/notification/handlers/... ./internal/api/oidc/... + go test -tags=integration -race -p 1 -coverprofile=profile.cov -coverpkg=./internal/...,./cmd/... ./internal/integration ./internal/api/grpc/... ./internal/notification/handlers/... ./internal/api/oidc/... .PHONY: console_lint console_lint: diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 16bc80ee7..40956d27d 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -823,6 +823,10 @@ DefaultInstance: # A value of "0s" means that all events are available. # If this value is set, it overwrites the system default unless it is not reset via the admin API. AuditLogRetention: # ZITADEL_DEFAULTINSTANCE_LIMITS_AUDITLOGRETENTION + Restrictions: + # DisallowPublicOrgRegistration defines if ZITADEL should expose the endpoint /ui/login/register/org + # If it is true, the endpoint returns the HTTP status 404 on GET requests, and 409 on POST requests. + DisallowPublicOrgRegistration: # ZITADEL_DEFAULTINSTANCE_RESTRICTIONS_DISALLOWPUBLICORGREGISTRATION Quotas: # Items take a slice of quota configurations, whereas, for each unit type and instance, one or zero quotas may exist. # The following unit types are supported @@ -907,6 +911,8 @@ InternalAuthZ: - "iam.flow.write" - "iam.flow.delete" - "iam.feature.write" + - "iam.restrictions.read" + - "iam.restrictions.write" - "org.read" - "org.global.read" - "org.create" @@ -967,6 +973,7 @@ InternalAuthZ: - "iam.idp.read" - "iam.action.read" - "iam.flow.read" + - "iam.restrictions.read" - "org.read" - "org.member.read" - "org.idp.read" diff --git a/docs/docs/guides/manage/customize/restrictions.md b/docs/docs/guides/manage/customize/restrictions.md new file mode 100644 index 000000000..443dd0a94 --- /dev/null +++ b/docs/docs/guides/manage/customize/restrictions.md @@ -0,0 +1,14 @@ +--- +title: Feature Restrictions +--- + +New self-hosted and [ZITADEL Cloud instances](https://zitadel.com/signin) are unrestricted by default. +Self-hosters can change this default using the DefaultInstance.Restrictions configuration section. +Users with the role IAM_OWNER can change the restrictions of their instance using the [Feature Restrictions Admin API](/category/apis/resources/admin/feature-restrictions). +Currently, the following restrictions are available: + +- *Disallow public organization registrations* - If restricted, only users with the role IAM_OWNERS can create new organizations. The endpoint */ui/login/register/org* returns HTTP status 404 on GET requests, and 409 on POST requests. +- *[Coming soon](https://github.com/zitadel/zitadel/issues/6250): AllowedLanguages* + +Feature restrictions for an instance are intended to be configured by a user that is managed within that instance. +However, if you are self-hosting and need to control your virtual instances usage, [read about the APIs for limits and quotas](/self-hosting/manage/usage_control) that are intended to be used by system users. diff --git a/docs/sidebars.js b/docs/sidebars.js index d2e7998fb..aaa282a21 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -94,6 +94,7 @@ module.exports = { "guides/manage/customize/branding", "guides/manage/customize/texts", "guides/manage/customize/behavior", + "guides/manage/customize/restrictions", ], }, { diff --git a/internal/api/grpc/admin/information_integration_test.go b/internal/api/grpc/admin/information_integration_test.go index 34aa52206..7c324fc27 100644 --- a/internal/api/grpc/admin/information_integration_test.go +++ b/internal/api/grpc/admin/information_integration_test.go @@ -4,32 +4,17 @@ package admin_test import ( "context" - "os" "testing" "time" "github.com/stretchr/testify/require" - "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/pkg/grpc/admin" ) -var ( - Tester *integration.Tester -) - -func TestMain(m *testing.M) { - os.Exit(func() int { - ctx, _, cancel := integration.Contexts(time.Minute) - defer cancel() - Tester = integration.NewTester(ctx) - defer Tester.Done() - - return m.Run() - }()) -} - func TestServer_Healthz(t *testing.T) { - _, err := Tester.Client.Admin.Healthz(context.TODO(), &admin.HealthzRequest{}) + ctx, cancel := context.WithTimeout(AdminCTX, time.Minute) + defer cancel() + _, err := Tester.Client.Admin.Healthz(ctx, &admin.HealthzRequest{}) require.NoError(t, err) } diff --git a/internal/api/grpc/admin/restrictions.go b/internal/api/grpc/admin/restrictions.go new file mode 100644 index 000000000..974f2e355 --- /dev/null +++ b/internal/api/grpc/admin/restrictions.go @@ -0,0 +1,30 @@ +package admin + +import ( + "context" + + "github.com/zitadel/zitadel/internal/api/grpc/object" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/pkg/grpc/admin" +) + +func (s *Server) SetRestrictions(ctx context.Context, req *admin.SetRestrictionsRequest) (*admin.SetRestrictionsResponse, error) { + details, err := s.command.SetInstanceRestrictions(ctx, &command.SetRestrictions{DisallowPublicOrgRegistration: req.DisallowPublicOrgRegistration}) + if err != nil { + return nil, err + } + return &admin.SetRestrictionsResponse{ + Details: object.ChangeToDetailsPb(details.Sequence, details.EventDate, details.ResourceOwner), + }, nil +} + +func (s *Server) GetRestrictions(ctx context.Context, _ *admin.GetRestrictionsRequest) (*admin.GetRestrictionsResponse, error) { + restrictions, err := s.query.GetInstanceRestrictions(ctx) + if err != nil { + return nil, err + } + return &admin.GetRestrictionsResponse{ + Details: object.ToViewDetailsPb(restrictions.Sequence, restrictions.CreationDate, restrictions.ChangeDate, restrictions.ResourceOwner), + DisallowPublicOrgRegistration: restrictions.DisallowPublicOrgRegistration, + }, nil +} diff --git a/internal/api/grpc/admin/restrictions_integration_test.go b/internal/api/grpc/admin/restrictions_integration_test.go new file mode 100644 index 000000000..07a56409e --- /dev/null +++ b/internal/api/grpc/admin/restrictions_integration_test.go @@ -0,0 +1,106 @@ +//go:build integration + +package admin_test + +import ( + "bytes" + "context" + "github.com/muhlemmer/gu" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/pkg/grpc/admin" +) + +func TestServer_Restrictions_DisallowPublicOrgRegistration(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + domain, _, iamOwnerCtx := Tester.UseIsolatedInstance(ctx, SystemCTX) + regOrgUrl, err := url.Parse("http://" + domain + ":8080/ui/login/register/org") + require.NoError(t, err) + // The CSRF cookie must be sent with every request. + // We can simulate a browser session using a cookie jar. + jar, err := cookiejar.New(nil) + require.NoError(t, err) + browserSession := &http.Client{Jar: jar} + // Default should be allowed + csrfToken := awaitAllowed(t, iamOwnerCtx, browserSession, regOrgUrl) + _, err = Tester.Client.Admin.SetRestrictions(iamOwnerCtx, &admin.SetRestrictionsRequest{DisallowPublicOrgRegistration: gu.Ptr(true)}) + require.NoError(t, err) + awaitDisallowed(t, iamOwnerCtx, browserSession, regOrgUrl, csrfToken) + _, err = Tester.Client.Admin.SetRestrictions(iamOwnerCtx, &admin.SetRestrictionsRequest{DisallowPublicOrgRegistration: gu.Ptr(false)}) + require.NoError(t, err) + awaitAllowed(t, iamOwnerCtx, browserSession, regOrgUrl) +} + +// awaitAllowed doesn't accept a CSRF token, as we expected it to always produce a new one +func awaitAllowed(t *testing.T, ctx context.Context, client *http.Client, parsedURL *url.URL) string { + csrfToken := awaitGetResponse(t, ctx, client, parsedURL, http.StatusOK) + awaitPostFormResponse(t, ctx, client, parsedURL, http.StatusOK, csrfToken) + restrictions, err := Tester.Client.Admin.GetRestrictions(ctx, &admin.GetRestrictionsRequest{}) + require.NoError(t, err) + require.False(t, restrictions.DisallowPublicOrgRegistration) + return csrfToken +} + +// awaitDisallowed accepts an old CSRF token, as we don't expect to get a CSRF token from the GET request anymore +func awaitDisallowed(t *testing.T, ctx context.Context, client *http.Client, parsedURL *url.URL, reuseOldCSRFToken string) { + awaitGetResponse(t, ctx, client, parsedURL, http.StatusNotFound) + awaitPostFormResponse(t, ctx, client, parsedURL, http.StatusConflict, reuseOldCSRFToken) + restrictions, err := Tester.Client.Admin.GetRestrictions(ctx, &admin.GetRestrictionsRequest{}) + require.NoError(t, err) + require.True(t, restrictions.DisallowPublicOrgRegistration) +} + +// awaitGetResponse cuts the CSRF token from the response body if it exists +func awaitGetResponse(t *testing.T, ctx context.Context, client *http.Client, parsedURL *url.URL, expectCode int) string { + var csrfToken []byte + await(t, ctx, func() bool { + resp, err := client.Get(parsedURL.String()) + require.NoError(t, err) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + searchField := ``)) + } + return resp.StatusCode == expectCode + }) + return string(csrfToken) +} + +// awaitPostFormResponse needs a valid CSRF token to make it to the actual endpoint implementation and get the expected status code +func awaitPostFormResponse(t *testing.T, ctx context.Context, client *http.Client, parsedURL *url.URL, expectCode int, csrfToken string) { + await(t, ctx, func() bool { + resp, err := client.PostForm(parsedURL.String(), url.Values{ + "gorilla.csrf.Token": {csrfToken}, + }) + require.NoError(t, err) + return resp.StatusCode == expectCode + + }) +} + +func await(t *testing.T, ctx context.Context, cb func() bool) { + deadline, ok := ctx.Deadline() + require.True(t, ok, "context must have deadline") + require.Eventuallyf( + t, + func() bool { + defer func() { + require.Nil(t, recover(), "panic in await callback") + }() + return cb() + }, + time.Until(deadline), + 100*time.Millisecond, + "awaiting successful callback failed", + ) +} diff --git a/internal/api/grpc/admin/server_integration_test.go b/internal/api/grpc/admin/server_integration_test.go new file mode 100644 index 000000000..64a761dd7 --- /dev/null +++ b/internal/api/grpc/admin/server_integration_test.go @@ -0,0 +1,32 @@ +//go:build integration + +package admin_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/zitadel/zitadel/internal/integration" +) + +var ( + AdminCTX, SystemCTX context.Context + Tester *integration.Tester +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, _, cancel := integration.Contexts(3 * time.Minute) + defer cancel() + + Tester = integration.NewTester(ctx) + defer Tester.Done() + + AdminCTX = Tester.WithAuthorization(ctx, integration.IAMOwner) + SystemCTX = Tester.WithAuthorization(ctx, integration.SystemUser) + + return m.Run() + }()) +} diff --git a/internal/api/ui/login/register_org_handler.go b/internal/api/ui/login/register_org_handler.go index d21639443..662f683d0 100644 --- a/internal/api/ui/login/register_org_handler.go +++ b/internal/api/ui/login/register_org_handler.go @@ -1,6 +1,7 @@ package login import ( + "context" "net/http" "github.com/zitadel/zitadel/internal/api/authz" @@ -38,6 +39,11 @@ type registerOrgData struct { } func (l *Login) handleRegisterOrg(w http.ResponseWriter, r *http.Request) { + disallowed, err := l.publicOrgRegistrationIsDisallowed(r.Context()) + if disallowed || err != nil { + w.WriteHeader(http.StatusNotFound) + return + } data := new(registerOrgFormData) authRequest, err := l.getAuthRequestAndParseData(r, data) if err != nil { @@ -48,6 +54,11 @@ func (l *Login) handleRegisterOrg(w http.ResponseWriter, r *http.Request) { } func (l *Login) handleRegisterOrgCheck(w http.ResponseWriter, r *http.Request) { + disallowed, err := l.publicOrgRegistrationIsDisallowed(r.Context()) + if disallowed || err != nil { + w.WriteHeader(http.StatusConflict) + return + } data := new(registerOrgFormData) authRequest, err := l.getAuthRequestAndParseData(r, data) if err != nil { @@ -119,6 +130,11 @@ func (l *Login) renderRegisterOrg(w http.ResponseWriter, r *http.Request, authRe l.renderer.RenderTemplate(w, r, translator, l.renderer.Templates[tmplRegisterOrg], data, nil) } +func (l *Login) publicOrgRegistrationIsDisallowed(ctx context.Context) (bool, error) { + restrictions, err := l.query.GetInstanceRestrictions(ctx) + return restrictions.DisallowPublicOrgRegistration, err +} + func (d registerOrgFormData) toUserDomain() *domain.Human { if d.Username == "" { d.Username = string(d.Email) diff --git a/internal/command/command.go b/internal/command/command.go index 5589965a3..3b45a9b87 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -32,6 +32,7 @@ import ( "github.com/zitadel/zitadel/internal/repository/org" proj_repo "github.com/zitadel/zitadel/internal/repository/project" "github.com/zitadel/zitadel/internal/repository/quota" + "github.com/zitadel/zitadel/internal/repository/restrictions" "github.com/zitadel/zitadel/internal/repository/session" usr_repo "github.com/zitadel/zitadel/internal/repository/user" usr_grant_repo "github.com/zitadel/zitadel/internal/repository/usergrant" @@ -152,6 +153,7 @@ func StartCommands( action.RegisterEventMappers(repo.eventstore) quota.RegisterEventMappers(repo.eventstore) limits.RegisterEventMappers(repo.eventstore) + restrictions.RegisterEventMappers(repo.eventstore) session.RegisterEventMappers(repo.eventstore) idpintent.RegisterEventMappers(repo.eventstore) authrequest.RegisterEventMappers(repo.eventstore) diff --git a/internal/command/instance.go b/internal/command/instance.go index df3420198..75325b5b3 100644 --- a/internal/command/instance.go +++ b/internal/command/instance.go @@ -21,6 +21,7 @@ import ( "github.com/zitadel/zitadel/internal/repository/org" "github.com/zitadel/zitadel/internal/repository/project" "github.com/zitadel/zitadel/internal/repository/quota" + "github.com/zitadel/zitadel/internal/repository/restrictions" "github.com/zitadel/zitadel/internal/repository/user" ) @@ -115,10 +116,9 @@ type InstanceSetup struct { Quotas *struct { Items []*SetQuota } - Features map[domain.Feature]any - Limits *struct { - AuditLogRetention *time.Duration - } + Features map[domain.Feature]any + Limits *SetLimits + Restrictions *SetRestrictions } type SecretGenerators struct { @@ -135,12 +135,13 @@ type SecretGenerators struct { } type ZitadelConfig struct { - projectID string - mgmtAppID string - adminAppID string - authAppID string - consoleAppID string - limitsID string + projectID string + mgmtAppID string + adminAppID string + authAppID string + consoleAppID string + limitsID string + restrictionsID string } func (s *InstanceSetup) generateIDs(idGenerator id.Generator) (err error) { @@ -169,6 +170,10 @@ func (s *InstanceSetup) generateIDs(idGenerator id.Generator) (err error) { return err } s.zitadel.limitsID, err = idGenerator.Next() + if err != nil { + return err + } + s.zitadel.restrictionsID, err = idGenerator.Next() return err } @@ -200,6 +205,7 @@ func (c *Commands) SetUpInstance(ctx context.Context, setup *InstanceSetup) (str userAgg := user.NewAggregate(userID, orgID) projectAgg := project.NewAggregate(setup.zitadel.projectID, orgID) limitsAgg := limits.NewAggregate(setup.zitadel.limitsID, instanceID, instanceID) + restrictionsAgg := restrictions.NewAggregate(setup.zitadel.restrictionsID, instanceID, instanceID) validations := []preparation.Validation{ prepareAddInstance(instanceAgg, setup.InstanceName, setup.DefaultLanguage), @@ -453,9 +459,11 @@ func (c *Commands) SetUpInstance(ctx context.Context, setup *InstanceSetup) (str } if setup.Limits != nil { - validations = append(validations, c.SetLimitsCommand(limitsAgg, &limitsWriteModel{}, &SetLimits{ - AuditLogRetention: setup.Limits.AuditLogRetention, - })) + validations = append(validations, c.SetLimitsCommand(limitsAgg, &limitsWriteModel{}, setup.Limits)) + } + + if setup.Restrictions != nil { + validations = append(validations, c.SetRestrictionsCommand(restrictionsAgg, &restrictionsWriteModel{}, setup.Restrictions)) } cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, validations...) diff --git a/internal/command/limits.go b/internal/command/limits.go index 5c7cdd8ee..ca4fb0be4 100644 --- a/internal/command/limits.go +++ b/internal/command/limits.go @@ -13,7 +13,7 @@ import ( ) type SetLimits struct { - AuditLogRetention *time.Duration `json:"AuditLogRetention,omitempty"` + AuditLogRetention *time.Duration } // SetLimits creates new limits or updates existing limits. @@ -34,14 +34,14 @@ func (c *Commands) SetLimits( return nil, err } } - if err != nil { - return nil, err - } createCmds, err := c.SetLimitsCommand(limits.NewAggregate(aggregateId, instanceId, resourceOwner), wm, setLimits)() if err != nil { return nil, err } cmds, err := createCmds(ctx, nil) + if err != nil { + return nil, err + } if len(cmds) > 0 { events, err := c.eventstore.Push(ctx, cmds...) if err != nil { diff --git a/internal/command/main_test.go b/internal/command/main_test.go index 341665de5..d01321b19 100644 --- a/internal/command/main_test.go +++ b/internal/command/main_test.go @@ -29,6 +29,7 @@ import ( "github.com/zitadel/zitadel/internal/repository/org" proj_repo "github.com/zitadel/zitadel/internal/repository/project" quota_repo "github.com/zitadel/zitadel/internal/repository/quota" + "github.com/zitadel/zitadel/internal/repository/restrictions" "github.com/zitadel/zitadel/internal/repository/session" usr_repo "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/repository/usergrant" @@ -60,6 +61,7 @@ func eventstoreExpect(t *testing.T, expects ...expect) *eventstore.Eventstore { oidcsession.RegisterEventMappers(es) quota_repo.RegisterEventMappers(es) limits.RegisterEventMappers(es) + restrictions.RegisterEventMappers(es) feature.RegisterEventMappers(es) return es } diff --git a/internal/command/restrictions.go b/internal/command/restrictions.go new file mode 100644 index 000000000..1658c9959 --- /dev/null +++ b/internal/command/restrictions.go @@ -0,0 +1,81 @@ +package command + +import ( + "context" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/command/preparation" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/restrictions" +) + +type SetRestrictions struct { + DisallowPublicOrgRegistration *bool +} + +// SetRestrictions creates new restrictions or updates existing restrictions. +func (c *Commands) SetInstanceRestrictions( + ctx context.Context, + setRestrictions *SetRestrictions, +) (*domain.ObjectDetails, error) { + instanceId := authz.GetInstance(ctx).InstanceID() + wm, err := c.getRestrictionsWriteModel(ctx, instanceId, instanceId) + if err != nil { + return nil, err + } + aggregateId := wm.AggregateID + if aggregateId == "" { + aggregateId, err = c.idGenerator.Next() + if err != nil { + return nil, err + } + } + setCmd, err := c.SetRestrictionsCommand(restrictions.NewAggregate(aggregateId, instanceId, instanceId), wm, setRestrictions)() + if err != nil { + return nil, err + } + cmds, err := setCmd(ctx, nil) + if err != nil { + return nil, err + } + if len(cmds) > 0 { + events, err := c.eventstore.Push(ctx, cmds...) + if err != nil { + return nil, err + } + err = AppendAndReduce(wm, events...) + if err != nil { + return nil, err + } + } + return writeModelToObjectDetails(&wm.WriteModel), nil +} + +func (c *Commands) getRestrictionsWriteModel(ctx context.Context, instanceId, resourceOwner string) (*restrictionsWriteModel, error) { + wm := newRestrictionsWriteModel(instanceId, resourceOwner) + return wm, c.eventstore.FilterToQueryReducer(ctx, wm) +} + +func (c *Commands) SetRestrictionsCommand(a *restrictions.Aggregate, wm *restrictionsWriteModel, setRestrictions *SetRestrictions) preparation.Validation { + return func() (preparation.CreateCommands, error) { + if setRestrictions == nil || setRestrictions.DisallowPublicOrgRegistration == nil { + return nil, errors.ThrowInvalidArgument(nil, "COMMAND-oASwj", "Errors.Restrictions.NoneSpecified") + } + return func(ctx context.Context, _ preparation.FilterToQueryReducer) ([]eventstore.Command, error) { + changes := wm.NewChanges(setRestrictions) + if len(changes) == 0 { + return nil, nil + } + return []eventstore.Command{restrictions.NewSetEvent( + eventstore.NewBaseEventForPush( + ctx, + &a.Aggregate, + restrictions.SetEventType, + ), + changes..., + )}, nil + }, nil + } +} diff --git a/internal/command/restrictions_model.go b/internal/command/restrictions_model.go new file mode 100644 index 000000000..cabf1981a --- /dev/null +++ b/internal/command/restrictions_model.go @@ -0,0 +1,55 @@ +package command + +import ( + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/restrictions" +) + +type restrictionsWriteModel struct { + eventstore.WriteModel + disallowPublicOrgRegistrations bool +} + +// newRestrictionsWriteModel aggregateId is filled by reducing unit matching events +func newRestrictionsWriteModel(instanceId, resourceOwner string) *restrictionsWriteModel { + return &restrictionsWriteModel{ + WriteModel: eventstore.WriteModel{ + InstanceID: instanceId, + ResourceOwner: resourceOwner, + }, + } +} + +func (wm *restrictionsWriteModel) Query() *eventstore.SearchQueryBuilder { + query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + ResourceOwner(wm.ResourceOwner). + InstanceID(wm.InstanceID). + AddQuery(). + AggregateTypes(restrictions.AggregateType). + EventTypes(restrictions.SetEventType) + + return query.Builder() +} + +func (wm *restrictionsWriteModel) Reduce() error { + for _, event := range wm.Events { + wm.ChangeDate = event.CreatedAt() + if e, ok := event.(*restrictions.SetEvent); ok && e.DisallowPublicOrgRegistrations != nil { + wm.disallowPublicOrgRegistrations = *e.DisallowPublicOrgRegistrations + } + } + return wm.WriteModel.Reduce() +} + +// NewChanges returns all changes that need to be applied to the aggregate. +// nil properties in setRestrictions are ignored +func (wm *restrictionsWriteModel) NewChanges(setRestrictions *SetRestrictions) (changes []restrictions.RestrictionsChange) { + if setRestrictions == nil { + return nil + } + changes = make([]restrictions.RestrictionsChange, 0, 1) + if setRestrictions.DisallowPublicOrgRegistration != nil && (wm.disallowPublicOrgRegistrations != *setRestrictions.DisallowPublicOrgRegistration) { + changes = append(changes, restrictions.ChangePublicOrgRegistrations(*setRestrictions.DisallowPublicOrgRegistration)) + } + return changes +} diff --git a/internal/command/restrictions_test.go b/internal/command/restrictions_test.go new file mode 100644 index 000000000..1fcefb806 --- /dev/null +++ b/internal/command/restrictions_test.go @@ -0,0 +1,189 @@ +package command + +import ( + "context" + "testing" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/domain" + zitadel_errs "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/id" + id_mock "github.com/zitadel/zitadel/internal/id/mock" + "github.com/zitadel/zitadel/internal/repository/restrictions" +) + +func TestSetRestrictions(t *testing.T) { + type fields func(*testing.T) (*eventstore.Eventstore, id.Generator) + type args struct { + ctx context.Context + setRestrictions *SetRestrictions + } + type res struct { + want *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "set new restrictions", + fields: func(*testing.T) (*eventstore.Eventstore, id.Generator) { + return eventstoreExpect( + t, + expectFilter(), + expectPush( + eventFromEventPusherWithInstanceID( + "instance1", + restrictions.NewSetEvent( + eventstore.NewBaseEventForPush( + context.Background(), + &restrictions.NewAggregate("restrictions1", "instance1", "instance1").Aggregate, + restrictions.SetEventType, + ), + restrictions.ChangePublicOrgRegistrations(true), + ), + ), + ), + ), + id_mock.NewIDGeneratorExpectIDs(t, "restrictions1") + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "instance1"), + setRestrictions: &SetRestrictions{ + DisallowPublicOrgRegistration: gu.Ptr(true), + }, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "instance1", + }, + }, + }, + { + name: "change restrictions", + fields: func(*testing.T) (*eventstore.Eventstore, id.Generator) { + return eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + restrictions.NewSetEvent( + eventstore.NewBaseEventForPush( + context.Background(), + &restrictions.NewAggregate("restrictions1", "instance1", "instance1").Aggregate, + restrictions.SetEventType, + ), + restrictions.ChangePublicOrgRegistrations(true), + ), + ), + ), + expectPush( + eventFromEventPusherWithInstanceID( + "instance1", + restrictions.NewSetEvent( + eventstore.NewBaseEventForPush( + context.Background(), + &restrictions.NewAggregate("restrictions1", "instance1", "instance1").Aggregate, + restrictions.SetEventType, + ), + restrictions.ChangePublicOrgRegistrations(false), + ), + ), + ), + ), + nil + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "instance1"), + setRestrictions: &SetRestrictions{ + DisallowPublicOrgRegistration: gu.Ptr(false), + }, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "instance1", + }, + }, + }, + { + name: "set restrictions idempotency", + fields: func(*testing.T) (*eventstore.Eventstore, id.Generator) { + return eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + restrictions.NewSetEvent( + eventstore.NewBaseEventForPush( + context.Background(), + &restrictions.NewAggregate("restrictions1", "instance1", "instance1").Aggregate, + restrictions.SetEventType, + ), + restrictions.ChangePublicOrgRegistrations(true), + ), + ), + ), + ), + nil + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "instance1"), + setRestrictions: &SetRestrictions{ + DisallowPublicOrgRegistration: gu.Ptr(true), + }, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "instance1", + }, + }, + }, + { + name: "no restrictions defined", + fields: func(*testing.T) (*eventstore.Eventstore, id.Generator) { + return eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + restrictions.NewSetEvent( + eventstore.NewBaseEventForPush( + context.Background(), + &restrictions.NewAggregate("restrictions1", "instance1", "instance1").Aggregate, + restrictions.SetEventType, + ), + restrictions.ChangePublicOrgRegistrations(true), + ), + ), + ), + ), nil + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "instance1"), + setRestrictions: &SetRestrictions{}, + }, + res: res{ + err: zitadel_errs.IsErrorInvalidArgument, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := new(Commands) + r.eventstore, r.idGenerator = tt.fields(t) + got, err := r.SetInstanceRestrictions(tt.args.ctx, tt.args.setRestrictions) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + } + if tt.res.err == nil { + assert.Equal(t, tt.res.want, got) + } + }) + } +} diff --git a/internal/eventstore/handler/v2/statement.go b/internal/eventstore/handler/v2/statement.go index bea3c156a..a167a3699 100644 --- a/internal/eventstore/handler/v2/statement.go +++ b/internal/eventstore/handler/v2/statement.go @@ -119,7 +119,7 @@ func NewUpsertStatement(event eventstore.Event, conflictCols []Column, values [] config.err = ErrNoValues } - updateCols, updateVals := getUpdateCols(cols, conflictTarget) + updateCols, updateVals := getUpdateCols(values, conflictTarget) if len(updateCols) == 0 || len(updateVals) == 0 { config.err = ErrNoValues } @@ -141,17 +141,38 @@ func NewUpsertStatement(event eventstore.Event, conflictCols []Column, values [] return NewStatement(event, exec(config, q, opts)) } -func getUpdateCols(cols, conflictTarget []string) (updateCols, updateVals []string) { +var _ ValueContainer = (*onlySetValueOnInsert)(nil) + +type onlySetValueOnInsert struct { + Table string + Value interface{} +} + +func (c *onlySetValueOnInsert) GetValue() interface{} { + return c.Value +} + +func OnlySetValueOnInsert(table string, value interface{}) *onlySetValueOnInsert { + return &onlySetValueOnInsert{ + Table: table, + Value: value, + } +} + +func getUpdateCols(cols []Column, conflictTarget []string) (updateCols, updateVals []string) { updateCols = make([]string, len(cols)) updateVals = make([]string, len(cols)) - copy(updateCols, cols) - - for i := len(updateCols) - 1; i >= 0; i-- { - updateVals[i] = "EXCLUDED." + updateCols[i] - + for i := len(cols) - 1; i >= 0; i-- { + col := cols[i] + table := "EXCLUDED" + if onlyOnInsert, ok := col.Value.(*onlySetValueOnInsert); ok { + table = onlyOnInsert.Table + } + updateCols[i] = col.Name + updateVals[i] = table + "." + col.Name for _, conflict := range conflictTarget { - if conflict == updateCols[i] { + if conflict == col.Name { copy(updateCols[i:], updateCols[i+1:]) updateCols[len(updateCols)-1] = "" updateCols = updateCols[:len(updateCols)-1] @@ -383,6 +404,10 @@ func NewCopyStatement(event eventstore.Event, conflictCols, from, to []Column, n return NewStatement(event, exec(config, q, opts)) } +type ValueContainer interface { + GetValue() interface{} +} + func columnsToQuery(cols []Column) (names []string, parameters []string, values []interface{}) { names = make([]string, len(cols)) values = make([]interface{}, len(cols)) @@ -390,10 +415,13 @@ func columnsToQuery(cols []Column) (names []string, parameters []string, values var parameterIndex int for i, col := range cols { names[i] = col.Name - if c, ok := col.Value.(Column); ok { + switch c := col.Value.(type) { + case Column: parameters[i] = c.Name continue - } else { + case ValueContainer: + values[parameterIndex] = c.GetValue() + default: values[parameterIndex] = col.Value } parameters[i] = "$" + strconv.Itoa(parameterIndex+1) diff --git a/internal/eventstore/handler/v2/statement_test.go b/internal/eventstore/handler/v2/statement_test.go index 940da53b2..a2bae8b53 100644 --- a/internal/eventstore/handler/v2/statement_test.go +++ b/internal/eventstore/handler/v2/statement_test.go @@ -358,7 +358,7 @@ func TestNewUpsertStatement(t *testing.T) { }, }, { - name: "correct UPDATE single col", + name: "correct *onlySetValueOnInsert", args: args{ table: "my_table", event: &testEvent{ @@ -372,11 +372,18 @@ func TestNewUpsertStatement(t *testing.T) { values: []Column{ { Name: "col1", - Value: "val", + Value: "val1", }, { Name: "col2", - Value: "val", + Value: "val2", + }, + { + Name: "col3", + Value: &onlySetValueOnInsert{ + Table: "some.table", + Value: "val3", + }, }, }, }, @@ -388,8 +395,53 @@ func TestNewUpsertStatement(t *testing.T) { executer: &wantExecuter{ params: []params{ { - query: "INSERT INTO my_table (col1, col2) VALUES ($1, $2) ON CONFLICT (col1) DO UPDATE SET col2 = EXCLUDED.col2", - args: []interface{}{"val", "val"}, + query: "INSERT INTO my_table (col1, col2, col3) VALUES ($1, $2, $3) ON CONFLICT (col1) DO UPDATE SET (col2, col3) = (EXCLUDED.col2, some.table.col3)", + args: []interface{}{"val1", "val2", "val3"}, + }, + }, + shouldExecute: true, + }, + isErr: func(err error) bool { + return err == nil + }, + }, + }, + { + name: "correct all *onlySetValueOnInsert", + args: args{ + table: "my_table", + event: &testEvent{ + aggregateType: "agg", + sequence: 1, + previousSequence: 0, + }, + conflictCols: []Column{ + NewCol("col1", nil), + }, + values: []Column{ + { + Name: "col1", + Value: "val1", + }, + { + Name: "col2", + Value: &onlySetValueOnInsert{ + Table: "some.table", + Value: "val2", + }, + }, + }, + }, + want: want{ + table: "my_table", + aggregateType: "agg", + sequence: 1, + previousSequence: 1, + executer: &wantExecuter{ + params: []params{ + { + query: "INSERT INTO my_table (col1, col2) VALUES ($1, $2) ON CONFLICT (col1) DO UPDATE SET col2 = some.table.col2", + args: []interface{}{"val1", "val2"}, }, }, shouldExecute: true, diff --git a/internal/integration/client.go b/internal/integration/client.go index dadf60e7e..7e64b1170 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -59,7 +59,7 @@ func newClient(cc *grpc.ClientConn) Client { } func (t *Tester) UseIsolatedInstance(iamOwnerCtx, systemCtx context.Context) (primaryDomain, instanceId string, authenticatedIamOwnerCtx context.Context) { - primaryDomain = randString(5) + ".integration" + primaryDomain = randString(5) + ".integration.localhost" instance, err := t.Client.System.CreateInstance(systemCtx, &system.CreateInstanceRequest{ InstanceName: "testinstance", CustomDomain: primaryDomain, @@ -74,7 +74,7 @@ func (t *Tester) UseIsolatedInstance(iamOwnerCtx, systemCtx context.Context) (pr if err != nil { panic(err) } - t.createClientConn(iamOwnerCtx, grpc.WithAuthority(primaryDomain)) + t.createClientConn(iamOwnerCtx, fmt.Sprintf("%s:%d", primaryDomain, t.Config.Port)) instanceId = instance.GetInstanceId() t.Users.Set(instanceId, IAMOwner, &User{ Token: instance.GetPat(), diff --git a/internal/integration/integration.go b/internal/integration/integration.go index 15cffe43b..3d794a10d 100644 --- a/internal/integration/integration.go +++ b/internal/integration/integration.go @@ -127,12 +127,11 @@ func (s *Tester) Host() string { return fmt.Sprintf("%s:%d", s.Config.ExternalDomain, s.Config.Port) } -func (s *Tester) createClientConn(ctx context.Context, opts ...grpc.DialOption) { - target := s.Host() - cc, err := grpc.DialContext(ctx, target, append(opts, +func (s *Tester) createClientConn(ctx context.Context, target string) { + cc, err := grpc.DialContext(ctx, target, grpc.WithBlock(), grpc.WithTransportCredentials(insecure.NewCredentials()), - )...) + ) if err != nil { s.Shutdown <- os.Interrupt s.wg.Wait() @@ -346,9 +345,10 @@ func NewTester(ctx context.Context) *Tester { case <-ctx.Done(): logging.OnError(ctx.Err()).Fatal("waiting for integration tester server") } - tester.createClientConn(ctx) + host := tester.Host() + tester.createClientConn(ctx, host) tester.createLoginClient(ctx) - tester.WebAuthN = webauthn.NewClient(tester.Config.WebAuthNName, tester.Config.ExternalDomain, http_util.BuildOrigin(tester.Host(), tester.Config.ExternalSecure)) + tester.WebAuthN = webauthn.NewClient(tester.Config.WebAuthNName, tester.Config.ExternalDomain, http_util.BuildOrigin(host, tester.Config.ExternalSecure)) tester.createMachineUserOrgOwner(ctx) tester.createMachineUserInstanceOwner(ctx) tester.WebAuthN = webauthn.NewClient(tester.Config.WebAuthNName, tester.Config.ExternalDomain, "https://"+tester.Host()) diff --git a/internal/query/projection/main_test.go b/internal/query/projection/main_test.go deleted file mode 100644 index 460e8f07d..000000000 --- a/internal/query/projection/main_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package projection - -import ( - "database/sql" - "testing" - "time" - - "github.com/zitadel/zitadel/internal/eventstore" - "github.com/zitadel/zitadel/internal/eventstore/repository" - "github.com/zitadel/zitadel/internal/eventstore/repository/mock" - action_repo "github.com/zitadel/zitadel/internal/repository/action" - iam_repo "github.com/zitadel/zitadel/internal/repository/instance" - key_repo "github.com/zitadel/zitadel/internal/repository/keypair" - "github.com/zitadel/zitadel/internal/repository/limits" - "github.com/zitadel/zitadel/internal/repository/org" - proj_repo "github.com/zitadel/zitadel/internal/repository/project" - quota_repo "github.com/zitadel/zitadel/internal/repository/quota" - usr_repo "github.com/zitadel/zitadel/internal/repository/user" - "github.com/zitadel/zitadel/internal/repository/usergrant" -) - -type expect func(mockRepository *mock.MockRepository) - -func eventstoreExpect(t *testing.T, expects ...expect) *eventstore.Eventstore { - m := mock.NewRepo(t) - for _, e := range expects { - e(m) - } - es := eventstore.NewEventstore( - &eventstore.Config{ - Querier: m.MockQuerier, - Pusher: m.MockPusher, - }, - ) - iam_repo.RegisterEventMappers(es) - org.RegisterEventMappers(es) - usr_repo.RegisterEventMappers(es) - proj_repo.RegisterEventMappers(es) - quota_repo.RegisterEventMappers(es) - limits.RegisterEventMappers(es) - usergrant.RegisterEventMappers(es) - key_repo.RegisterEventMappers(es) - action_repo.RegisterEventMappers(es) - return es -} - -func eventFromEventPusher(event eventstore.Command) *repository.Event { - data, _ := eventstore.EventData(event) - return &repository.Event{ - ID: "", - Seq: 0, - CreationDate: time.Time{}, - Typ: event.Type(), - Data: data, - EditorUser: event.Creator(), - Version: event.Aggregate().Version, - AggregateID: event.Aggregate().ID, - AggregateType: event.Aggregate().Type, - ResourceOwner: sql.NullString{String: event.Aggregate().ResourceOwner, Valid: event.Aggregate().ResourceOwner != ""}, - } -} diff --git a/internal/query/projection/projection.go b/internal/query/projection/projection.go index c95c60c6d..055aaa81a 100644 --- a/internal/query/projection/projection.go +++ b/internal/query/projection/projection.go @@ -70,6 +70,7 @@ var ( MilestoneProjection *handler.Handler QuotaProjection *quotaProjection LimitsProjection *handler.Handler + RestrictionsProjection *handler.Handler ) type projection interface { @@ -143,6 +144,7 @@ func Create(ctx context.Context, sqlClient *database.DB, es handler.EventStore, MilestoneProjection = newMilestoneProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["milestones"]), systemUsers) QuotaProjection = newQuotaProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["quotas"])) LimitsProjection = newLimitsProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["limits"])) + RestrictionsProjection = newRestrictionsProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["restrictions"])) newProjectionsList() return nil } @@ -247,5 +249,6 @@ func newProjectionsList() { MilestoneProjection, QuotaProjection.handler, LimitsProjection, + RestrictionsProjection, } } diff --git a/internal/query/projection/restrictions.go b/internal/query/projection/restrictions.go new file mode 100644 index 000000000..597a6fac6 --- /dev/null +++ b/internal/query/projection/restrictions.go @@ -0,0 +1,96 @@ +package projection + +import ( + "context" + + "github.com/zitadel/zitadel/internal/eventstore" + old_handler "github.com/zitadel/zitadel/internal/eventstore/handler" + "github.com/zitadel/zitadel/internal/eventstore/handler/v2" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/restrictions" +) + +const ( + RestrictionsProjectionTable = "projections.restrictions" + + RestrictionsColumnAggregateID = "aggregate_id" + RestrictionsColumnCreationDate = "creation_date" + RestrictionsColumnChangeDate = "change_date" + RestrictionsColumnResourceOwner = "resource_owner" + RestrictionsColumnInstanceID = "instance_id" + RestrictionsColumnSequence = "sequence" + + RestrictionsColumnDisallowPublicOrgRegistration = "disallow_public_org_registration" +) + +type restrictionsProjection struct{} + +func newRestrictionsProjection(ctx context.Context, config handler.Config) *handler.Handler { + return handler.NewHandler(ctx, &config, &restrictionsProjection{}) +} + +func (*restrictionsProjection) Name() string { + return RestrictionsProjectionTable +} + +func (*restrictionsProjection) Init() *old_handler.Check { + return handler.NewTableCheck( + handler.NewTable([]*handler.InitColumn{ + handler.NewColumn(RestrictionsColumnAggregateID, handler.ColumnTypeText), + handler.NewColumn(RestrictionsColumnCreationDate, handler.ColumnTypeTimestamp), + handler.NewColumn(RestrictionsColumnChangeDate, handler.ColumnTypeTimestamp), + handler.NewColumn(RestrictionsColumnResourceOwner, handler.ColumnTypeText), + handler.NewColumn(RestrictionsColumnInstanceID, handler.ColumnTypeText), + handler.NewColumn(RestrictionsColumnSequence, handler.ColumnTypeInt64), + handler.NewColumn(RestrictionsColumnDisallowPublicOrgRegistration, handler.ColumnTypeBool), + }, + handler.NewPrimaryKey(RestrictionsColumnInstanceID, RestrictionsColumnResourceOwner), + ), + ) +} + +func (p *restrictionsProjection) Reducers() []handler.AggregateReducer { + return []handler.AggregateReducer{ + { + Aggregate: restrictions.AggregateType, + EventReducers: []handler.EventReducer{ + { + Event: restrictions.SetEventType, + Reduce: p.reduceRestrictionsSet, + }, + }, + }, + { + Aggregate: instance.AggregateType, + EventReducers: []handler.EventReducer{ + { + Event: instance.InstanceRemovedEventType, + Reduce: reduceInstanceRemovedHelper(RestrictionsColumnInstanceID), + }, + }, + }, + } +} + +func (p *restrictionsProjection) reduceRestrictionsSet(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*restrictions.SetEvent](event) + if err != nil { + return nil, err + } + conflictCols := []handler.Column{ + handler.NewCol(RestrictionsColumnInstanceID, e.Aggregate().InstanceID), + handler.NewCol(RestrictionsColumnResourceOwner, e.Aggregate().ResourceOwner), + } + updateCols := []handler.Column{ + handler.NewCol(RestrictionsColumnInstanceID, e.Aggregate().InstanceID), + handler.NewCol(RestrictionsColumnResourceOwner, e.Aggregate().ResourceOwner), + handler.NewCol(RestrictionsColumnCreationDate, handler.OnlySetValueOnInsert(RestrictionsProjectionTable, e.CreationDate())), + handler.NewCol(RestrictionsColumnChangeDate, e.CreationDate()), + handler.NewCol(RestrictionsColumnSequence, e.Sequence()), + handler.NewCol(RestrictionsColumnAggregateID, e.Aggregate().ID), + } + if e.DisallowPublicOrgRegistrations != nil { + updateCols = append(updateCols, handler.NewCol(RestrictionsColumnDisallowPublicOrgRegistration, *e.DisallowPublicOrgRegistrations)) + } + return handler.NewUpsertStatement(e, conflictCols, updateCols), nil +} diff --git a/internal/query/projection/restrictions_test.go b/internal/query/projection/restrictions_test.go new file mode 100644 index 000000000..f018ab00c --- /dev/null +++ b/internal/query/projection/restrictions_test.go @@ -0,0 +1,96 @@ +package projection + +import ( + "testing" + + "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/handler/v2" + "github.com/zitadel/zitadel/internal/repository/restrictions" +) + +func TestRestrictionsProjection_reduces(t *testing.T) { + type args struct { + event func(t *testing.T) eventstore.Event + } + tests := []struct { + name string + args args + reduce func(event eventstore.Event) (*handler.Statement, error) + want wantReduce + }{ + { + name: "reduceRestrictionsSet should update defined", + args: args{ + event: getEvent(testEvent( + restrictions.SetEventType, + restrictions.AggregateType, + []byte(`{ "disallowPublicOrgRegistrations": true }`), + ), restrictions.SetEventMapper), + }, + reduce: (&restrictionsProjection{}).reduceRestrictionsSet, + want: wantReduce{ + aggregateType: eventstore.AggregateType("restrictions"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "INSERT INTO projections.restrictions (instance_id, resource_owner, creation_date, change_date, sequence, aggregate_id, disallow_public_org_registration) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (instance_id, resource_owner) DO UPDATE SET (creation_date, change_date, sequence, aggregate_id, disallow_public_org_registration) = (projections.restrictions.creation_date, EXCLUDED.change_date, EXCLUDED.sequence, EXCLUDED.aggregate_id, EXCLUDED.disallow_public_org_registration)", + expectedArgs: []interface{}{ + "instance-id", + "ro-id", + anyArg{}, + anyArg{}, + uint64(15), + "agg-id", + true, + }, + }, + }, + }, + }, + }, + { + name: "reduceRestrictionsSet shouldn't update undefined", + args: args{ + event: getEvent(testEvent( + restrictions.SetEventType, + restrictions.AggregateType, + []byte(`{}`), + ), restrictions.SetEventMapper), + }, + reduce: (&restrictionsProjection{}).reduceRestrictionsSet, + want: wantReduce{ + aggregateType: eventstore.AggregateType("restrictions"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "INSERT INTO projections.restrictions (instance_id, resource_owner, creation_date, change_date, sequence, aggregate_id) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (instance_id, resource_owner) DO UPDATE SET (creation_date, change_date, sequence, aggregate_id) = (projections.restrictions.creation_date, EXCLUDED.change_date, EXCLUDED.sequence, EXCLUDED.aggregate_id)", + expectedArgs: []interface{}{ + "instance-id", + "ro-id", + anyArg{}, + anyArg{}, + uint64(15), + "agg-id", + }, + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event := baseEvent(t) + got, err := tt.reduce(event) + if !errors.IsErrorInvalidArgument(err) { + t.Errorf("no wrong event mapping: %v, got: %v", err, got) + } + event = tt.args.event(t) + got, err = tt.reduce(event) + assertReduce(t, got, err, RestrictionsProjectionTable, tt.want) + }) + } +} diff --git a/internal/query/query.go b/internal/query/query.go index 70d672308..69cb42284 100644 --- a/internal/query/query.go +++ b/internal/query/query.go @@ -30,6 +30,7 @@ import ( "github.com/zitadel/zitadel/internal/repository/org" "github.com/zitadel/zitadel/internal/repository/project" "github.com/zitadel/zitadel/internal/repository/quota" + "github.com/zitadel/zitadel/internal/repository/restrictions" "github.com/zitadel/zitadel/internal/repository/session" usr_repo "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/repository/usergrant" @@ -113,6 +114,7 @@ func StartQueries( oidcsession.RegisterEventMappers(repo.eventstore) quota.RegisterEventMappers(repo.eventstore) limits.RegisterEventMappers(repo.eventstore) + restrictions.RegisterEventMappers(repo.eventstore) repo.checkPermission = permissionCheck(repo) diff --git a/internal/query/restrictions.go b/internal/query/restrictions.go new file mode 100644 index 000000000..80217164a --- /dev/null +++ b/internal/query/restrictions.go @@ -0,0 +1,108 @@ +package query + +import ( + "context" + "database/sql" + "errors" + "time" + + sq "github.com/Masterminds/squirrel" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/call" + zitade_errors "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/query/projection" + "github.com/zitadel/zitadel/internal/telemetry/tracing" +) + +var ( + restrictionsTable = table{ + name: projection.RestrictionsProjectionTable, + instanceIDCol: projection.RestrictionsColumnInstanceID, + } + RestrictionsColumnAggregateID = Column{ + name: projection.RestrictionsColumnAggregateID, + table: restrictionsTable, + } + RestrictionsColumnCreationDate = Column{ + name: projection.RestrictionsColumnCreationDate, + table: restrictionsTable, + } + RestrictionsColumnChangeDate = Column{ + name: projection.RestrictionsColumnChangeDate, + table: restrictionsTable, + } + RestrictionsColumnResourceOwner = Column{ + name: projection.RestrictionsColumnResourceOwner, + table: restrictionsTable, + } + RestrictionsColumnInstanceID = Column{ + name: projection.RestrictionsColumnInstanceID, + table: restrictionsTable, + } + RestrictionsColumnSequence = Column{ + name: projection.RestrictionsColumnSequence, + table: restrictionsTable, + } + RestrictionsColumnDisallowPublicOrgRegistrations = Column{ + name: projection.RestrictionsColumnDisallowPublicOrgRegistration, + table: restrictionsTable, + } +) + +type Restrictions struct { + AggregateID string + CreationDate time.Time + ChangeDate time.Time + ResourceOwner string + Sequence uint64 + + DisallowPublicOrgRegistration bool +} + +func (q *Queries) GetInstanceRestrictions(ctx context.Context) (restrictions Restrictions, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + stmt, scan := prepareRestrictionsQuery(ctx, q.client) + instanceID := authz.GetInstance(ctx).InstanceID() + query, args, err := stmt.Where(sq.Eq{ + RestrictionsColumnInstanceID.identifier(): instanceID, + RestrictionsColumnResourceOwner.identifier(): instanceID, + }).ToSql() + if err != nil { + return restrictions, zitade_errors.ThrowInternal(err, "QUERY-XnLMQ", "Errors.Query.SQLStatment") + } + err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { + restrictions, err = scan(row) + return err + }, query, args...) + if errors.Is(err, sql.ErrNoRows) { + // not found is not an error + err = nil + } + return restrictions, err +} + +func prepareRestrictionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (Restrictions, error)) { + return sq.Select( + RestrictionsColumnAggregateID.identifier(), + RestrictionsColumnCreationDate.identifier(), + RestrictionsColumnChangeDate.identifier(), + RestrictionsColumnResourceOwner.identifier(), + RestrictionsColumnSequence.identifier(), + RestrictionsColumnDisallowPublicOrgRegistrations.identifier(), + ). + From(restrictionsTable.identifier() + db.Timetravel(call.Took(ctx))). + PlaceholderFormat(sq.Dollar), + func(row *sql.Row) (restrictions Restrictions, err error) { + return restrictions, row.Scan( + &restrictions.AggregateID, + &restrictions.CreationDate, + &restrictions.ChangeDate, + &restrictions.ResourceOwner, + &restrictions.Sequence, + &restrictions.DisallowPublicOrgRegistration, + ) + } +} diff --git a/internal/query/restrictions_test.go b/internal/query/restrictions_test.go new file mode 100644 index 000000000..83e6d9a8f --- /dev/null +++ b/internal/query/restrictions_test.go @@ -0,0 +1,111 @@ +package query + +import ( + "database/sql" + "database/sql/driver" + "errors" + "fmt" + "regexp" + "testing" +) + +var ( + expectedRestrictionsQuery = regexp.QuoteMeta("SELECT projections.restrictions.aggregate_id," + + " projections.restrictions.creation_date," + + " projections.restrictions.change_date," + + " projections.restrictions.resource_owner," + + " projections.restrictions.sequence," + + " projections.restrictions.disallow_public_org_registration" + + " FROM projections.restrictions" + + " AS OF SYSTEM TIME '-1 ms'", + ) + + restrictionsCols = []string{ + "aggregate_id", + "creation_date", + "change_date", + "resource_owner", + "sequence", + "disallow_public_org_registration", + } +) + +func Test_RestrictionsPrepare(t *testing.T) { + type want struct { + sqlExpectations sqlExpectation + err checkErr + object interface{} + } + tests := []struct { + name string + prepare interface{} + want want + }{ + { + name: "prepareRestrictionsQuery no result", + prepare: prepareRestrictionsQuery, + want: want{ + sqlExpectations: mockQueryScanErr( + expectedRestrictionsQuery, + nil, + nil, + ), + err: func(err error) (error, bool) { + if !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("err should be sql.ErrNoRows got: %w", err), false + } + return nil, true + }, + object: Restrictions{}, + }, + }, + { + name: "prepareRestrictionsQuery", + prepare: prepareRestrictionsQuery, + want: want{ + sqlExpectations: mockQuery( + expectedRestrictionsQuery, + restrictionsCols, + []driver.Value{ + "restrictions1", + testNow, + testNow, + "instance1", + 0, + true, + }, + ), + object: Restrictions{ + AggregateID: "restrictions1", + CreationDate: testNow, + ChangeDate: testNow, + ResourceOwner: "instance1", + Sequence: 0, + DisallowPublicOrgRegistration: true, + }, + }, + }, + { + name: "prepareRestrictionsQuery sql err", + prepare: prepareRestrictionsQuery, + want: want{ + sqlExpectations: mockQueryErr( + expectedRestrictionsQuery, + sql.ErrConnDone, + ), + err: func(err error) (error, bool) { + if !errors.Is(err, sql.ErrConnDone) { + return fmt.Errorf("err should be sql.ErrConnDone got: %w", err), false + } + return nil, true + }, + object: (*Restrictions)(nil), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertPrepare(t, tt.prepare, tt.want.object, tt.want.sqlExpectations, tt.want.err, defaultPrepareArgs...) + }) + } +} diff --git a/internal/repository/restrictions/aggregate.go b/internal/repository/restrictions/aggregate.go new file mode 100644 index 000000000..41efd98da --- /dev/null +++ b/internal/repository/restrictions/aggregate.go @@ -0,0 +1,26 @@ +package restrictions + +import ( + "github.com/zitadel/zitadel/internal/eventstore" +) + +const ( + AggregateType = "restrictions" + AggregateVersion = "v1" +) + +type Aggregate struct { + eventstore.Aggregate +} + +func NewAggregate(id, instanceId, resourceOwner string) *Aggregate { + return &Aggregate{ + Aggregate: eventstore.Aggregate{ + Type: AggregateType, + Version: AggregateVersion, + ID: id, + InstanceID: instanceId, + ResourceOwner: resourceOwner, + }, + } +} diff --git a/internal/repository/restrictions/events.go b/internal/repository/restrictions/events.go new file mode 100644 index 000000000..e15fd7c76 --- /dev/null +++ b/internal/repository/restrictions/events.go @@ -0,0 +1,53 @@ +package restrictions + +import ( + "github.com/muhlemmer/gu" + + "github.com/zitadel/zitadel/internal/eventstore" +) + +const ( + eventTypePrefix = eventstore.EventType("restrictions.") + SetEventType = eventTypePrefix + "set" +) + +// SetEvent describes that restrictions are added or modified and contains only changed properties +type SetEvent struct { + *eventstore.BaseEvent `json:"-"` + DisallowPublicOrgRegistrations *bool `json:"disallowPublicOrgRegistrations,omitempty"` +} + +func (e *SetEvent) Payload() any { + return e +} + +func (e *SetEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func (e *SetEvent) SetBaseEvent(b *eventstore.BaseEvent) { + e.BaseEvent = b +} + +func NewSetEvent( + base *eventstore.BaseEvent, + changes ...RestrictionsChange, +) *SetEvent { + changedEvent := &SetEvent{ + BaseEvent: base, + } + for _, change := range changes { + change(changedEvent) + } + return changedEvent +} + +type RestrictionsChange func(*SetEvent) + +func ChangePublicOrgRegistrations(disallow bool) RestrictionsChange { + return func(e *SetEvent) { + e.DisallowPublicOrgRegistrations = gu.Ptr(disallow) + } +} + +var SetEventMapper = eventstore.GenericEventMapper[SetEvent] diff --git a/internal/repository/restrictions/eventstore.go b/internal/repository/restrictions/eventstore.go new file mode 100644 index 000000000..b1633244d --- /dev/null +++ b/internal/repository/restrictions/eventstore.go @@ -0,0 +1,9 @@ +package restrictions + +import ( + "github.com/zitadel/zitadel/internal/eventstore" +) + +func RegisterEventMappers(es *eventstore.Eventstore) { + es.RegisterFilterEventMapper(AggregateType, SetEventType, SetEventMapper) +} diff --git a/internal/static/i18n/bg.yaml b/internal/static/i18n/bg.yaml index 90b236f78..ff7f86601 100644 --- a/internal/static/i18n/bg.yaml +++ b/internal/static/i18n/bg.yaml @@ -31,6 +31,8 @@ Errors: Limits: NotFound: Лимитът не е намерен NoneSpecified: Не са посочени лимити + Restrictions: + NoneSpecified: Не са посочени ограничения Language: NotParsed: Езикът не можа да бъде анализиран синтактично OIDCSettings: diff --git a/internal/static/i18n/cs.yaml b/internal/static/i18n/cs.yaml index 81dc605b7..4230dc774 100644 --- a/internal/static/i18n/cs.yaml +++ b/internal/static/i18n/cs.yaml @@ -31,6 +31,8 @@ Errors: Limits: NotFound: Limity nebyly nalezeny NoneSpecified: Nebyly určeny žádné limity + Restrictions: + NoneSpecified: Nebyla určena žádná omezení Language: NotParsed: Jazyk nelze určit OIDCSettings: diff --git a/internal/static/i18n/de.yaml b/internal/static/i18n/de.yaml index cfcaf9f5a..65cd5b116 100644 --- a/internal/static/i18n/de.yaml +++ b/internal/static/i18n/de.yaml @@ -31,6 +31,8 @@ Errors: Limits: NotFound: Limits konnten nicht gefunden werden NoneSpecified: Keine Limits angegeben + Restrictions: + NoneSpecified: Keine Restriktionen angegeben Language: NotParsed: Sprache konnte nicht gemapped werden OIDCSettings: diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index f993dde46..e3cdbcf2e 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -31,6 +31,8 @@ Errors: Limits: NotFound: Limits not found NoneSpecified: No limits specified + Restrictions: + NoneSpecified: No restrictions specified Language: NotParsed: Could not parse language OIDCSettings: diff --git a/internal/static/i18n/es.yaml b/internal/static/i18n/es.yaml index 4cb5da451..7a5d0ab29 100644 --- a/internal/static/i18n/es.yaml +++ b/internal/static/i18n/es.yaml @@ -31,6 +31,8 @@ Errors: Limits: NotFound: Límite no encontrado NoneSpecified: No se especificaron límites + Restrictions: + NoneSpecified: No se especificaron restricciones Language: NotParsed: No pude analizar el idioma OIDCSettings: diff --git a/internal/static/i18n/fr.yaml b/internal/static/i18n/fr.yaml index 423c91e35..43f8b8453 100644 --- a/internal/static/i18n/fr.yaml +++ b/internal/static/i18n/fr.yaml @@ -31,6 +31,8 @@ Errors: Limits: NotFound: Limites non trouvée NoneSpecified: Aucune limite spécifiée + Restrictions: + NoneSpecified: Aucune restriction spécifiée Language: NotParsed: Impossible d'analyser la langue OIDCSettings: diff --git a/internal/static/i18n/it.yaml b/internal/static/i18n/it.yaml index 282f20557..045a7b06b 100644 --- a/internal/static/i18n/it.yaml +++ b/internal/static/i18n/it.yaml @@ -31,6 +31,8 @@ Errors: Limits: NotFound: Limite non trovato NoneSpecified: Nessun limite specificato + Restrictions: + NoneSpecified: Nessuna restrizione specificata Language: NotParsed: Impossibile analizzare la lingua OIDCSettings: diff --git a/internal/static/i18n/ja.yaml b/internal/static/i18n/ja.yaml index a716f7c90..aa6e59198 100644 --- a/internal/static/i18n/ja.yaml +++ b/internal/static/i18n/ja.yaml @@ -31,6 +31,8 @@ Errors: Limits: NotFound: 制限が見つかりません NoneSpecified: 制限が指定されていません + Restrictions: + NoneSpecified: 制限が指定されていません Language: NotParsed: 言語のパースに失敗しました OIDCSettings: diff --git a/internal/static/i18n/mk.yaml b/internal/static/i18n/mk.yaml index ea33e18b9..a28190f64 100644 --- a/internal/static/i18n/mk.yaml +++ b/internal/static/i18n/mk.yaml @@ -31,6 +31,8 @@ Errors: Limits: NotFound: Лимитот не е пронајден NoneSpecified: Не се наведени лимити + Restrictions: + NoneSpecified: Не се наведени ограничувања Language: NotParsed: Јазикот не може да се парсира OIDCSettings: diff --git a/internal/static/i18n/pl.yaml b/internal/static/i18n/pl.yaml index d20da0d1e..2359c4a6d 100644 --- a/internal/static/i18n/pl.yaml +++ b/internal/static/i18n/pl.yaml @@ -31,6 +31,8 @@ Errors: Limits: NotFound: Limit nie znaleziony NoneSpecified: Nie określono limitów + Restrictions: + NoneSpecified: Nie określono ograniczeń Language: NotParsed: Nie można przeanalizować języka OIDCSettings: diff --git a/internal/static/i18n/pt.yaml b/internal/static/i18n/pt.yaml index 06035c93e..163318153 100644 --- a/internal/static/i18n/pt.yaml +++ b/internal/static/i18n/pt.yaml @@ -31,6 +31,8 @@ Errors: Limits: NotFound: Limite não encontrado NoneSpecified: Nenhum limite especificado + Restrictions: + NoneSpecified: Nenhuma restrição especificada Language: NotParsed: Não foi possível analisar o idioma OIDCSettings: diff --git a/internal/static/i18n/ru.yaml b/internal/static/i18n/ru.yaml index 792ce7b9c..21d8dd9a6 100644 --- a/internal/static/i18n/ru.yaml +++ b/internal/static/i18n/ru.yaml @@ -28,6 +28,11 @@ Errors: RemoveFailed: Объект не удалось удалить Limit: ExceedsDefault: Лимит превышает лимит по умолчанию + Limits: + NotFound: Лимиты не найдены + NoneSpecified: Не указаны лимиты + Restrictions: + NoneSpecified: Не указаны ограничения Language: NotParsed: Не удалось разобрать язык OIDCSettings: diff --git a/internal/static/i18n/zh.yaml b/internal/static/i18n/zh.yaml index a5cee656f..5b3ee8cab 100644 --- a/internal/static/i18n/zh.yaml +++ b/internal/static/i18n/zh.yaml @@ -31,6 +31,8 @@ Errors: Limits: NotFound: 未找到限制 NoneSpecified: 未指定限制 + Restrictions: + NoneSpecified: 未指定限制 Language: NotParsed: 无法解析语言 OIDCSettings: diff --git a/proto/zitadel/admin.proto b/proto/zitadel/admin.proto index fec52b103..448fcca30 100644 --- a/proto/zitadel/admin.proto +++ b/proto/zitadel/admin.proto @@ -3795,6 +3795,59 @@ service AdminService { description: "Returns a list of reached instance usage milestones." }; } + + // Sets restrictions + rpc SetRestrictions(SetRestrictionsRequest) returns (SetRestrictionsResponse) { + option (google.api.http) = { + put: "/restrictions" + body: "*" + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.restrictions.write"; + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: ["Feature Restrictions"]; + summary: "Restrict the instances features"; + description: "Undefined values don't change the current restriction. Zero values remove the current restriction."; + responses: { + key: "200"; + value: { + description: "Restrictions set."; + }; + }; + responses: { + key: "400"; + value: { + description: "No restriction is defined."; + }; + }; + }; + } + + // Gets restrictions + rpc GetRestrictions(GetRestrictionsRequest) returns (GetRestrictionsResponse) { + option (google.api.http) = { + get: "/restrictions" + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.restrictions.read"; + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: ["Feature Restrictions"]; + summary: "Get the current feature restrictions for the instance"; + description: "Undefined values mean that the feature is not restricted. If restrictions were never set, the instances features are not restricted, all properties are undefined and the details object is empty."; + responses: { + key: "200"; + value: { + description: "The status 200 is also returned if no restrictions were ever set. In this case, all feature restrictions have zero values."; + }; + }; + }; + } } @@ -7934,3 +7987,27 @@ message ListMilestonesResponse { zitadel.v1.ListDetails details = 1; repeated zitadel.milestone.v1.Milestone result = 2; } + +message SetRestrictionsRequest { + optional bool disallow_public_org_registration = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines if ZITADEL should expose the endpoint /ui/login/register/org. If it is true, the org registration endpoint returns the HTTP status 404 on GET requests, and 409 on POST requests."; + } + ]; +} + +message SetRestrictionsResponse { + zitadel.v1.ObjectDetails details = 1; +} + +message GetRestrictionsRequest {} + +message GetRestrictionsResponse { + zitadel.v1.ObjectDetails details = 1; + bool disallow_public_org_registration = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines if ZITADEL should expose the endpoint /ui/login/register/org. If it is true, the org registration endpoint returns the HTTP status 404 on GET requests, and 409 on POST requests."; + } + ]; +} + diff --git a/proto/zitadel/system.proto b/proto/zitadel/system.proto index 01d0aecaf..044bbf579 100644 --- a/proto/zitadel/system.proto +++ b/proto/zitadel/system.proto @@ -433,7 +433,7 @@ service SystemService { }; option (zitadel.v1.auth_option) = { - permission: "authenticated"; + permission: "system.limits.write"; }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { @@ -465,7 +465,7 @@ service SystemService { }; option (zitadel.v1.auth_option) = { - permission: "authenticated"; + permission: "system.limits.delete"; }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { @@ -766,7 +766,7 @@ message SetLimitsRequest { string instance_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; google.protobuf.Duration audit_log_retention = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - description: "AuditLogRetention limits the number of events that can be queried via the events API by their age. A value of '0s' means that all events are available. If this value is set, it overwrites the system default."; + description: "auditLogRetention limits the number of events that can be queried via the events API by their age. A value of '0s' means that all events are available. If this value is set, it overwrites the system default."; } ]; }