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
This commit is contained in:
Elio Bischof 2023-11-22 10:29:38 +01:00 committed by GitHub
parent 5fa596a871
commit 76fe032b5f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 1280 additions and 123 deletions

View File

@ -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:

View File

@ -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"

View File

@ -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.

View File

@ -94,6 +94,7 @@ module.exports = {
"guides/manage/customize/branding",
"guides/manage/customize/texts",
"guides/manage/customize/behavior",
"guides/manage/customize/restrictions",
],
},
{

View File

@ -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)
}

View File

@ -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
}

View File

@ -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 := `<input type="hidden" name="gorilla.csrf.Token" value="`
_, after, hasCsrfToken := bytes.Cut(body, []byte(searchField))
if hasCsrfToken {
csrfToken, _, _ = bytes.Cut(after, []byte(`">`))
}
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",
)
}

View File

@ -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()
}())
}

View File

@ -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)

View File

@ -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)

View File

@ -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...)

View File

@ -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 {

View File

@ -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
}

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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)
}
})
}
}

View File

@ -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)

View File

@ -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,

View File

@ -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(),

View File

@ -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())

View File

@ -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 != ""},
}
}

View File

@ -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,
}
}

View File

@ -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
}

View File

@ -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)
})
}
}

View File

@ -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)

View File

@ -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,
)
}
}

View File

@ -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...)
})
}
}

View File

@ -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,
},
}
}

View File

@ -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]

View File

@ -0,0 +1,9 @@
package restrictions
import (
"github.com/zitadel/zitadel/internal/eventstore"
)
func RegisterEventMappers(es *eventstore.Eventstore) {
es.RegisterFilterEventMapper(AggregateType, SetEventType, SetEventMapper)
}

View File

@ -31,6 +31,8 @@ Errors:
Limits:
NotFound: Лимитът не е намерен
NoneSpecified: Не са посочени лимити
Restrictions:
NoneSpecified: Не са посочени ограничения
Language:
NotParsed: Езикът не можа да бъде анализиран синтактично
OIDCSettings:

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -31,6 +31,8 @@ Errors:
Limits:
NotFound: 制限が見つかりません
NoneSpecified: 制限が指定されていません
Restrictions:
NoneSpecified: 制限が指定されていません
Language:
NotParsed: 言語のパースに失敗しました
OIDCSettings:

View File

@ -31,6 +31,8 @@ Errors:
Limits:
NotFound: Лимитот не е пронајден
NoneSpecified: Не се наведени лимити
Restrictions:
NoneSpecified: Не се наведени ограничувања
Language:
NotParsed: Јазикот не може да се парсира
OIDCSettings:

View File

@ -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:

View File

@ -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:

View File

@ -28,6 +28,11 @@ Errors:
RemoveFailed: Объект не удалось удалить
Limit:
ExceedsDefault: Лимит превышает лимит по умолчанию
Limits:
NotFound: Лимиты не найдены
NoneSpecified: Не указаны лимиты
Restrictions:
NoneSpecified: Не указаны ограничения
Language:
NotParsed: Не удалось разобрать язык
OIDCSettings:

View File

@ -31,6 +31,8 @@ Errors:
Limits:
NotFound: 未找到限制
NoneSpecified: 未指定限制
Restrictions:
NoneSpecified: 未指定限制
Language:
NotParsed: 无法解析语言
OIDCSettings:

View File

@ -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.";
}
];
}

View File

@ -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.";
}
];
}