feat: block instances (#7129)

* docs: fix init description typos

* feat: block instances using limits

* translate

* unit tests

* fix translations

* redirect /ui/login

* fix http interceptor

* cleanup

* fix http interceptor

* fix: delete cookies on gateway 200

* add integration tests

* add command test

* docs

* fix integration tests

* add bulk api and integration test

* optimize bulk set limits

* unit test bulk limits

* fix broken link

* fix assets middleware

* fix broken link

* validate instance id format

* Update internal/eventstore/search_query.go

Co-authored-by: Livio Spring <livio.a@gmail.com>

* remove support for owner bulk limit commands

* project limits to instances

* migrate instances projection

* Revert "migrate instances projection"

This reverts commit 214218732a56e6df823beac1972adfcf8beeded5.

* join limits, remove owner

* remove todo

* use optional bool

* normally validate instance ids

* use 302

* cleanup

* cleanup

* Update internal/api/grpc/system/limits_converter.go

Co-authored-by: Livio Spring <livio.a@gmail.com>

* remove owner

* remove owner from reset

---------

Co-authored-by: Livio Spring <livio.a@gmail.com>
This commit is contained in:
Elio Bischof 2024-01-17 11:16:48 +01:00 committed by GitHub
parent d9d376a275
commit ed0bc39ea4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
80 changed files with 1609 additions and 438 deletions

View File

@ -830,6 +830,9 @@ DefaultInstance:
# A value of "0s" means that all events are available. # 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. # If this value is set, it overwrites the system default unless it is not reset via the admin API.
AuditLogRetention: # ZITADEL_DEFAULTINSTANCE_LIMITS_AUDITLOGRETENTION AuditLogRetention: # ZITADEL_DEFAULTINSTANCE_LIMITS_AUDITLOGRETENTION
# If Block is true, all requests except to /ui/console or the system API are blocked and /ui/login is redirected to /ui/console.
# /ui/console shows a message that the instance is blocked with a link to Console.InstanceManagementURL
Block: # ZITADEL_DEFAULTINSTANCE_LIMITS_BLOCK
Restrictions: Restrictions:
# DisallowPublicOrgRegistration defines if ZITADEL should expose the endpoint /ui/login/register/org # 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. # If it is true, the endpoint returns the HTTP status 404 on GET requests, and 409 on POST requests.
@ -862,7 +865,8 @@ DefaultInstance:
# ResetInterval: 720h # 30 days # ResetInterval: 720h # 30 days
# # Amount defines the number of units for this quota # # Amount defines the number of units for this quota
# Amount: 25000 # Amount: 25000
# # Limit defines whether ZITADEL should block further usage when the configured amount is used # # Limit defines whether ZITADEL should block further authenticated requests when the configured amount is used.
# # If you not only want to block authenticated requests but also authentication itself, consider using the system APIs SetLimits method.
# Limit: false # Limit: false
# # Notifications are emitted by ZITADEL when certain quota percentages are reached # # Notifications are emitted by ZITADEL when certain quota percentages are reached
# Notifications: # Notifications:

View File

@ -39,7 +39,7 @@ func New() *cobra.Command {
Long: `Sets up the minimum requirements to start ZITADEL. Long: `Sets up the minimum requirements to start ZITADEL.
Prerequisites: Prerequisites:
- cockroachdb - cockroachDB
The user provided by flags needs privileges to The user provided by flags needs privileges to
- create the database if it does not exist - create the database if it does not exist

View File

@ -17,10 +17,10 @@ func newDatabase() *cobra.Command {
Short: "initialize only the database", Short: "initialize only the database",
Long: `Sets up the ZITADEL database. Long: `Sets up the ZITADEL database.
Prereqesits: Prerequisites:
- cockroachDB or postgreSQL - cockroachDB or postgreSQL
The user provided by flags needs priviledge to The user provided by flags needs privileges to
- create the database if it does not exist - create the database if it does not exist
- see other users and create a new one if the user does not exist - see other users and create a new one if the user does not exist
- grant all rights of the ZITADEL database to the user created if not yet set - grant all rights of the ZITADEL database to the user created if not yet set

View File

@ -17,7 +17,7 @@ func newGrant() *cobra.Command {
Short: "set ALL grant to user", Short: "set ALL grant to user",
Long: `Sets ALL grant to the database user. Long: `Sets ALL grant to the database user.
Prereqesits: Prerequisites:
- cockroachDB or postgreSQL - cockroachDB or postgreSQL
`, `,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {

View File

@ -17,10 +17,10 @@ func newUser() *cobra.Command {
Short: "initialize only the database user", Short: "initialize only the database user",
Long: `Sets up the ZITADEL database user. Long: `Sets up the ZITADEL database user.
Prereqesits: Prerequisites:
- cockroachDB or postreSQL - cockroachDB or postgreSQL
The user provided by flags needs priviledge to The user provided by flags needs privileges to
- create the database if it does not exist - create the database if it does not exist
- see other users and create a new one if the user does not exist - see other users and create a new one if the user does not exist
- grant all rights of the ZITADEL database to the user created if not yet set - grant all rights of the ZITADEL database to the user created if not yet set

View File

@ -19,7 +19,7 @@ func newZitadel() *cobra.Command {
Short: "initialize ZITADEL internals", Short: "initialize ZITADEL internals",
Long: `initialize ZITADEL internals. Long: `initialize ZITADEL internals.
Prereqesits: Prerequisites:
- cockroachDB or postgreSQL with user and database - cockroachDB or postgreSQL with user and database
`, `,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {

26
cmd/setup/21.go Normal file
View File

@ -0,0 +1,26 @@
package setup
import (
"context"
_ "embed"
"github.com/zitadel/zitadel/internal/database"
)
var (
//go:embed 21.sql
addBlockFieldToLimits string
)
type AddBlockFieldToLimits struct {
dbClient *database.DB
}
func (mig *AddBlockFieldToLimits) Execute(ctx context.Context) error {
_, err := mig.dbClient.ExecContext(ctx, addBlockFieldToLimits)
return err
}
func (mig *AddBlockFieldToLimits) String() string {
return "21_add_block_field_to_limits"
}

1
cmd/setup/21.sql Normal file
View File

@ -0,0 +1 @@
ALTER TABLE IF EXISTS projections.limits ADD COLUMN IF NOT EXISTS block BOOLEAN;

View File

@ -78,6 +78,7 @@ type Steps struct {
s18AddLowerFieldsToLoginNames *AddLowerFieldsToLoginNames s18AddLowerFieldsToLoginNames *AddLowerFieldsToLoginNames
s19AddCurrentStatesIndex *AddCurrentSequencesIndex s19AddCurrentStatesIndex *AddCurrentSequencesIndex
s20AddByUserSessionIndex *AddByUserIndexToSession s20AddByUserSessionIndex *AddByUserIndexToSession
s21AddBlockFieldToLimits *AddBlockFieldToLimits
} }
type encryptionKeyConfig struct { type encryptionKeyConfig struct {

View File

@ -111,6 +111,7 @@ func Setup(config *Config, steps *Steps, masterKey string) {
steps.s18AddLowerFieldsToLoginNames = &AddLowerFieldsToLoginNames{dbClient: queryDBClient} steps.s18AddLowerFieldsToLoginNames = &AddLowerFieldsToLoginNames{dbClient: queryDBClient}
steps.s19AddCurrentStatesIndex = &AddCurrentSequencesIndex{dbClient: queryDBClient} steps.s19AddCurrentStatesIndex = &AddCurrentSequencesIndex{dbClient: queryDBClient}
steps.s20AddByUserSessionIndex = &AddByUserIndexToSession{dbClient: queryDBClient} steps.s20AddByUserSessionIndex = &AddByUserIndexToSession{dbClient: queryDBClient}
steps.s21AddBlockFieldToLimits = &AddBlockFieldToLimits{dbClient: queryDBClient}
err = projection.Create(ctx, projectionDBClient, eventstoreClient, config.Projections, nil, nil, nil) err = projection.Create(ctx, projectionDBClient, eventstoreClient, config.Projections, nil, nil, nil)
logging.OnError(err).Fatal("unable to start projections") logging.OnError(err).Fatal("unable to start projections")
@ -165,9 +166,11 @@ func Setup(config *Config, steps *Steps, masterKey string) {
logging.OnError(err).Fatalf("unable to migrate repeatable step: %s", repeatableStep.String()) logging.OnError(err).Fatalf("unable to migrate repeatable step: %s", repeatableStep.String())
} }
// This step is executed after the repeatable steps because it adds fields to the login_names3 projection // These steps are executed after the repeatable steps because they add fields projections
err = migration.Migrate(ctx, eventstoreClient, steps.s18AddLowerFieldsToLoginNames) err = migration.Migrate(ctx, eventstoreClient, steps.s18AddLowerFieldsToLoginNames)
logging.WithFields("name", steps.s18AddLowerFieldsToLoginNames.String()).OnError(err).Fatal("migration failed") logging.WithFields("name", steps.s18AddLowerFieldsToLoginNames.String()).OnError(err).Fatal("migration failed")
err = migration.Migrate(ctx, eventstoreClient, steps.s21AddBlockFieldToLimits)
logging.WithFields("name", steps.s21AddBlockFieldToLimits.String()).OnError(err).Fatal("migration failed")
} }
func readStmt(fs embed.FS, folder, typ, filename string) (string, error) { func readStmt(fs embed.FS, folder, typ, filename string) (string, error) {

View File

@ -439,14 +439,14 @@ func startAPIs(
return fmt.Errorf("unable to start console: %w", err) return fmt.Errorf("unable to start console: %w", err)
} }
apis.RegisterHandlerOnPrefix(console.HandlerPrefix, c) apis.RegisterHandlerOnPrefix(console.HandlerPrefix, c)
consolePath := console.HandlerPrefix + "/"
l, err := login.CreateLogin( l, err := login.CreateLogin(
config.Login, config.Login,
commands, commands,
queries, queries,
authRepo, authRepo,
store, store,
console.HandlerPrefix+"/", consolePath,
oidcServer.AuthCallbackURL(), oidcServer.AuthCallbackURL(),
provider.AuthCallbackURL(samlProvider), provider.AuthCallbackURL(samlProvider),
config.ExternalSecure, config.ExternalSecure,
@ -455,7 +455,7 @@ func startAPIs(
provider.NewIssuerInterceptor(samlProvider.IssuerFromRequest).Handler, provider.NewIssuerInterceptor(samlProvider.IssuerFromRequest).Handler,
instanceInterceptor.Handler, instanceInterceptor.Handler,
assetsCache.Handler, assetsCache.Handler,
limitingAccessInterceptor.WithoutLimiting().Handle, limitingAccessInterceptor.WithRedirect(consolePath).Handle,
keys.User, keys.User,
keys.IDPConfig, keys.IDPConfig,
keys.CSRFCookieKey, keys.CSRFCookieKey,

View File

@ -261,8 +261,8 @@
"DESCRIPTION": "Щракнете върху бутона по-долу, за да влезете отново." "DESCRIPTION": "Щракнете върху бутона по-долу, за да влезете отново."
}, },
"EXHAUSTED": { "EXHAUSTED": {
"TITLE": "Вашата квота за удостоверени заявки е изчерпана.", "TITLE": "Вашият екземпляр е блокиран.",
"DESCRIPTION": ремахнете или увеличете ограничението на квотата за този екземпляр на ZITADEL." "DESCRIPTION": опитайте администратора на вашия екземпляр ZITADEL да актуализира абонамента."
}, },
"INVALID_FORMAT": "Форматирането е невалидно.", "INVALID_FORMAT": "Форматирането е невалидно.",
"NOTANEMAIL": "Дадената стойност не е имейл адрес.", "NOTANEMAIL": "Дадената стойност не е имейл адрес.",

View File

@ -268,8 +268,8 @@
"DESCRIPTION": "Klikněte na tlačítko níže pro opětovné přihlášení." "DESCRIPTION": "Klikněte na tlačítko níže pro opětovné přihlášení."
}, },
"EXHAUSTED": { "EXHAUSTED": {
"TITLE": "Vyčerpali jste kvótu pro autentizované požadavky.", "TITLE": "Vaše instance je blokována.",
"DESCRIPTION": "Odstraňte nebo zvyšte limit kvóty pro tuto instanci ZITADEL." "DESCRIPTION": "Požádejte svého správce instance ZITADEL, aby aktualizoval předplatné."
}, },
"INVALID_FORMAT": "Formát je neplatný.", "INVALID_FORMAT": "Formát je neplatný.",
"NOTANEMAIL": "Zadaná hodnota není e-mailová adresa.", "NOTANEMAIL": "Zadaná hodnota není e-mailová adresa.",

View File

@ -267,8 +267,8 @@
"DESCRIPTION": "Klicke auf \"Einloggen\", um Dich erneut anzumelden." "DESCRIPTION": "Klicke auf \"Einloggen\", um Dich erneut anzumelden."
}, },
"EXHAUSTED": { "EXHAUSTED": {
"TITLE": "Dein Kontingent an authentifizierten Anfragen is erschöpft.", "TITLE": "Deine Instanz ist blockiert.",
"DESCRIPTION": "Lösche oder erhöhe die Grenze für diese ZITADEL Instanz." "DESCRIPTION": "Bitte kontaktiere den Administrator deiner ZITADEL Instanz."
}, },
"INVALID_FORMAT": "Das Format is ungültig.", "INVALID_FORMAT": "Das Format is ungültig.",
"NOTANEMAIL": "Der eingegebene Wert ist keine E-Mail Adresse.", "NOTANEMAIL": "Der eingegebene Wert ist keine E-Mail Adresse.",

View File

@ -268,8 +268,8 @@
"DESCRIPTION": "Click the button below to log in again." "DESCRIPTION": "Click the button below to log in again."
}, },
"EXHAUSTED": { "EXHAUSTED": {
"TITLE": "Your quota for authenticated requests is exhausted.", "TITLE": "Your instance is blocked.",
"DESCRIPTION": "Remove or increase the quota limit for this ZITADEL instance." "DESCRIPTION": "Ask your ZITADEL instance administrator to update the subscription."
}, },
"INVALID_FORMAT": "The formatting is invalid.", "INVALID_FORMAT": "The formatting is invalid.",
"NOTANEMAIL": "The given value is not an e-mail address.", "NOTANEMAIL": "The given value is not an e-mail address.",

View File

@ -268,8 +268,8 @@
"DESCRIPTION": "Haz clic en el botón más abajo para iniciar sesión otra vez." "DESCRIPTION": "Haz clic en el botón más abajo para iniciar sesión otra vez."
}, },
"EXHAUSTED": { "EXHAUSTED": {
"TITLE": "Su cuota de solicitudes autenticadas se ha agotado.", "TITLE": "Tu instancia está bloqueada.",
"DESCRIPTION": "Borrar o aumentar el límite de esta instancia de ZITADEL." "DESCRIPTION": "Pide a tu administrador de instancia de ZITADEL que actualice la suscripción."
}, },
"INVALID_FORMAT": "El formato no es valido.", "INVALID_FORMAT": "El formato no es valido.",
"NOTANEMAIL": "El valor proporcionado no es una dirección de email.", "NOTANEMAIL": "El valor proporcionado no es una dirección de email.",

View File

@ -267,8 +267,8 @@
"DESCRIPTION": "Cliquez sur le bouton ci-dessous pour vous reconnecter." "DESCRIPTION": "Cliquez sur le bouton ci-dessous pour vous reconnecter."
}, },
"EXHAUSTED": { "EXHAUSTED": {
"TITLE": "Ton quota de demandes authentifiées est épuisé.", "TITLE": "Votre instance est bloquée.",
"DESCRIPTION": "Supprimez ou augmentez la limite de cette instance ZITADEL." "DESCRIPTION": "Demandez à votre administrateur d'instance ZITADEL de mettre à jour l'abonnement."
}, },
"INVALID_FORMAT": "Le format n'est pas valide", "INVALID_FORMAT": "Le format n'est pas valide",
"NOTANEMAIL": "La valeur donnée n'est pas une adresse e-mail", "NOTANEMAIL": "La valeur donnée n'est pas une adresse e-mail",

View File

@ -266,8 +266,8 @@
"DESCRIPTION": "Clicca il pulsante per richiedere una nuova sessione." "DESCRIPTION": "Clicca il pulsante per richiedere una nuova sessione."
}, },
"EXHAUSTED": { "EXHAUSTED": {
"TITLE": "La quota di richieste autenticate è esaurita.", "TITLE": "La tua istanza è bloccata.",
"DESCRIPTION": "Cancellare o aumentare il limite per questa istanza ZITADEL." "DESCRIPTION": "Chiedi all'amministratore dell'istanza ZITADEL di aggiornare l'abbonamento."
}, },
"INVALID_FORMAT": "Il formato non è valido.", "INVALID_FORMAT": "Il formato non è valido.",
"NOTANEMAIL": "Il valore dato non \u00e8 un indirizzo e-mail.", "NOTANEMAIL": "Il valore dato non \u00e8 un indirizzo e-mail.",

View File

@ -268,8 +268,8 @@
"DESCRIPTION": "下のボタンをクリックして、もう一度ログインする。" "DESCRIPTION": "下のボタンをクリックして、もう一度ログインする。"
}, },
"EXHAUSTED": { "EXHAUSTED": {
"TITLE": "認証されたリクエストのクォータを使い果たしました", "TITLE": "あなたのインスタンスはブロックされています。",
"DESCRIPTION": "このZITADELインスタンスの制限を削除または増加させる" "DESCRIPTION": "ZITADELインスタンス管理者にサブスクリプションの更新を依頼してください。"
}, },
"INVALID_FORMAT": "不正なフォーマットです", "INVALID_FORMAT": "不正なフォーマットです",
"NOTANEMAIL": "入力された値がメールアドレスではありません。", "NOTANEMAIL": "入力された値がメールアドレスではありません。",

View File

@ -268,8 +268,8 @@
"DESCRIPTION": "Кликнете на копчето подолу за повторна најава." "DESCRIPTION": "Кликнете на копчето подолу за повторна најава."
}, },
"EXHAUSTED": { "EXHAUSTED": {
"TITLE": "Вашиот квота за автентицирани барања е надмината.", "TITLE": "Вашиот авторизациски токен е истечен.",
"DESCRIPTION": "Отстранете или зголемете ја квотата за оваа ZITADEL инстанца." "DESCRIPTION": "Кликнете на копчето подолу за повторна најава."
}, },
"INVALID_FORMAT": "Невалиден формат.", "INVALID_FORMAT": "Невалиден формат.",
"NOTANEMAIL": "Внесената вредност не е е-пошта.", "NOTANEMAIL": "Внесената вредност не е е-пошта.",

View File

@ -268,8 +268,8 @@
"DESCRIPTION": "Klik op de knop hieronder om opnieuw in te loggen." "DESCRIPTION": "Klik op de knop hieronder om opnieuw in te loggen."
}, },
"EXHAUSTED": { "EXHAUSTED": {
"TITLE": "Uw quotum voor geauthenticeerde aanvragen is opgebruikt.", "TITLE": "Uw instantie is geblokkeerd.",
"DESCRIPTION": "Verwijder of verhoog het quotumlimiet voor deze ZITADEL-instantie." "DESCRIPTION": "Vraag uw ZITADEL instantiebeheerder om het abonnement bij te werken."
}, },
"INVALID_FORMAT": "De opmaak is ongeldig.", "INVALID_FORMAT": "De opmaak is ongeldig.",
"NOTANEMAIL": "De opgegeven waarde is geen e-mailadres.", "NOTANEMAIL": "De opgegeven waarde is geen e-mailadres.",

View File

@ -267,8 +267,8 @@
"DESCRIPTION": "Kliknij przycisk poniżej, aby ponownie się zalogować." "DESCRIPTION": "Kliknij przycisk poniżej, aby ponownie się zalogować."
}, },
"EXHAUSTED": { "EXHAUSTED": {
"TITLE": "Twój limit uwierzytelnionych wniosków został wyczerpany.", "TITLE": "Twoja instancja jest zablokowana.",
"DESCRIPTION": "Usuń lub zwiększ limit dla tej instancji ZITADEL." "DESCRIPTION": "Poproś administratora swojej instancji ZITADEL o aktualizację subskrypcji."
}, },
"INVALID_FORMAT": "Format jest nieprawidłowy.", "INVALID_FORMAT": "Format jest nieprawidłowy.",
"NOTANEMAIL": "Podana wartość nie jest adresem e-mail.", "NOTANEMAIL": "Podana wartość nie jest adresem e-mail.",

View File

@ -268,8 +268,8 @@
"DESCRIPTION": "Clique no botão abaixo para fazer login novamente." "DESCRIPTION": "Clique no botão abaixo para fazer login novamente."
}, },
"EXHAUSTED": { "EXHAUSTED": {
"TITLE": "Sua cota para solicitações autenticadas está esgotada.", "TITLE": "Sua instância está bloqueada.",
"DESCRIPTION": "Remova ou aumente o limite de cota para esta instância ZITADEL." "DESCRIPTION": "Peça ao administrador da sua instância ZITADEL para atualizar a assinatura."
}, },
"INVALID_FORMAT": "O formato é inválido.", "INVALID_FORMAT": "O formato é inválido.",
"NOTANEMAIL": "O valor fornecido não é um endereço de e-mail.", "NOTANEMAIL": "O valor fornecido não é um endereço de e-mail.",

View File

@ -264,8 +264,8 @@
"DESCRIPTION": "Нажмите кнопку ниже, чтобы войти снова." "DESCRIPTION": "Нажмите кнопку ниже, чтобы войти снова."
}, },
"EXHAUSTED": { "EXHAUSTED": {
"TITLE": "Ваша квота на аутентифицированные запросы исчерпана.", "TITLE": "Ваш экземпляр заблокирован.",
"DESCRIPTION": "Удалите или увеличьте лимит квоты для этого экземпляра ZITADEL." "DESCRIPTION": "Попросите администратора вашего экземпляра ZITADEL обновить подписку."
}, },
"INVALID_FORMAT": "Форматирование неверно.", "INVALID_FORMAT": "Форматирование неверно.",
"NOTANEMAIL": "Данное значение не является адресом электронной почты.", "NOTANEMAIL": "Данное значение не является адресом электронной почты.",

View File

@ -267,8 +267,8 @@
"DESCRIPTION": "点击下方按钮再次登录。" "DESCRIPTION": "点击下方按钮再次登录。"
}, },
"EXHAUSTED": { "EXHAUSTED": {
"TITLE": "你的认证请求配额已用完.", "TITLE": "您的实例已被阻止。",
"DESCRIPTION": "删除或增加这个ZITADEL实例的限制。" "DESCRIPTION": "请联系您的 ZITADEL 实例管理员以更新订阅。"
}, },
"INVALID_FORMAT": "格式是无效的。", "INVALID_FORMAT": "格式是无效的。",
"NOTANEMAIL": "给定的值不是合法电子邮件地址。", "NOTANEMAIL": "给定的值不是合法电子邮件地址。",

View File

@ -7,6 +7,26 @@ If you have a self-hosted ZITADEL environment, you can limit the usage of your [
For example, if you provide your customers [their own virtual instances](/concepts/structure/instance#multiple-virtual-instances) with access on their own domains, you can design a pricing model based on the usage of their instances. For example, if you provide your customers [their own virtual instances](/concepts/structure/instance#multiple-virtual-instances) with access on their own domains, you can design a pricing model based on the usage of their instances.
The usage control features are currently limited to the instance level only. The usage control features are currently limited to the instance level only.
## Block Instances
You can block an instance using the [system API](/category/apis/resources/system/limits).
Most requests to a blocked instance are rejected with the HTTP status *429 Too Many Requests* or the gRPC status *8 Resource Exhausted*.
However, requests to the [system API](/apis/introduction#system) are still allowed.
Requests to paths with the prefix */ui/login* return a redirect with HTTP status *302 Found* to */ui/console*, where the user is guided to *InstanceManagementURL*.
Blocked HTTP requests additionally set a cookie to make it easy to block traffic before it reaches your ZITADEL runtime, for example with a WAF rule.
You can block new instances by default using the *DefaultInstance.Limits.Block* runtime configuration.
The following snippets shows the default YAML:
```yaml
DefaultInstance:
Limits:
# If Block is true, all requests except to /ui/console or the system API are blocked and /ui/login is redirected to /ui/console.
# /ui/console shows a message that the instance is blocked with a link to Console.InstanceManagementURL
Block: # ZITADEL_DEFAULTINSTANCE_LIMITS_BLOCK
```
## Limit Audit Trails ## Limit Audit Trails
You can restrict the maximum age of events returned by the following APIs: You can restrict the maximum age of events returned by the following APIs:
@ -107,8 +127,9 @@ DefaultInstance:
### Exhausted Authenticated Requests ### Exhausted Authenticated Requests
If a quota is configured to limit requests and the quotas amount is exhausted, all further requests are blocked except requests to the System API. If a quota is configured to limit requests and the quotas amount is exhausted, all further authenticated requests are blocked except requests to the [system API](/apis/introduction#system).
Also, a cookie is set, to make it easier to block further traffic before it reaches your ZITADEL runtime. Also, a cookie is set, to make it easier to block further traffic before it reaches your ZITADEL runtime, for example with a WAF rule.
The console is still served, but it only shows a dialog that says that the instance is blocked with a link to *InstanceManagementURL*.
### Exhausted Action Run Seconds ### Exhausted Action Run Seconds

View File

@ -94,7 +94,6 @@ func NewHandler(commands *command.Commands, verifier authz.APITokenVerifier, aut
router := mux.NewRouter() router := mux.NewRouter()
csp := http_mw.SecurityHeaders(&http_mw.DefaultSCP, nil) csp := http_mw.SecurityHeaders(&http_mw.DefaultSCP, nil)
router.Use(callDurationInterceptor, instanceInterceptor, assetCacheInterceptor, accessInterceptor, csp) router.Use(callDurationInterceptor, instanceInterceptor, assetCacheInterceptor, accessInterceptor, csp)
router.Use(callDurationInterceptor, instanceInterceptor, assetCacheInterceptor, accessInterceptor)
RegisterRoutes(router, h) RegisterRoutes(router, h)
router.PathPrefix("/{owner}").Methods("GET").HandlerFunc(DownloadHandleFunc(h, h.GetFile())) router.PathPrefix("/{owner}").Methods("GET").HandlerFunc(DownloadHandleFunc(h, h.GetFile()))
return http_util.CopyHeadersToContext(http_mw.CORSInterceptor(router)) return http_util.CopyHeadersToContext(http_mw.CORSInterceptor(router))

View File

@ -2,6 +2,7 @@ package authz
import ( import (
"context" "context"
"time"
"golang.org/x/text/language" "golang.org/x/text/language"
) )
@ -20,6 +21,8 @@ type Instance interface {
DefaultLanguage() language.Tag DefaultLanguage() language.Tag
DefaultOrganisationID() string DefaultOrganisationID() string
SecurityPolicyAllowedOrigins() []string SecurityPolicyAllowedOrigins() []string
Block() *bool
AuditLogRetention() *time.Duration
} }
type InstanceVerifier interface { type InstanceVerifier interface {
@ -36,6 +39,14 @@ type instance struct {
orgID string orgID string
} }
func (i *instance) Block() *bool {
return nil
}
func (i *instance) AuditLogRetention() *time.Duration {
return nil
}
func (i *instance) InstanceID() string { func (i *instance) InstanceID() string {
return i.id return i.id
} }

View File

@ -3,6 +3,7 @@ package authz
import ( import (
"context" "context"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"golang.org/x/text/language" "golang.org/x/text/language"
@ -68,6 +69,14 @@ func Test_Instance(t *testing.T) {
type mockInstance struct{} type mockInstance struct{}
func (m *mockInstance) Block() *bool {
panic("shouldn't be called here")
}
func (m *mockInstance) AuditLogRetention() *time.Duration {
panic("shouldn't be called here")
}
func (m *mockInstance) InstanceID() string { func (m *mockInstance) InstanceID() string {
return "instanceID" return "instanceID"
} }

View File

@ -185,7 +185,7 @@ func addInterceptors(
handler = http_mw.ActivityHandler(handler) handler = http_mw.ActivityHandler(handler)
// For some non-obvious reason, the exhaustedCookieInterceptor sends the SetCookie header // For some non-obvious reason, the exhaustedCookieInterceptor sends the SetCookie header
// only if it follows the http_mw.DefaultTelemetryHandler // only if it follows the http_mw.DefaultTelemetryHandler
handler = exhaustedCookieInterceptor(handler, accessInterceptor, queries) handler = exhaustedCookieInterceptor(handler, accessInterceptor)
handler = http_mw.DefaultMetricsHandler(handler) handler = http_mw.DefaultMetricsHandler(handler)
return handler return handler
} }
@ -205,14 +205,12 @@ func http1Host(next http.Handler, http1HostName string) http.Handler {
func exhaustedCookieInterceptor( func exhaustedCookieInterceptor(
next http.Handler, next http.Handler,
accessInterceptor *http_mw.AccessInterceptor, accessInterceptor *http_mw.AccessInterceptor,
queries *query.Queries,
) http.Handler { ) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
next.ServeHTTP(&cookieResponseWriter{ next.ServeHTTP(&cookieResponseWriter{
ResponseWriter: writer, ResponseWriter: writer,
accessInterceptor: accessInterceptor, accessInterceptor: accessInterceptor,
request: request, request: request,
queries: queries,
}, request) }, request)
}) })
} }
@ -221,7 +219,7 @@ type cookieResponseWriter struct {
http.ResponseWriter http.ResponseWriter
accessInterceptor *http_mw.AccessInterceptor accessInterceptor *http_mw.AccessInterceptor
request *http.Request request *http.Request
queries *query.Queries headerWritten bool
} }
func (r *cookieResponseWriter) WriteHeader(status int) { func (r *cookieResponseWriter) WriteHeader(status int) {
@ -231,9 +229,18 @@ func (r *cookieResponseWriter) WriteHeader(status int) {
if status == http.StatusTooManyRequests { if status == http.StatusTooManyRequests {
r.accessInterceptor.SetExhaustedCookie(r.ResponseWriter, r.request) r.accessInterceptor.SetExhaustedCookie(r.ResponseWriter, r.request)
} }
r.headerWritten = true
r.ResponseWriter.WriteHeader(status) r.ResponseWriter.WriteHeader(status)
} }
func (r *cookieResponseWriter) Write(bytes []byte) (int, error) {
if !r.headerWritten {
// If no header was written before the data, the status code is 200 and we can delete the cookie
r.accessInterceptor.DeleteExhaustedCookie(r.ResponseWriter)
}
return r.ResponseWriter.Write(bytes)
}
func grpcCredentials(tlsConfig *tls.Config) credentials.TransportCredentials { func grpcCredentials(tlsConfig *tls.Config) credentials.TransportCredentials {
creds := insecure.NewCredentials() creds := insecure.NewCredentials()
if tlsConfig != nil { if tlsConfig != nil {

View File

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"reflect" "reflect"
"testing" "testing"
"time"
"golang.org/x/text/language" "golang.org/x/text/language"
"google.golang.org/grpc" "google.golang.org/grpc"
@ -164,6 +165,14 @@ func (m *mockInstanceVerifier) InstanceByID(context.Context) (authz.Instance, er
type mockInstance struct{} type mockInstance struct{}
func (m *mockInstance) Block() *bool {
panic("shouldn't be called here")
}
func (m *mockInstance) AuditLogRetention() *time.Duration {
panic("shouldn't be called here")
}
func (m *mockInstance) InstanceID() string { func (m *mockInstance) InstanceID() string {
return "instanceID" return "instanceID"
} }

View File

@ -0,0 +1,31 @@
package middleware
import (
"context"
"strings"
"google.golang.org/grpc"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/zerrors"
)
func LimitsInterceptor(ignoreService ...string) grpc.UnaryServerInterceptor {
for idx, service := range ignoreService {
if !strings.HasPrefix(service, "/") {
ignoreService[idx] = "/" + service
}
}
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (_ interface{}, err error) {
for _, service := range ignoreService {
if strings.HasPrefix(info.FullMethod, service) {
return handler(ctx, req)
}
}
instance := authz.GetInstance(ctx)
if block := instance.Block(); block != nil && *block {
return nil, zerrors.ThrowResourceExhausted(nil, "LIMITS-molsj", "Errors.Limits.Instance.Blocked")
}
return handler(ctx, req)
}
}

View File

@ -53,9 +53,10 @@ func CreateServer(
middleware.InstanceInterceptor(queries, hostHeaderName, system_pb.SystemService_ServiceDesc.ServiceName, healthpb.Health_ServiceDesc.ServiceName), middleware.InstanceInterceptor(queries, hostHeaderName, system_pb.SystemService_ServiceDesc.ServiceName, healthpb.Health_ServiceDesc.ServiceName),
middleware.AccessStorageInterceptor(accessSvc), middleware.AccessStorageInterceptor(accessSvc),
middleware.ErrorHandler(), middleware.ErrorHandler(),
middleware.LimitsInterceptor(system_pb.SystemService_ServiceDesc.ServiceName),
middleware.AuthorizationInterceptor(verifier, authConfig), middleware.AuthorizationInterceptor(verifier, authConfig),
middleware.QuotaExhaustedInterceptor(accessSvc, system_pb.SystemService_ServiceDesc.ServiceName),
middleware.TranslationHandler(), middleware.TranslationHandler(),
middleware.QuotaExhaustedInterceptor(accessSvc, system_pb.SystemService_ServiceDesc.ServiceName),
middleware.ValidationHandler(), middleware.ValidationHandler(),
middleware.ServiceHandler(), middleware.ServiceHandler(),
middleware.ActivityInterceptor(), middleware.ActivityInterceptor(),

View File

@ -4,15 +4,12 @@ import (
"context" "context"
"github.com/zitadel/zitadel/internal/api/grpc/object" "github.com/zitadel/zitadel/internal/api/grpc/object"
objectpb "github.com/zitadel/zitadel/pkg/grpc/object"
"github.com/zitadel/zitadel/pkg/grpc/system" "github.com/zitadel/zitadel/pkg/grpc/system"
) )
func (s *Server) SetLimits(ctx context.Context, req *system.SetLimitsRequest) (*system.SetLimitsResponse, error) { func (s *Server) SetLimits(ctx context.Context, req *system.SetLimitsRequest) (*system.SetLimitsResponse, error) {
details, err := s.command.SetLimits( details, err := s.command.SetLimits(ctx, setInstanceLimitsPbToCommand(req))
ctx,
req.GetInstanceId(),
instanceLimitsPbToCommand(req),
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -21,8 +18,23 @@ func (s *Server) SetLimits(ctx context.Context, req *system.SetLimitsRequest) (*
}, nil }, nil
} }
func (s *Server) ResetLimits(ctx context.Context, req *system.ResetLimitsRequest) (*system.ResetLimitsResponse, error) { func (s *Server) BulkSetLimits(ctx context.Context, req *system.BulkSetLimitsRequest) (*system.BulkSetLimitsResponse, error) {
details, err := s.command.ResetLimits(ctx, req.GetInstanceId()) details, targetDetails, err := s.command.SetInstanceLimitsBulk(ctx, bulkSetInstanceLimitsPbToCommand(req))
if err != nil {
return nil, err
}
resp := &system.BulkSetLimitsResponse{
Details: object.AddToDetailsPb(details.Sequence, details.EventDate, details.ResourceOwner),
TargetDetails: make([]*objectpb.ObjectDetails, len(targetDetails)),
}
for i := range targetDetails {
resp.TargetDetails[i] = object.AddToDetailsPb(targetDetails[i].Sequence, targetDetails[i].EventDate, targetDetails[i].ResourceOwner)
}
return resp, nil
}
func (s *Server) ResetLimits(ctx context.Context, _ *system.ResetLimitsRequest) (*system.ResetLimitsResponse, error) {
details, err := s.command.ResetLimits(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -7,10 +7,23 @@ import (
"github.com/zitadel/zitadel/pkg/grpc/system" "github.com/zitadel/zitadel/pkg/grpc/system"
) )
func instanceLimitsPbToCommand(req *system.SetLimitsRequest) *command.SetLimits { func setInstanceLimitsPbToCommand(req *system.SetLimitsRequest) *command.SetLimits {
var setLimits = new(command.SetLimits) var setLimits = new(command.SetLimits)
if req.AuditLogRetention != nil { if req.AuditLogRetention != nil {
setLimits.AuditLogRetention = gu.Ptr(req.AuditLogRetention.AsDuration()) setLimits.AuditLogRetention = gu.Ptr(req.AuditLogRetention.AsDuration())
} }
setLimits.Block = req.Block
return setLimits return setLimits
} }
func bulkSetInstanceLimitsPbToCommand(req *system.BulkSetLimitsRequest) []*command.SetInstanceLimitsBulk {
cmds := make([]*command.SetInstanceLimitsBulk, len(req.Limits))
for i := range req.Limits {
setLimitsReq := req.Limits[i]
cmds[i] = &command.SetInstanceLimitsBulk{
InstanceID: setLimitsReq.GetInstanceId(),
SetLimits: *setInstanceLimitsPbToCommand(req.Limits[i]),
}
}
return cmds
}

View File

@ -0,0 +1,287 @@
//go:build integration
package system_test
import (
"fmt"
"io"
"net"
"net/http"
"strings"
"testing"
"time"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/durationpb"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/pkg/grpc/admin"
"github.com/zitadel/zitadel/pkg/grpc/system"
)
func TestServer_Limits_Block(t *testing.T) {
domain, instanceID, iamOwnerCtx := Tester.UseIsolatedInstance(t, CTX, SystemCTX)
tests := []*test{
publicAPIBlockingTest(domain),
{
name: "mutating API",
testGrpc: func(tt assert.TestingT, expectBlocked bool) {
randomGrpcIdpName := randomString("idp-grpc", 5)
_, err := Tester.Client.Admin.AddGitHubProvider(iamOwnerCtx, &admin.AddGitHubProviderRequest{
Name: randomGrpcIdpName,
ClientId: "client-id",
ClientSecret: "client-secret",
})
assertGrpcError(tt, err, expectBlocked)
//nolint:contextcheck
idpExists := idpExistsCondition(tt, instanceID, randomGrpcIdpName)
if expectBlocked {
// We ensure that the idp really is not created
assert.Neverf(tt, idpExists, 5*time.Second, 1*time.Second, "idp should never be created")
} else {
assert.Eventuallyf(tt, idpExists, 5*time.Second, 1*time.Second, "idp should be created")
}
},
testHttp: func(tt assert.TestingT) (*http.Request, error, func(assert.TestingT, *http.Response, bool)) {
randomHttpIdpName := randomString("idp-http", 5)
req, err := http.NewRequestWithContext(
CTX,
"POST",
fmt.Sprintf("http://%s/admin/v1/idps/github", net.JoinHostPort(domain, "8080")),
strings.NewReader(`{
"name": "`+randomHttpIdpName+`",
"clientId": "client-id",
"clientSecret": "client-secret"
}`),
)
if err != nil {
return nil, err, nil
}
req.Header.Set("Authorization", Tester.BearerToken(iamOwnerCtx))
return req, nil, func(ttt assert.TestingT, response *http.Response, expectBlocked bool) {
assertLimitResponse(ttt, response, expectBlocked)
assertSetLimitingCookie(ttt, response, expectBlocked)
}
},
}, {
name: "discovery",
testHttp: func(tt assert.TestingT) (*http.Request, error, func(assert.TestingT, *http.Response, bool)) {
req, err := http.NewRequestWithContext(
CTX,
"GET",
fmt.Sprintf("http://%s/.well-known/openid-configuration", net.JoinHostPort(domain, "8080")),
nil,
)
return req, err, func(ttt assert.TestingT, response *http.Response, expectBlocked bool) {
assertLimitResponse(ttt, response, expectBlocked)
assertSetLimitingCookie(ttt, response, expectBlocked)
}
},
}, {
name: "login",
testHttp: func(tt assert.TestingT) (*http.Request, error, func(assert.TestingT, *http.Response, bool)) {
req, err := http.NewRequestWithContext(
CTX,
"GET",
fmt.Sprintf("http://%s/ui/login/login/externalidp/callback", net.JoinHostPort(domain, "8080")),
nil,
)
return req, err, func(ttt assert.TestingT, response *http.Response, expectBlocked bool) {
// the login paths should return a redirect if the instance is blocked
if expectBlocked {
assert.Equal(ttt, http.StatusFound, response.StatusCode)
} else {
assertLimitResponse(ttt, response, false)
}
assertSetLimitingCookie(ttt, response, expectBlocked)
}
},
}, {
name: "console",
testHttp: func(tt assert.TestingT) (*http.Request, error, func(assert.TestingT, *http.Response, bool)) {
req, err := http.NewRequestWithContext(
CTX,
"GET",
fmt.Sprintf("http://%s/ui/console/", net.JoinHostPort(domain, "8080")),
nil,
)
return req, err, func(ttt assert.TestingT, response *http.Response, expectBlocked bool) {
// the console is not blocked so we can render a link to an instance management portal.
// A CDN can cache these assets easily
// We also don't care about a cookie because the environment.json already takes care of that.
assertLimitResponse(ttt, response, false)
}
},
}, {
name: "environment.json",
testHttp: func(tt assert.TestingT) (*http.Request, error, func(assert.TestingT, *http.Response, bool)) {
req, err := http.NewRequestWithContext(
CTX,
"GET",
fmt.Sprintf("http://%s/ui/console/assets/environment.json", net.JoinHostPort(domain, "8080")),
nil,
)
return req, err, func(ttt assert.TestingT, response *http.Response, expectBlocked bool) {
// the environment.json should always return successfully
assertLimitResponse(ttt, response, false)
assertSetLimitingCookie(ttt, response, expectBlocked)
body, err := io.ReadAll(response.Body)
assert.NoError(ttt, err)
var compFunc assert.ComparisonAssertionFunc = assert.NotContains
if expectBlocked {
compFunc = assert.Contains
}
compFunc(ttt, string(body), `"exhausted":true`)
}
},
}}
_, err := Tester.Client.System.SetLimits(SystemCTX, &system.SetLimitsRequest{
InstanceId: instanceID,
Block: gu.Ptr(true),
})
require.NoError(t, err)
// The following call ensures that an undefined bool is not deserialized to false
_, err = Tester.Client.System.SetLimits(SystemCTX, &system.SetLimitsRequest{
InstanceId: instanceID,
AuditLogRetention: durationpb.New(time.Hour),
})
require.NoError(t, err)
for _, tt := range tests {
var isFirst bool
t.Run(tt.name+" with blocking", func(t *testing.T) {
isFirst = isFirst || !t.Skipped()
testBlockingAPI(t, tt, true, isFirst)
})
}
_, err = Tester.Client.System.SetLimits(SystemCTX, &system.SetLimitsRequest{
InstanceId: instanceID,
Block: gu.Ptr(false),
})
require.NoError(t, err)
for _, tt := range tests {
var isFirst bool
t.Run(tt.name+" without blocking", func(t *testing.T) {
isFirst = isFirst || !t.Skipped()
testBlockingAPI(t, tt, false, isFirst)
})
}
}
type test struct {
name string
testHttp func(t assert.TestingT) (req *http.Request, err error, assertResponse func(t assert.TestingT, response *http.Response, expectBlocked bool))
testGrpc func(t assert.TestingT, expectBlocked bool)
}
func testBlockingAPI(t *testing.T, tt *test, expectBlocked bool, isFirst bool) {
req, err, assertResponse := tt.testHttp(t)
require.NoError(t, err)
testHTTP := func(tt assert.TestingT) {
resp, err := (&http.Client{
// Don't follow redirects
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}).Do(req)
defer func() {
require.NoError(t, resp.Body.Close())
}()
require.NoError(t, err)
assertResponse(t, resp, expectBlocked)
}
if isFirst {
// limits are eventually consistent, so we need to wait for the blocking to be set on the first test
assert.EventuallyWithT(t, func(c *assert.CollectT) {
testHTTP(c)
}, 15*time.Second, time.Second, "wait for blocking to be set")
} else {
testHTTP(t)
}
if tt.testGrpc != nil {
tt.testGrpc(t, expectBlocked)
}
}
func publicAPIBlockingTest(domain string) *test {
return &test{
name: "public API",
testGrpc: func(tt assert.TestingT, expectBlocked bool) {
conn, err := grpc.DialContext(CTX, net.JoinHostPort(domain, "8080"),
grpc.WithBlock(),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
assert.NoError(tt, err)
_, err = admin.NewAdminServiceClient(conn).Healthz(CTX, &admin.HealthzRequest{})
assertGrpcError(tt, err, expectBlocked)
},
testHttp: func(tt assert.TestingT) (*http.Request, error, func(assert.TestingT, *http.Response, bool)) {
req, err := http.NewRequestWithContext(
CTX,
"GET",
fmt.Sprintf("http://%s/admin/v1/healthz", net.JoinHostPort(domain, "8080")),
nil,
)
return req, err, func(ttt assert.TestingT, response *http.Response, expectBlocked bool) {
assertLimitResponse(ttt, response, expectBlocked)
assertSetLimitingCookie(ttt, response, expectBlocked)
}
},
}
}
// If expectSet is true, we expect the cookie to be set
// If expectSet is false, we expect the cookie to be deleted
func assertSetLimitingCookie(t assert.TestingT, response *http.Response, expectSet bool) {
for _, cookie := range response.Cookies() {
if cookie.Name == "zitadel.quota.exhausted" {
if expectSet {
assert.Greater(t, cookie.MaxAge, 0)
} else {
assert.LessOrEqual(t, cookie.MaxAge, 0)
}
return
}
}
assert.FailNow(t, "cookie not found")
}
func assertGrpcError(t assert.TestingT, err error, expectBlocked bool) {
if expectBlocked {
assert.Equal(t, codes.ResourceExhausted, status.Convert(err).Code())
return
}
assert.NoError(t, err)
}
func assertLimitResponse(t assert.TestingT, response *http.Response, expectBlocked bool) {
if expectBlocked {
assert.Equal(t, http.StatusTooManyRequests, response.StatusCode)
return
}
assert.GreaterOrEqual(t, response.StatusCode, 200)
assert.Less(t, response.StatusCode, 300)
}
func idpExistsCondition(t assert.TestingT, instanceID, idpName string) func() bool {
return func() bool {
nameQuery, err := query.NewIDPTemplateNameSearchQuery(query.TextEquals, idpName)
assert.NoError(t, err)
instanceQuery, err := query.NewIDPTemplateResourceOwnerSearchQuery(instanceID)
assert.NoError(t, err)
idps, err := Tester.Queries.IDPTemplates(authz.WithInstanceID(CTX, instanceID), &query.IDPTemplateSearchQueries{
Queries: []query.SearchQuery{
instanceQuery,
nameQuery,
},
}, false)
assert.NoError(t, err)
return len(idps.Templates) > 0
}
}

View File

@ -0,0 +1,75 @@
//go:build integration
package system_test
import (
"testing"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/internal/integration"
"github.com/zitadel/zitadel/pkg/grpc/system"
)
func TestServer_Limits_Bulk(t *testing.T) {
const len = 5
type instance struct{ domain, id string }
instances := make([]*instance, len)
for i := 0; i < len; i++ {
domain := integration.RandString(5) + ".integration.localhost"
resp, err := Tester.Client.System.CreateInstance(SystemCTX, &system.CreateInstanceRequest{
InstanceName: "testinstance",
CustomDomain: domain,
Owner: &system.CreateInstanceRequest_Machine_{
Machine: &system.CreateInstanceRequest_Machine{
UserName: "owner",
Name: "owner",
},
},
})
require.NoError(t, err)
instances[i] = &instance{domain, resp.GetInstanceId()}
}
resp, err := Tester.Client.System.BulkSetLimits(SystemCTX, &system.BulkSetLimitsRequest{
Limits: []*system.SetLimitsRequest{{
InstanceId: instances[0].id,
Block: gu.Ptr(true),
}, {
InstanceId: instances[1].id,
Block: gu.Ptr(false),
}, {
InstanceId: instances[2].id,
Block: gu.Ptr(true),
}, {
InstanceId: instances[3].id,
Block: gu.Ptr(false),
}, {
InstanceId: instances[4].id,
Block: gu.Ptr(true),
}},
})
require.NoError(t, err)
details := resp.GetTargetDetails()
require.Len(t, details, len)
t.Run("the first instance is blocked", func(t *testing.T) {
require.Equal(t, instances[0].id, details[0].GetResourceOwner(), "resource owner must be instance id")
testBlockingAPI(t, publicAPIBlockingTest(instances[0].domain), true, true)
})
t.Run("the second instance isn't blocked", func(t *testing.T) {
require.Equal(t, instances[1].id, details[1].GetResourceOwner(), "resource owner must be instance id")
testBlockingAPI(t, publicAPIBlockingTest(instances[1].domain), false, true)
})
t.Run("the third instance is blocked", func(t *testing.T) {
require.Equal(t, instances[2].id, details[2].GetResourceOwner(), "resource owner must be instance id")
testBlockingAPI(t, publicAPIBlockingTest(instances[2].domain), true, true)
})
t.Run("the fourth instance isn't blocked", func(t *testing.T) {
require.Equal(t, instances[3].id, details[3].GetResourceOwner(), "resource owner must be instance id")
testBlockingAPI(t, publicAPIBlockingTest(instances[3].domain), false, true)
})
t.Run("the fifth instance is blocked", func(t *testing.T) {
require.Equal(t, instances[4].id, details[4].GetResourceOwner(), "resource owner must be instance id")
testBlockingAPI(t, publicAPIBlockingTest(instances[4].domain), true, true)
})
}

View File

@ -1,6 +1,6 @@
//go:build integration //go:build integration
package system_test package quotas_enabled_test
import ( import (
"bytes" "bytes"
@ -23,13 +23,12 @@ import (
var callURL = "http://localhost:" + integration.PortQuotaServer var callURL = "http://localhost:" + integration.PortQuotaServer
func TestServer_QuotaNotification_Limit(t *testing.T) { func TestServer_QuotaNotification_Limit(t *testing.T) {
_, instanceID, iamOwnerCtx := Tester.UseIsolatedInstance(t, CTX, SystemCTX)
amount := 10 amount := 10
percent := 50 percent := 50
percentAmount := amount * percent / 100 percentAmount := amount * percent / 100
_, err := Tester.Client.System.SetQuota(SystemCTX, &system.SetQuotaRequest{ _, err := Tester.Client.System.SetQuota(SystemCTX, &system.SetQuotaRequest{
InstanceId: instanceID, InstanceId: Tester.Instance.InstanceID(),
Unit: quota_pb.Unit_UNIT_REQUESTS_ALL_AUTHENTICATED, Unit: quota_pb.Unit_UNIT_REQUESTS_ALL_AUTHENTICATED,
From: timestamppb.Now(), From: timestamppb.Now(),
ResetInterval: durationpb.New(time.Minute * 5), ResetInterval: durationpb.New(time.Minute * 5),
@ -51,23 +50,23 @@ func TestServer_QuotaNotification_Limit(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
for i := 0; i < percentAmount; i++ { for i := 0; i < percentAmount; i++ {
_, err := Tester.Client.Admin.GetDefaultOrg(iamOwnerCtx, &admin.GetDefaultOrgRequest{}) _, err := Tester.Client.Admin.GetDefaultOrg(IAMOwnerCTX, &admin.GetDefaultOrgRequest{})
require.NoErrorf(t, err, "error in %d call of %d", i, percentAmount) require.NoErrorf(t, err, "error in %d call of %d", i, percentAmount)
} }
awaitNotification(t, Tester.QuotaNotificationChan, quota.RequestsAllAuthenticated, percent) awaitNotification(t, Tester.QuotaNotificationChan, quota.RequestsAllAuthenticated, percent)
for i := 0; i < (amount - percentAmount); i++ { for i := 0; i < (amount - percentAmount); i++ {
_, err := Tester.Client.Admin.GetDefaultOrg(iamOwnerCtx, &admin.GetDefaultOrgRequest{}) _, err := Tester.Client.Admin.GetDefaultOrg(IAMOwnerCTX, &admin.GetDefaultOrgRequest{})
require.NoErrorf(t, err, "error in %d call of %d", i, percentAmount) require.NoErrorf(t, err, "error in %d call of %d", i, percentAmount)
} }
awaitNotification(t, Tester.QuotaNotificationChan, quota.RequestsAllAuthenticated, 100) awaitNotification(t, Tester.QuotaNotificationChan, quota.RequestsAllAuthenticated, 100)
_, limitErr := Tester.Client.Admin.GetDefaultOrg(iamOwnerCtx, &admin.GetDefaultOrgRequest{}) _, limitErr := Tester.Client.Admin.GetDefaultOrg(IAMOwnerCTX, &admin.GetDefaultOrgRequest{})
require.Error(t, limitErr) require.Error(t, limitErr)
} }
func TestServer_QuotaNotification_NoLimit(t *testing.T) { func TestServer_QuotaNotification_NoLimit(t *testing.T) {
_, instanceID, iamOwnerCtx := Tester.UseIsolatedInstance(t, CTX, SystemCTX) _, instanceID, IAMOwnerCTX := Tester.UseIsolatedInstance(t, CTX, SystemCTX)
amount := 10 amount := 10
percent := 50 percent := 50
percentAmount := amount * percent / 100 percentAmount := amount * percent / 100
@ -95,24 +94,24 @@ func TestServer_QuotaNotification_NoLimit(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
for i := 0; i < percentAmount; i++ { for i := 0; i < percentAmount; i++ {
_, err := Tester.Client.Admin.GetDefaultOrg(iamOwnerCtx, &admin.GetDefaultOrgRequest{}) _, err := Tester.Client.Admin.GetDefaultOrg(IAMOwnerCTX, &admin.GetDefaultOrgRequest{})
require.NoErrorf(t, err, "error in %d call of %d", i, percentAmount) require.NoErrorf(t, err, "error in %d call of %d", i, percentAmount)
} }
awaitNotification(t, Tester.QuotaNotificationChan, quota.RequestsAllAuthenticated, percent) awaitNotification(t, Tester.QuotaNotificationChan, quota.RequestsAllAuthenticated, percent)
for i := 0; i < (amount - percentAmount); i++ { for i := 0; i < (amount - percentAmount); i++ {
_, err := Tester.Client.Admin.GetDefaultOrg(iamOwnerCtx, &admin.GetDefaultOrgRequest{}) _, err := Tester.Client.Admin.GetDefaultOrg(IAMOwnerCTX, &admin.GetDefaultOrgRequest{})
require.NoErrorf(t, err, "error in %d call of %d", i, percentAmount) require.NoErrorf(t, err, "error in %d call of %d", i, percentAmount)
} }
awaitNotification(t, Tester.QuotaNotificationChan, quota.RequestsAllAuthenticated, 100) awaitNotification(t, Tester.QuotaNotificationChan, quota.RequestsAllAuthenticated, 100)
for i := 0; i < amount; i++ { for i := 0; i < amount; i++ {
_, err := Tester.Client.Admin.GetDefaultOrg(iamOwnerCtx, &admin.GetDefaultOrgRequest{}) _, err := Tester.Client.Admin.GetDefaultOrg(IAMOwnerCTX, &admin.GetDefaultOrgRequest{})
require.NoErrorf(t, err, "error in %d call of %d", i, percentAmount) require.NoErrorf(t, err, "error in %d call of %d", i, percentAmount)
} }
awaitNotification(t, Tester.QuotaNotificationChan, quota.RequestsAllAuthenticated, 200) awaitNotification(t, Tester.QuotaNotificationChan, quota.RequestsAllAuthenticated, 200)
_, limitErr := Tester.Client.Admin.GetDefaultOrg(iamOwnerCtx, &admin.GetDefaultOrgRequest{}) _, limitErr := Tester.Client.Admin.GetDefaultOrg(IAMOwnerCTX, &admin.GetDefaultOrgRequest{})
require.NoError(t, limitErr) require.NoError(t, limitErr)
} }

View File

@ -0,0 +1,37 @@
//go:build integration
package quotas_enabled_test
import (
"context"
"os"
"testing"
"time"
"github.com/zitadel/zitadel/internal/integration"
)
var (
CTX context.Context
SystemCTX context.Context
IAMOwnerCTX context.Context
Tester *integration.Tester
)
func TestMain(m *testing.M) {
os.Exit(func() int {
ctx, _, cancel := integration.Contexts(5 * time.Minute)
defer cancel()
CTX = ctx
Tester = integration.NewTester(ctx, `
Quotas:
Access:
Enabled: true
`)
defer Tester.Done()
SystemCTX = Tester.WithAuthorization(ctx, integration.SystemUser)
IAMOwnerCTX = Tester.WithAuthorization(ctx, integration.IAMOwner)
return m.Run()
}())
}

View File

@ -19,10 +19,11 @@ import (
) )
type AccessInterceptor struct { type AccessInterceptor struct {
svc *logstore.Service[*record.AccessLog] logstoreSvc *logstore.Service[*record.AccessLog]
cookieHandler *http_utils.CookieHandler cookieHandler *http_utils.CookieHandler
limitConfig *AccessConfig limitConfig *AccessConfig
storeOnly bool storeOnly bool
redirect string
} }
type AccessConfig struct { type AccessConfig struct {
@ -32,10 +33,10 @@ type AccessConfig struct {
// NewAccessInterceptor intercepts all requests and stores them to the logstore. // NewAccessInterceptor intercepts all requests and stores them to the logstore.
// If storeOnly is false, it also checks if requests are exhausted. // If storeOnly is false, it also checks if requests are exhausted.
// If requests are exhausted, it also returns http.StatusTooManyRequests and sets a cookie // If requests are exhausted, it also returns http.StatusTooManyRequests or a redirect to the given path and sets a cookie
func NewAccessInterceptor(svc *logstore.Service[*record.AccessLog], cookieHandler *http_utils.CookieHandler, cookieConfig *AccessConfig) *AccessInterceptor { func NewAccessInterceptor(svc *logstore.Service[*record.AccessLog], cookieHandler *http_utils.CookieHandler, cookieConfig *AccessConfig) *AccessInterceptor {
return &AccessInterceptor{ return &AccessInterceptor{
svc: svc, logstoreSvc: svc,
cookieHandler: cookieHandler, cookieHandler: cookieHandler,
limitConfig: cookieConfig, limitConfig: cookieConfig,
} }
@ -43,24 +44,61 @@ func NewAccessInterceptor(svc *logstore.Service[*record.AccessLog], cookieHandle
func (a *AccessInterceptor) WithoutLimiting() *AccessInterceptor { func (a *AccessInterceptor) WithoutLimiting() *AccessInterceptor {
return &AccessInterceptor{ return &AccessInterceptor{
svc: a.svc, logstoreSvc: a.logstoreSvc,
cookieHandler: a.cookieHandler, cookieHandler: a.cookieHandler,
limitConfig: a.limitConfig, limitConfig: a.limitConfig,
storeOnly: true, storeOnly: true,
redirect: a.redirect,
}
}
func (a *AccessInterceptor) WithRedirect(redirect string) *AccessInterceptor {
return &AccessInterceptor{
logstoreSvc: a.logstoreSvc,
cookieHandler: a.cookieHandler,
limitConfig: a.limitConfig,
storeOnly: a.storeOnly,
redirect: redirect,
} }
} }
func (a *AccessInterceptor) AccessService() *logstore.Service[*record.AccessLog] { func (a *AccessInterceptor) AccessService() *logstore.Service[*record.AccessLog] {
return a.svc return a.logstoreSvc
} }
func (a *AccessInterceptor) Limit(ctx context.Context) bool { func (a *AccessInterceptor) Limit(w http.ResponseWriter, r *http.Request, publicAuthPathPrefixes ...string) bool {
if !a.svc.Enabled() || a.storeOnly { if a.storeOnly {
return false return false
} }
ctx := r.Context()
instance := authz.GetInstance(ctx) instance := authz.GetInstance(ctx)
remaining := a.svc.Limit(ctx, instance.InstanceID()) var deleteCookie bool
return remaining != nil && *remaining <= 0 defer func() {
if deleteCookie {
a.DeleteExhaustedCookie(w)
}
}()
if block := instance.Block(); block != nil {
if *block {
a.SetExhaustedCookie(w, r)
return true
}
deleteCookie = true
}
for _, ignoredPathPrefix := range publicAuthPathPrefixes {
if strings.HasPrefix(r.RequestURI, ignoredPathPrefix) {
return false
}
}
remaining := a.logstoreSvc.Limit(ctx, instance.InstanceID())
if remaining != nil {
if remaining != nil && *remaining > 0 {
a.SetExhaustedCookie(w, r)
return true
}
deleteCookie = true
}
return false
} }
func (a *AccessInterceptor) SetExhaustedCookie(writer http.ResponseWriter, request *http.Request) { func (a *AccessInterceptor) SetExhaustedCookie(writer http.ResponseWriter, request *http.Request) {
@ -81,42 +119,30 @@ func (a *AccessInterceptor) DeleteExhaustedCookie(writer http.ResponseWriter) {
a.cookieHandler.DeleteCookie(writer, a.limitConfig.ExhaustedCookieKey) a.cookieHandler.DeleteCookie(writer, a.limitConfig.ExhaustedCookieKey)
} }
func (a *AccessInterceptor) HandleIgnorePathPrefixes(ignoredPathPrefixes []string) func(next http.Handler) http.Handler { func (a *AccessInterceptor) HandleWithPublicAuthPathPrefixes(publicPathPrefixes []string) func(next http.Handler) http.Handler {
return a.handle(ignoredPathPrefixes...) return a.handle(publicPathPrefixes...)
} }
func (a *AccessInterceptor) Handle(next http.Handler) http.Handler { func (a *AccessInterceptor) Handle(next http.Handler) http.Handler {
return a.handle()(next) return a.handle()(next)
} }
func (a *AccessInterceptor) handle(ignoredPathPrefixes ...string) func(http.Handler) http.Handler { func (a *AccessInterceptor) handle(publicAuthPathPrefixes ...string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
if !a.svc.Enabled() {
return next
}
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
ctx := request.Context() ctx := request.Context()
tracingCtx, checkSpan := tracing.NewNamedSpan(ctx, "checkAccessQuota") tracingCtx, checkSpan := tracing.NewNamedSpan(ctx, "checkAccessQuota")
wrappedWriter := &statusRecorder{ResponseWriter: writer, status: 0} wrappedWriter := &statusRecorder{ResponseWriter: writer, status: 0}
for _, ignoredPathPrefix := range ignoredPathPrefixes { limited := a.Limit(wrappedWriter, request.WithContext(tracingCtx), publicAuthPathPrefixes...)
if !strings.HasPrefix(request.RequestURI, ignoredPathPrefix) {
continue
}
checkSpan.End()
next.ServeHTTP(wrappedWriter, request)
a.writeLog(tracingCtx, wrappedWriter, writer, request, true)
return
}
limited := a.Limit(tracingCtx)
checkSpan.End() checkSpan.End()
if limited { if limited {
a.SetExhaustedCookie(wrappedWriter, request) if a.redirect != "" {
http.Error(wrappedWriter, "quota for authenticated requests is exhausted", http.StatusTooManyRequests) // The console guides the user when the cookie is set
} http.Redirect(wrappedWriter, request, a.redirect, http.StatusFound)
if !limited && !a.storeOnly { } else {
a.DeleteExhaustedCookie(wrappedWriter) http.Error(wrappedWriter, "Your ZITADEL instance is blocked.", http.StatusTooManyRequests)
} }
if !limited { } else {
next.ServeHTTP(wrappedWriter, request) next.ServeHTTP(wrappedWriter, request)
} }
a.writeLog(tracingCtx, wrappedWriter, writer, request, a.storeOnly) a.writeLog(tracingCtx, wrappedWriter, writer, request, a.storeOnly)
@ -125,6 +151,9 @@ func (a *AccessInterceptor) handle(ignoredPathPrefixes ...string) func(http.Hand
} }
func (a *AccessInterceptor) writeLog(ctx context.Context, wrappedWriter *statusRecorder, writer http.ResponseWriter, request *http.Request, notCountable bool) { func (a *AccessInterceptor) writeLog(ctx context.Context, wrappedWriter *statusRecorder, writer http.ResponseWriter, request *http.Request, notCountable bool) {
if !a.logstoreSvc.Enabled() {
return
}
ctx, writeSpan := tracing.NewNamedSpan(ctx, "writeAccess") ctx, writeSpan := tracing.NewNamedSpan(ctx, "writeAccess")
defer writeSpan.End() defer writeSpan.End()
requestURL := request.RequestURI requestURL := request.RequestURI
@ -133,7 +162,7 @@ func (a *AccessInterceptor) writeLog(ctx context.Context, wrappedWriter *statusR
logging.WithError(err).WithField("url", requestURL).Warning("failed to unescape request url") logging.WithError(err).WithField("url", requestURL).Warning("failed to unescape request url")
} }
instance := authz.GetInstance(ctx) instance := authz.GetInstance(ctx)
a.svc.Handle(ctx, &record.AccessLog{ a.logstoreSvc.Handle(ctx, &record.AccessLog{
LogDate: time.Now(), LogDate: time.Now(),
Protocol: record.HTTP, Protocol: record.HTTP,
RequestURL: unescapedURL, RequestURL: unescapedURL,

View File

@ -7,6 +7,7 @@ import (
"net/http/httptest" "net/http/httptest"
"reflect" "reflect"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"golang.org/x/text/language" "golang.org/x/text/language"
@ -299,6 +300,14 @@ func (m *mockInstanceVerifier) InstanceByID(context.Context) (authz.Instance, er
type mockInstance struct{} type mockInstance struct{}
func (m *mockInstance) Block() *bool {
panic("shouldn't be called here")
}
func (m *mockInstance) AuditLogRetention() *time.Duration {
panic("shouldn't be called here")
}
func (m *mockInstance) InstanceID() string { func (m *mockInstance) InstanceID() string {
return "instanceID" return "instanceID"
} }

View File

@ -148,14 +148,14 @@ func NewServer(
instanceHandler, instanceHandler,
userAgentCookie, userAgentCookie,
http_utils.CopyHeadersToContext, http_utils.CopyHeadersToContext,
accessHandler.HandleIgnorePathPrefixes(ignoredQuotaLimitEndpoint(config.CustomEndpoints)), accessHandler.HandleWithPublicAuthPathPrefixes(publicAuthPathPrefixes(config.CustomEndpoints)),
middleware.ActivityHandler, middleware.ActivityHandler,
)) ))
return server, nil return server, nil
} }
func ignoredQuotaLimitEndpoint(endpoints *EndpointConfig) []string { func publicAuthPathPrefixes(endpoints *EndpointConfig) []string {
authURL := op.DefaultEndpoints.Authorization.Relative() authURL := op.DefaultEndpoints.Authorization.Relative()
keysURL := op.DefaultEndpoints.JwksURI.Relative() keysURL := op.DefaultEndpoints.JwksURI.Relative()
if endpoints == nil { if endpoints == nil {

View File

@ -63,7 +63,7 @@ func NewProvider(
middleware.NoCacheInterceptor().Handler, middleware.NoCacheInterceptor().Handler,
instanceHandler, instanceHandler,
userAgentCookie, userAgentCookie,
accessHandler.HandleIgnorePathPrefixes(ignoredQuotaLimitEndpoint(conf.ProviderConfig)), accessHandler.HandleWithPublicAuthPathPrefixes(publicAuthPathPrefixes(conf.ProviderConfig)),
http_utils.CopyHeadersToContext, http_utils.CopyHeadersToContext,
middleware.ActivityHandler, middleware.ActivityHandler,
), ),
@ -102,7 +102,7 @@ func newStorage(
}, nil }, nil
} }
func ignoredQuotaLimitEndpoint(config *provider.Config) []string { func publicAuthPathPrefixes(config *provider.Config) []string {
metadataEndpoint := HandlerPrefix + provider.DefaultMetadataEndpoint metadataEndpoint := HandlerPrefix + provider.DefaultMetadataEndpoint
certificateEndpoint := HandlerPrefix + provider.DefaultCertificateEndpoint certificateEndpoint := HandlerPrefix + provider.DefaultCertificateEndpoint
ssoEndpoint := HandlerPrefix + provider.DefaultSingleSignOnEndpoint ssoEndpoint := HandlerPrefix + provider.DefaultSingleSignOnEndpoint

View File

@ -116,17 +116,12 @@ func Start(config Config, externalSecure bool, issuer op.IssuerFromRequest, call
http.Error(w, fmt.Sprintf("unable to template instance management url for console: %v", err), http.StatusInternalServerError) http.Error(w, fmt.Sprintf("unable to template instance management url for console: %v", err), http.StatusInternalServerError)
return return
} }
exhausted := limitingAccessInterceptor.Limit(ctx) limited := limitingAccessInterceptor.Limit(w, r)
environmentJSON, err := createEnvironmentJSON(url, issuer(r), instance.ConsoleClientID(), customerPortal, instanceMgmtURL, exhausted) environmentJSON, err := createEnvironmentJSON(url, issuer(r), instance.ConsoleClientID(), customerPortal, instanceMgmtURL, limited)
if err != nil { if err != nil {
http.Error(w, fmt.Sprintf("unable to marshal env for console: %v", err), http.StatusInternalServerError) http.Error(w, fmt.Sprintf("unable to marshal env for console: %v", err), http.StatusInternalServerError)
return return
} }
if exhausted {
limitingAccessInterceptor.SetExhaustedCookie(w, r)
} else {
limitingAccessInterceptor.DeleteExhaustedCookie(w)
}
_, err = w.Write(environmentJSON) _, err = w.Write(environmentJSON)
logging.OnError(err).Error("error serving environment.json") logging.OnError(err).Error("error serving environment.json")
}))) })))

View File

@ -209,7 +209,7 @@ func (c *Commands) SetUpInstance(ctx context.Context, setup *InstanceSetup) (str
orgAgg := org.NewAggregate(orgID) orgAgg := org.NewAggregate(orgID)
userAgg := user.NewAggregate(userID, orgID) userAgg := user.NewAggregate(userID, orgID)
projectAgg := project.NewAggregate(setup.zitadel.projectID, orgID) projectAgg := project.NewAggregate(setup.zitadel.projectID, orgID)
limitsAgg := limits.NewAggregate(setup.zitadel.limitsID, instanceID, instanceID) limitsAgg := limits.NewAggregate(setup.zitadel.limitsID, instanceID)
restrictionsAgg := restrictions.NewAggregate(setup.zitadel.restrictionsID, instanceID, instanceID) restrictionsAgg := restrictions.NewAggregate(setup.zitadel.restrictionsID, instanceID, instanceID)
validations := []preparation.Validation{ validations := []preparation.Validation{

View File

@ -2,6 +2,7 @@ package command
import ( import (
"context" "context"
"errors"
"time" "time"
"github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/authz"
@ -14,31 +15,20 @@ import (
type SetLimits struct { type SetLimits struct {
AuditLogRetention *time.Duration AuditLogRetention *time.Duration
Block *bool
} }
// SetLimits creates new limits or updates existing limits. // SetLimits creates new limits or updates existing limits.
func (c *Commands) SetLimits( func (c *Commands) SetLimits(
ctx context.Context, ctx context.Context,
resourceOwner string,
setLimits *SetLimits, setLimits *SetLimits,
) (*domain.ObjectDetails, error) { ) (*domain.ObjectDetails, error) {
instanceId := authz.GetInstance(ctx).InstanceID() instanceId := authz.GetInstance(ctx).InstanceID()
wm, err := c.getLimitsWriteModel(ctx, instanceId, resourceOwner) wm, err := c.getLimitsWriteModel(ctx, instanceId)
if err != nil { if err != nil {
return nil, err return nil, err
} }
aggregateId := wm.AggregateID cmds, err := c.setLimitsCommands(ctx, wm, setLimits)
if aggregateId == "" {
aggregateId, err = c.idGenerator.Next()
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 { if err != nil {
return nil, err return nil, err
} }
@ -52,19 +42,81 @@ func (c *Commands) SetLimits(
return nil, err return nil, err
} }
} }
return writeModelToObjectDetails(&wm.WriteModel), nil return writeModelToObjectDetails(&wm.WriteModel), err
} }
func (c *Commands) ResetLimits(ctx context.Context, resourceOwner string) (*domain.ObjectDetails, error) { type SetInstanceLimitsBulk struct {
InstanceID string
SetLimits
}
func (c *Commands) SetInstanceLimitsBulk(
ctx context.Context,
bulk []*SetInstanceLimitsBulk,
) (bulkDetails *domain.ObjectDetails, targetsDetails []*domain.ObjectDetails, err error) {
bulkWm, err := c.getBulkInstanceLimitsWriteModel(ctx, bulk)
if err != nil {
return nil, nil, err
}
cmds := make([]eventstore.Command, 0)
for _, t := range bulk {
targetWM, ok := bulkWm.writeModels[t.InstanceID]
if !ok {
return nil, nil, zerrors.ThrowInternal(nil, "COMMAND-5HWA9", "Errors.Limits.NotFound")
}
targetCMDs, setErr := c.setLimitsCommands(ctx, targetWM, &t.SetLimits)
err = errors.Join(err, setErr)
cmds = append(cmds, targetCMDs...)
}
if err != nil {
return nil, nil, err
}
if len(cmds) > 0 {
events, err := c.eventstore.Push(ctx, cmds...)
if err != nil {
return nil, nil, err
}
err = AppendAndReduce(bulkWm, events...)
if err != nil {
return nil, nil, err
}
}
targetDetails := make([]*domain.ObjectDetails, len(bulk))
for i, t := range bulk {
targetDetails[i] = writeModelToObjectDetails(&bulkWm.writeModels[t.InstanceID].WriteModel)
}
details := writeModelToObjectDetails(&bulkWm.WriteModel)
details.ResourceOwner = ""
return details, targetDetails, err
}
func (c *Commands) setLimitsCommands(ctx context.Context, wm *limitsWriteModel, setLimits *SetLimits) (cmds []eventstore.Command, err error) {
aggregateId := wm.AggregateID
if aggregateId == "" {
aggregateId, err = c.idGenerator.Next()
if err != nil {
return nil, err
}
}
aggregate := limits.NewAggregate(aggregateId, wm.InstanceID)
createCmds, err := c.SetLimitsCommand(aggregate, wm, setLimits)()
if err != nil {
return nil, err
}
cmds, err = createCmds(ctx, nil)
return cmds, err
}
func (c *Commands) ResetLimits(ctx context.Context) (*domain.ObjectDetails, error) {
instanceId := authz.GetInstance(ctx).InstanceID() instanceId := authz.GetInstance(ctx).InstanceID()
wm, err := c.getLimitsWriteModel(ctx, instanceId, resourceOwner) wm, err := c.getLimitsWriteModel(ctx, instanceId)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if wm.AggregateID == "" { if wm.AggregateID == "" {
return nil, zerrors.ThrowNotFound(nil, "COMMAND-9JToT", "Errors.Limits.NotFound") return nil, zerrors.ThrowNotFound(nil, "COMMAND-9JToT", "Errors.Limits.NotFound")
} }
aggregate := limits.NewAggregate(wm.AggregateID, instanceId, resourceOwner) aggregate := limits.NewAggregate(wm.AggregateID, instanceId)
events := []eventstore.Command{limits.NewResetEvent(ctx, &aggregate.Aggregate)} events := []eventstore.Command{limits.NewResetEvent(ctx, &aggregate.Aggregate)}
pushedEvents, err := c.eventstore.Push(ctx, events...) pushedEvents, err := c.eventstore.Push(ctx, events...)
if err != nil { if err != nil {
@ -77,14 +129,22 @@ func (c *Commands) ResetLimits(ctx context.Context, resourceOwner string) (*doma
return writeModelToObjectDetails(&wm.WriteModel), nil return writeModelToObjectDetails(&wm.WriteModel), nil
} }
func (c *Commands) getLimitsWriteModel(ctx context.Context, instanceId, resourceOwner string) (*limitsWriteModel, error) { func (c *Commands) getLimitsWriteModel(ctx context.Context, instanceId string) (*limitsWriteModel, error) {
wm := newLimitsWriteModel(instanceId, resourceOwner) wm := newLimitsWriteModel(instanceId)
return wm, c.eventstore.FilterToQueryReducer(ctx, wm)
}
func (c *Commands) getBulkInstanceLimitsWriteModel(ctx context.Context, target []*SetInstanceLimitsBulk) (*limitsBulkWriteModel, error) {
wm := newLimitsBulkWriteModel()
for _, t := range target {
wm.addWriteModel(t.InstanceID)
}
return wm, c.eventstore.FilterToQueryReducer(ctx, wm) return wm, c.eventstore.FilterToQueryReducer(ctx, wm)
} }
func (c *Commands) SetLimitsCommand(a *limits.Aggregate, wm *limitsWriteModel, setLimits *SetLimits) preparation.Validation { func (c *Commands) SetLimitsCommand(a *limits.Aggregate, wm *limitsWriteModel, setLimits *SetLimits) preparation.Validation {
return func() (preparation.CreateCommands, error) { return func() (preparation.CreateCommands, error) {
if setLimits == nil || setLimits.AuditLogRetention == nil { if setLimits == nil || (setLimits.AuditLogRetention == nil && setLimits.Block == nil) {
return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-4M9vs", "Errors.Limits.NoneSpecified") return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-4M9vs", "Errors.Limits.NoneSpecified")
} }
return func(ctx context.Context, _ preparation.FilterToQueryReducer) ([]eventstore.Command, error) { return func(ctx context.Context, _ preparation.FilterToQueryReducer) ([]eventstore.Command, error) {

View File

@ -0,0 +1,55 @@
package command
import (
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/limits"
)
type limitsBulkWriteModel struct {
eventstore.WriteModel
writeModels map[string]*limitsWriteModel
filterInstanceIDs []string
}
// newLimitsBulkWriteModel should be followed by limitsBulkWriteModel.addWriteModel before querying and reducing it.
func newLimitsBulkWriteModel() *limitsBulkWriteModel {
return &limitsBulkWriteModel{
writeModels: make(map[string]*limitsWriteModel),
filterInstanceIDs: make([]string, 0),
}
}
func (wm *limitsBulkWriteModel) addWriteModel(instanceID string) {
if _, ok := wm.writeModels[instanceID]; !ok {
wm.writeModels[instanceID] = newLimitsWriteModel(instanceID)
}
wm.filterInstanceIDs = append(wm.filterInstanceIDs, instanceID)
}
func (wm *limitsBulkWriteModel) Query() *eventstore.SearchQueryBuilder {
query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
InstanceIDs(wm.filterInstanceIDs).
AddQuery().
AggregateTypes(limits.AggregateType).
EventTypes(
limits.SetEventType,
limits.ResetEventType,
)
return query.Builder()
}
func (wm *limitsBulkWriteModel) Reduce() error {
for _, event := range wm.Events {
instanceID := event.Aggregate().InstanceID
limitsWm, ok := wm.writeModels[instanceID]
if !ok {
continue
}
limitsWm.AppendEvents(event)
if err := limitsWm.Reduce(); err != nil {
return err
}
}
return nil
}

View File

@ -11,14 +11,15 @@ type limitsWriteModel struct {
eventstore.WriteModel eventstore.WriteModel
rollingAggregateID string rollingAggregateID string
auditLogRetention *time.Duration auditLogRetention *time.Duration
block *bool
} }
// newLimitsWriteModel aggregateId is filled by reducing unit matching events // newLimitsWriteModel aggregateId is filled by reducing unit matching events
func newLimitsWriteModel(instanceId, resourceOwner string) *limitsWriteModel { func newLimitsWriteModel(instanceId string) *limitsWriteModel {
return &limitsWriteModel{ return &limitsWriteModel{
WriteModel: eventstore.WriteModel{ WriteModel: eventstore.WriteModel{
InstanceID: instanceId, InstanceID: instanceId,
ResourceOwner: resourceOwner, ResourceOwner: instanceId,
}, },
} }
} }
@ -46,9 +47,13 @@ func (wm *limitsWriteModel) Reduce() error {
if e.AuditLogRetention != nil { if e.AuditLogRetention != nil {
wm.auditLogRetention = e.AuditLogRetention wm.auditLogRetention = e.AuditLogRetention
} }
if e.Block != nil {
wm.block = e.Block
}
case *limits.ResetEvent: case *limits.ResetEvent:
wm.rollingAggregateID = "" wm.rollingAggregateID = ""
wm.auditLogRetention = nil wm.auditLogRetention = nil
wm.block = nil
} }
} }
if err := wm.WriteModel.Reduce(); err != nil { if err := wm.WriteModel.Reduce(); err != nil {
@ -69,5 +74,8 @@ func (wm *limitsWriteModel) NewChanges(setLimits *SetLimits) (changes []limits.L
if setLimits.AuditLogRetention != nil && (wm.auditLogRetention == nil || *wm.auditLogRetention != *setLimits.AuditLogRetention) { if setLimits.AuditLogRetention != nil && (wm.auditLogRetention == nil || *wm.auditLogRetention != *setLimits.AuditLogRetention) {
changes = append(changes, limits.ChangeAuditLogRetention(setLimits.AuditLogRetention)) changes = append(changes, limits.ChangeAuditLogRetention(setLimits.AuditLogRetention))
} }
if setLimits.Block != nil && (wm.block == nil || *wm.block != *setLimits.Block) {
changes = append(changes, limits.ChangeBlock(setLimits.Block))
}
return changes return changes
} }

View File

@ -21,9 +21,8 @@ import (
func TestLimits_SetLimits(t *testing.T) { func TestLimits_SetLimits(t *testing.T) {
type fields func(*testing.T) (*eventstore.Eventstore, id.Generator) type fields func(*testing.T) (*eventstore.Eventstore, id.Generator)
type args struct { type args struct {
ctx context.Context ctx context.Context
resourceOwner string setLimits *SetLimits
setLimits *SetLimits
} }
type res struct { type res struct {
want *domain.ObjectDetails want *domain.ObjectDetails
@ -47,7 +46,7 @@ func TestLimits_SetLimits(t *testing.T) {
limits.NewSetEvent( limits.NewSetEvent(
eventstore.NewBaseEventForPush( eventstore.NewBaseEventForPush(
context.Background(), context.Background(),
&limits.NewAggregate("limits1", "instance1", "instance1").Aggregate, &limits.NewAggregate("limits1", "instance1").Aggregate,
limits.SetEventType, limits.SetEventType,
), ),
limits.ChangeAuditLogRetention(gu.Ptr(time.Hour)), limits.ChangeAuditLogRetention(gu.Ptr(time.Hour)),
@ -58,8 +57,7 @@ func TestLimits_SetLimits(t *testing.T) {
id_mock.NewIDGeneratorExpectIDs(t, "limits1") id_mock.NewIDGeneratorExpectIDs(t, "limits1")
}, },
args: args{ args: args{
ctx: authz.WithInstanceID(context.Background(), "instance1"), ctx: authz.WithInstanceID(context.Background(), "instance1"),
resourceOwner: "instance1",
setLimits: &SetLimits{ setLimits: &SetLimits{
AuditLogRetention: gu.Ptr(time.Hour), AuditLogRetention: gu.Ptr(time.Hour),
}, },
@ -71,7 +69,7 @@ func TestLimits_SetLimits(t *testing.T) {
}, },
}, },
{ {
name: "update limits, ok", name: "update limits audit log retention, ok",
fields: func(*testing.T) (*eventstore.Eventstore, id.Generator) { fields: func(*testing.T) (*eventstore.Eventstore, id.Generator) {
return eventstoreExpect( return eventstoreExpect(
t, t,
@ -80,7 +78,7 @@ func TestLimits_SetLimits(t *testing.T) {
limits.NewSetEvent( limits.NewSetEvent(
eventstore.NewBaseEventForPush( eventstore.NewBaseEventForPush(
context.Background(), context.Background(),
&limits.NewAggregate("limits1", "instance1", "instance1").Aggregate, &limits.NewAggregate("limits1", "instance1").Aggregate,
limits.SetEventType, limits.SetEventType,
), ),
limits.ChangeAuditLogRetention(gu.Ptr(time.Minute)), limits.ChangeAuditLogRetention(gu.Ptr(time.Minute)),
@ -93,7 +91,7 @@ func TestLimits_SetLimits(t *testing.T) {
limits.NewSetEvent( limits.NewSetEvent(
eventstore.NewBaseEventForPush( eventstore.NewBaseEventForPush(
context.Background(), context.Background(),
&limits.NewAggregate("limits1", "instance1", "instance1").Aggregate, &limits.NewAggregate("limits1", "instance1").Aggregate,
limits.SetEventType, limits.SetEventType,
), ),
limits.ChangeAuditLogRetention(gu.Ptr(time.Hour)), limits.ChangeAuditLogRetention(gu.Ptr(time.Hour)),
@ -104,8 +102,7 @@ func TestLimits_SetLimits(t *testing.T) {
nil nil
}, },
args: args{ args: args{
ctx: authz.WithInstanceID(context.Background(), "instance1"), ctx: authz.WithInstanceID(context.Background(), "instance1"),
resourceOwner: "instance1",
setLimits: &SetLimits{ setLimits: &SetLimits{
AuditLogRetention: gu.Ptr(time.Hour), AuditLogRetention: gu.Ptr(time.Hour),
}, },
@ -116,6 +113,52 @@ func TestLimits_SetLimits(t *testing.T) {
}, },
}, },
}, },
{
name: "update limits unblock, ok",
fields: func(*testing.T) (*eventstore.Eventstore, id.Generator) {
return eventstoreExpect(
t,
expectFilter(
eventFromEventPusher(
limits.NewSetEvent(
eventstore.NewBaseEventForPush(
context.Background(),
&limits.NewAggregate("limits1", "instance1").Aggregate,
limits.SetEventType,
),
limits.ChangeAuditLogRetention(gu.Ptr(time.Minute)),
limits.ChangeBlock(gu.Ptr(true)),
),
),
),
expectPush(
eventFromEventPusherWithInstanceID(
"instance1",
limits.NewSetEvent(
eventstore.NewBaseEventForPush(
context.Background(),
&limits.NewAggregate("limits1", "instance1").Aggregate,
limits.SetEventType,
),
limits.ChangeBlock(gu.Ptr(false)),
),
),
),
),
nil
},
args: args{
ctx: authz.WithInstanceID(context.Background(), "instance1"),
setLimits: &SetLimits{
Block: gu.Ptr(false),
},
},
res: res{
want: &domain.ObjectDetails{
ResourceOwner: "instance1",
},
},
},
{ {
name: "set limits after resetting limits, ok", name: "set limits after resetting limits, ok",
fields: func(*testing.T) (*eventstore.Eventstore, id.Generator) { fields: func(*testing.T) (*eventstore.Eventstore, id.Generator) {
@ -126,7 +169,7 @@ func TestLimits_SetLimits(t *testing.T) {
limits.NewSetEvent( limits.NewSetEvent(
eventstore.NewBaseEventForPush( eventstore.NewBaseEventForPush(
context.Background(), context.Background(),
&limits.NewAggregate("limits1", "instance1", "instance1").Aggregate, &limits.NewAggregate("limits1", "instance1").Aggregate,
limits.SetEventType, limits.SetEventType,
), ),
limits.ChangeAuditLogRetention(gu.Ptr(time.Hour)), limits.ChangeAuditLogRetention(gu.Ptr(time.Hour)),
@ -135,7 +178,7 @@ func TestLimits_SetLimits(t *testing.T) {
eventFromEventPusher( eventFromEventPusher(
limits.NewResetEvent( limits.NewResetEvent(
context.Background(), context.Background(),
&limits.NewAggregate("limits1", "instance1", "instance1").Aggregate, &limits.NewAggregate("limits1", "instance1").Aggregate,
), ),
), ),
), ),
@ -145,7 +188,7 @@ func TestLimits_SetLimits(t *testing.T) {
limits.NewSetEvent( limits.NewSetEvent(
eventstore.NewBaseEventForPush( eventstore.NewBaseEventForPush(
context.Background(), context.Background(),
&limits.NewAggregate("limits2", "instance1", "instance1").Aggregate, &limits.NewAggregate("limits2", "instance1").Aggregate,
limits.SetEventType, limits.SetEventType,
), ),
limits.ChangeAuditLogRetention(gu.Ptr(time.Hour)), limits.ChangeAuditLogRetention(gu.Ptr(time.Hour)),
@ -156,8 +199,7 @@ func TestLimits_SetLimits(t *testing.T) {
id_mock.NewIDGeneratorExpectIDs(t, "limits2") id_mock.NewIDGeneratorExpectIDs(t, "limits2")
}, },
args: args{ args: args{
ctx: authz.WithInstanceID(context.Background(), "instance1"), ctx: authz.WithInstanceID(context.Background(), "instance1"),
resourceOwner: "instance1",
setLimits: &SetLimits{ setLimits: &SetLimits{
AuditLogRetention: gu.Ptr(time.Hour), AuditLogRetention: gu.Ptr(time.Hour),
}, },
@ -173,7 +215,7 @@ func TestLimits_SetLimits(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
r := new(Commands) r := new(Commands)
r.eventstore, r.idGenerator = tt.fields(t) r.eventstore, r.idGenerator = tt.fields(t)
got, err := r.SetLimits(tt.args.ctx, tt.args.resourceOwner, tt.args.setLimits) got, err := r.SetLimits(tt.args.ctx, tt.args.setLimits)
if tt.res.err == nil { if tt.res.err == nil {
assert.NoError(t, err) assert.NoError(t, err)
} }
@ -187,11 +229,414 @@ func TestLimits_SetLimits(t *testing.T) {
} }
} }
func TestLimits_SetLimitsBulk(t *testing.T) {
type fields func(*testing.T) (*eventstore.Eventstore, id.Generator)
type args struct {
ctx context.Context
setLimitsBulk []*SetInstanceLimitsBulk
}
type res struct {
want *domain.ObjectDetails
wantTarget []*domain.ObjectDetails
err func(error) bool
}
tests := []struct {
name string
fields fields
args args
res res
}{
{
name: "create limits, ok",
fields: func(*testing.T) (*eventstore.Eventstore, id.Generator) {
return eventstoreExpect(
t,
expectFilter(),
expectPush(
eventFromEventPusherWithInstanceID(
"instance1",
limits.NewSetEvent(
eventstore.NewBaseEventForPush(
context.Background(),
&limits.NewAggregate("limits1", "instance1").Aggregate,
limits.SetEventType,
),
limits.ChangeAuditLogRetention(gu.Ptr(time.Hour)),
),
),
),
),
id_mock.NewIDGeneratorExpectIDs(t, "limits1")
},
args: args{
ctx: authz.WithInstanceID(context.Background(), "instance1"),
setLimitsBulk: []*SetInstanceLimitsBulk{{
InstanceID: "instance1",
SetLimits: SetLimits{
AuditLogRetention: gu.Ptr(time.Hour),
},
}},
},
res: res{
want: &domain.ObjectDetails{},
wantTarget: []*domain.ObjectDetails{{
ResourceOwner: "instance1",
}},
},
},
{
name: "update limits audit log retention, ok",
fields: func(*testing.T) (*eventstore.Eventstore, id.Generator) {
return eventstoreExpect(
t,
expectFilter(
eventFromEventPusher(
limits.NewSetEvent(
eventstore.NewBaseEventForPush(
context.Background(),
&limits.NewAggregate("limits1", "instance1").Aggregate,
limits.SetEventType,
),
limits.ChangeAuditLogRetention(gu.Ptr(time.Minute)),
),
),
),
expectPush(
eventFromEventPusherWithInstanceID(
"instance1",
limits.NewSetEvent(
eventstore.NewBaseEventForPush(
context.Background(),
&limits.NewAggregate("limits1", "instance1").Aggregate,
limits.SetEventType,
),
limits.ChangeAuditLogRetention(gu.Ptr(time.Hour)),
),
),
),
),
nil
},
args: args{
ctx: authz.WithInstanceID(context.Background(), "instance1"),
setLimitsBulk: []*SetInstanceLimitsBulk{{
InstanceID: "instance1",
SetLimits: SetLimits{
AuditLogRetention: gu.Ptr(time.Hour),
},
}},
},
res: res{
want: &domain.ObjectDetails{},
wantTarget: []*domain.ObjectDetails{{
ResourceOwner: "instance1",
}},
},
},
{
name: "update limits unblock, ok",
fields: func(*testing.T) (*eventstore.Eventstore, id.Generator) {
return eventstoreExpect(
t,
expectFilter(
eventFromEventPusher(
limits.NewSetEvent(
eventstore.NewBaseEventForPush(
context.Background(),
&limits.NewAggregate("limits1", "instance1").Aggregate,
limits.SetEventType,
),
limits.ChangeAuditLogRetention(gu.Ptr(time.Minute)),
limits.ChangeBlock(gu.Ptr(true)),
),
),
),
expectPush(
eventFromEventPusherWithInstanceID(
"instance1",
limits.NewSetEvent(
eventstore.NewBaseEventForPush(
context.Background(),
&limits.NewAggregate("limits1", "instance1").Aggregate,
limits.SetEventType,
),
limits.ChangeBlock(gu.Ptr(false)),
),
),
),
),
nil
},
args: args{
ctx: authz.WithInstanceID(context.Background(), "instance1"),
setLimitsBulk: []*SetInstanceLimitsBulk{{
InstanceID: "instance1",
SetLimits: SetLimits{
Block: gu.Ptr(false),
},
}},
},
res: res{
want: &domain.ObjectDetails{},
wantTarget: []*domain.ObjectDetails{{
ResourceOwner: "instance1",
}},
},
},
{
name: "set limits after resetting limits, ok",
fields: func(*testing.T) (*eventstore.Eventstore, id.Generator) {
return eventstoreExpect(
t,
expectFilter(
eventFromEventPusher(
limits.NewSetEvent(
eventstore.NewBaseEventForPush(
context.Background(),
&limits.NewAggregate("limits1", "instance1").Aggregate,
limits.SetEventType,
),
limits.ChangeAuditLogRetention(gu.Ptr(time.Hour)),
),
),
eventFromEventPusher(
limits.NewResetEvent(
context.Background(),
&limits.NewAggregate("limits1", "instance1").Aggregate,
),
),
),
expectPush(
eventFromEventPusherWithInstanceID(
"instance1",
limits.NewSetEvent(
eventstore.NewBaseEventForPush(
context.Background(),
&limits.NewAggregate("limits2", "instance1").Aggregate,
limits.SetEventType,
),
limits.ChangeAuditLogRetention(gu.Ptr(time.Hour)),
),
),
),
),
id_mock.NewIDGeneratorExpectIDs(t, "limits2")
},
args: args{
ctx: authz.WithInstanceID(context.Background(), "instance1"),
setLimitsBulk: []*SetInstanceLimitsBulk{{
InstanceID: "instance1",
SetLimits: SetLimits{
AuditLogRetention: gu.Ptr(time.Hour),
},
},
},
},
res: res{
want: &domain.ObjectDetails{},
wantTarget: []*domain.ObjectDetails{{
ResourceOwner: "instance1",
}},
},
},
{
name: "set many limits, ok",
fields: func(*testing.T) (*eventstore.Eventstore, id.Generator) {
return eventstoreExpect(
t,
expectFilter(
eventFromEventPusher(
limits.NewSetEvent(
eventstore.NewBaseEventForPush(
context.Background(),
&limits.NewAggregate("add-block", "instance1").Aggregate,
limits.SetEventType,
),
limits.ChangeAuditLogRetention(gu.Ptr(time.Hour)),
),
),
eventFromEventPusher(
limits.NewSetEvent(
eventstore.NewBaseEventForPush(
context.Background(),
&limits.NewAggregate("switch-to-block", "instance2").Aggregate,
limits.SetEventType,
),
limits.ChangeBlock(gu.Ptr(false)),
),
),
eventFromEventPusher(
limits.NewSetEvent(
eventstore.NewBaseEventForPush(
context.Background(),
&limits.NewAggregate("already-blocked", "instance3").Aggregate,
limits.SetEventType,
),
limits.ChangeBlock(gu.Ptr(true)),
),
),
eventFromEventPusher(
limits.NewSetEvent(
eventstore.NewBaseEventForPush(
context.Background(),
&limits.NewAggregate("unblock", "instance4").Aggregate,
limits.SetEventType,
),
limits.ChangeBlock(gu.Ptr(true)),
),
),
eventFromEventPusher(
limits.NewSetEvent(
eventstore.NewBaseEventForPush(
context.Background(),
&limits.NewAggregate("relimit", "instance5").Aggregate,
limits.SetEventType,
),
limits.ChangeBlock(gu.Ptr(true)),
),
),
eventFromEventPusher(
limits.NewResetEvent(
context.Background(),
&limits.NewAggregate("relimit", "instance5").Aggregate,
),
),
),
expectPush(
eventFromEventPusherWithInstanceID(
"instance0",
limits.NewSetEvent(
eventstore.NewBaseEventForPush(
context.Background(),
&limits.NewAggregate("create-limits", "instance0").Aggregate,
limits.SetEventType,
),
limits.ChangeBlock(gu.Ptr(true)),
),
),
eventFromEventPusherWithInstanceID(
"instance1",
limits.NewSetEvent(
eventstore.NewBaseEventForPush(
context.Background(),
&limits.NewAggregate("add-block", "instance1").Aggregate,
limits.SetEventType,
),
limits.ChangeBlock(gu.Ptr(true)),
),
),
eventFromEventPusherWithInstanceID(
"instance2",
limits.NewSetEvent(
eventstore.NewBaseEventForPush(
context.Background(),
&limits.NewAggregate("switch-to-block", "instance2").Aggregate,
limits.SetEventType,
),
limits.ChangeBlock(gu.Ptr(true)),
),
),
eventFromEventPusherWithInstanceID(
"instance4",
limits.NewSetEvent(
eventstore.NewBaseEventForPush(
context.Background(),
&limits.NewAggregate("unblock", "instance4").Aggregate,
limits.SetEventType,
),
limits.ChangeBlock(gu.Ptr(false)),
),
),
eventFromEventPusherWithInstanceID(
"instance5",
limits.NewSetEvent(
eventstore.NewBaseEventForPush(
context.Background(),
&limits.NewAggregate("relimit", "instance5").Aggregate,
limits.SetEventType,
),
limits.ChangeBlock(gu.Ptr(true)),
),
),
),
),
id_mock.NewIDGeneratorExpectIDs(t, "create-limits", "relimit")
},
args: args{
ctx: context.Background(),
setLimitsBulk: []*SetInstanceLimitsBulk{{
InstanceID: "instance0",
SetLimits: SetLimits{
Block: gu.Ptr(true),
},
}, {
InstanceID: "instance1",
SetLimits: SetLimits{
Block: gu.Ptr(true),
},
}, {
InstanceID: "instance2",
SetLimits: SetLimits{
Block: gu.Ptr(true),
},
}, {
InstanceID: "instance3",
SetLimits: SetLimits{
Block: gu.Ptr(true),
},
}, {
InstanceID: "instance4",
SetLimits: SetLimits{
Block: gu.Ptr(false),
},
}, {
InstanceID: "instance5",
SetLimits: SetLimits{
Block: gu.Ptr(true),
},
}},
},
res: res{
want: &domain.ObjectDetails{},
wantTarget: []*domain.ObjectDetails{{
ResourceOwner: "instance0",
}, {
ResourceOwner: "instance1",
}, {
ResourceOwner: "instance2",
}, {
ResourceOwner: "instance3",
}, {
ResourceOwner: "instance4",
}, {
ResourceOwner: "instance5",
}},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := new(Commands)
r.eventstore, r.idGenerator = tt.fields(t)
gotDetails, gotTargetDetails, err := r.SetInstanceLimitsBulk(tt.args.ctx, tt.args.setLimitsBulk)
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, gotDetails)
assert.Equal(t, tt.res.wantTarget, gotTargetDetails)
}
})
}
}
func TestLimits_ResetLimits(t *testing.T) { func TestLimits_ResetLimits(t *testing.T) {
type fields func(*testing.T) *eventstore.Eventstore type fields func(*testing.T) *eventstore.Eventstore
type args struct { type args struct {
ctx context.Context ctx context.Context
resourceOwner string
} }
type res struct { type res struct {
want *domain.ObjectDetails want *domain.ObjectDetails
@ -212,8 +657,7 @@ func TestLimits_ResetLimits(t *testing.T) {
) )
}, },
args: args{ args: args{
ctx: authz.WithInstanceID(context.Background(), "instance1"), ctx: authz.WithInstanceID(context.Background(), "instance1"),
resourceOwner: "instance1",
}, },
res: res{ res: res{
err: func(err error) bool { err: func(err error) bool {
@ -231,7 +675,7 @@ func TestLimits_ResetLimits(t *testing.T) {
limits.NewSetEvent( limits.NewSetEvent(
eventstore.NewBaseEventForPush( eventstore.NewBaseEventForPush(
context.Background(), context.Background(),
&limits.NewAggregate("limits1", "instance1", "instance1").Aggregate, &limits.NewAggregate("limits1", "instance1").Aggregate,
limits.SetEventType, limits.SetEventType,
), ),
limits.ChangeAuditLogRetention(gu.Ptr(time.Hour)), limits.ChangeAuditLogRetention(gu.Ptr(time.Hour)),
@ -239,15 +683,14 @@ func TestLimits_ResetLimits(t *testing.T) {
), ),
eventFromEventPusher( eventFromEventPusher(
limits.NewResetEvent(context.Background(), limits.NewResetEvent(context.Background(),
&limits.NewAggregate("limits1", "instance1", "instance1").Aggregate, &limits.NewAggregate("limits1", "instance1").Aggregate,
), ),
), ),
), ),
) )
}, },
args: args{ args: args{
ctx: authz.WithInstanceID(context.Background(), "instance1"), ctx: authz.WithInstanceID(context.Background(), "instance1"),
resourceOwner: "instance1",
}, },
res: res{ res: res{
err: func(err error) bool { err: func(err error) bool {
@ -265,7 +708,7 @@ func TestLimits_ResetLimits(t *testing.T) {
limits.NewSetEvent( limits.NewSetEvent(
eventstore.NewBaseEventForPush( eventstore.NewBaseEventForPush(
context.Background(), context.Background(),
&limits.NewAggregate("limits1", "instance1", "instance1").Aggregate, &limits.NewAggregate("limits1", "instance1").Aggregate,
limits.SetEventType, limits.SetEventType,
), ),
limits.ChangeAuditLogRetention(gu.Ptr(time.Hour)), limits.ChangeAuditLogRetention(gu.Ptr(time.Hour)),
@ -276,15 +719,14 @@ func TestLimits_ResetLimits(t *testing.T) {
eventFromEventPusherWithInstanceID( eventFromEventPusherWithInstanceID(
"instance1", "instance1",
limits.NewResetEvent(context.Background(), limits.NewResetEvent(context.Background(),
&limits.NewAggregate("limits1", "instance1", "instance1").Aggregate, &limits.NewAggregate("limits1", "instance1").Aggregate,
), ),
), ),
), ),
) )
}, },
args: args{ args: args{
ctx: authz.WithInstanceID(context.Background(), "instance1"), ctx: authz.WithInstanceID(context.Background(), "instance1"),
resourceOwner: "instance1",
}, },
res: res{ res: res{
want: &domain.ObjectDetails{ want: &domain.ObjectDetails{
@ -298,7 +740,7 @@ func TestLimits_ResetLimits(t *testing.T) {
r := &Commands{ r := &Commands{
eventstore: tt.fields(t), eventstore: tt.fields(t),
} }
got, err := r.ResetLimits(tt.args.ctx, tt.args.resourceOwner) got, err := r.ResetLimits(tt.args.ctx)
if tt.res.err == nil { if tt.res.err == nil {
assert.NoError(t, err) assert.NoError(t, err)
} }

View File

@ -203,6 +203,14 @@ func GetMockSecretGenerator(t *testing.T) crypto.Generator {
type mockInstance struct{} type mockInstance struct{}
func (m *mockInstance) Block() *bool {
panic("shouldn't be called here")
}
func (m *mockInstance) AuditLogRetention() *time.Duration {
panic("shouldn't be called here")
}
func (m *mockInstance) InstanceID() string { func (m *mockInstance) InstanceID() string {
return "INSTANCE" return "INSTANCE"
} }

View File

@ -21,6 +21,7 @@ type SearchQuery struct {
Desc bool Desc bool
InstanceID *Filter InstanceID *Filter
InstanceIDs *Filter
ExcludedInstances *Filter ExcludedInstances *Filter
Creator *Filter Creator *Filter
Owner *Filter Owner *Filter
@ -132,6 +133,7 @@ func QueryFromBuilder(builder *eventstore.SearchQueryBuilder) (*SearchQuery, err
for _, f := range []func(builder *eventstore.SearchQueryBuilder, query *SearchQuery) *Filter{ for _, f := range []func(builder *eventstore.SearchQueryBuilder, query *SearchQuery) *Filter{
instanceIDFilter, instanceIDFilter,
instanceIDsFilter,
excludedInstanceIDFilter, excludedInstanceIDFilter,
editorUserFilter, editorUserFilter,
resourceOwnerFilter, resourceOwnerFilter,
@ -230,6 +232,14 @@ func instanceIDFilter(builder *eventstore.SearchQueryBuilder, query *SearchQuery
return query.InstanceID return query.InstanceID
} }
func instanceIDsFilter(builder *eventstore.SearchQueryBuilder, query *SearchQuery) *Filter {
if builder.GetInstanceIDs() == nil {
return nil
}
query.InstanceIDs = NewFilter(FieldInstanceID, builder.GetInstanceIDs(), OperationIn)
return query.InstanceIDs
}
func positionAfterFilter(builder *eventstore.SearchQueryBuilder, query *SearchQuery) *Filter { func positionAfterFilter(builder *eventstore.SearchQueryBuilder, query *SearchQuery) *Filter {
if builder.GetPositionAfter() == 0 { if builder.GetPositionAfter() == 0 {
return nil return nil

View File

@ -18,6 +18,7 @@ type SearchQueryBuilder struct {
desc bool desc bool
resourceOwner string resourceOwner string
instanceID *string instanceID *string
instanceIDs []string
excludedInstanceIDs []string excludedInstanceIDs []string
editorUser string editorUser string
queries []*SearchQuery queries []*SearchQuery
@ -54,6 +55,10 @@ func (b *SearchQueryBuilder) GetInstanceID() *string {
return b.instanceID return b.instanceID
} }
func (b *SearchQueryBuilder) GetInstanceIDs() []string {
return b.instanceIDs
}
func (b *SearchQueryBuilder) GetEditorUser() string { func (b *SearchQueryBuilder) GetEditorUser() string {
return b.editorUser return b.editorUser
} }
@ -96,7 +101,7 @@ func (q SearchQueryBuilder) GetCreationDateBefore() time.Time {
// ensureInstanceID makes sure that the instance id is always set // ensureInstanceID makes sure that the instance id is always set
func (b *SearchQueryBuilder) ensureInstanceID(ctx context.Context) { func (b *SearchQueryBuilder) ensureInstanceID(ctx context.Context) {
if b.instanceID == nil && authz.GetInstance(ctx).InstanceID() != "" { if b.instanceID == nil && len(b.instanceIDs) == 0 && authz.GetInstance(ctx).InstanceID() != "" {
b.InstanceID(authz.GetInstance(ctx).InstanceID()) b.InstanceID(authz.GetInstance(ctx).InstanceID())
} }
} }
@ -218,7 +223,7 @@ func (builder *SearchQueryBuilder) Offset(offset uint32) *SearchQueryBuilder {
return builder return builder
} }
// ResourceOwner defines the resource owner (org) of the events // ResourceOwner defines the resource owner (org or instance) of the events
func (builder *SearchQueryBuilder) ResourceOwner(resourceOwner string) *SearchQueryBuilder { func (builder *SearchQueryBuilder) ResourceOwner(resourceOwner string) *SearchQueryBuilder {
builder.resourceOwner = resourceOwner builder.resourceOwner = resourceOwner
return builder return builder
@ -230,6 +235,12 @@ func (builder *SearchQueryBuilder) InstanceID(instanceID string) *SearchQueryBui
return builder return builder
} }
// InstanceIDs defines the instanceIDs (system) of the events
func (builder *SearchQueryBuilder) InstanceIDs(instanceIDs []string) *SearchQueryBuilder {
builder.instanceIDs = instanceIDs
return builder
}
// OrderDesc changes the sorting order of the returned events to descending // OrderDesc changes the sorting order of the returned events to descending
func (builder *SearchQueryBuilder) OrderDesc() *SearchQueryBuilder { func (builder *SearchQueryBuilder) OrderDesc() *SearchQueryBuilder {
builder.desc = true builder.desc = true

View File

@ -14,6 +14,7 @@ services:
- PGUSER=zitadel - PGUSER=zitadel
- POSTGRES_DB=zitadel - POSTGRES_DB=zitadel
- POSTGRES_HOST_AUTH_METHOD=trust - POSTGRES_HOST_AUTH_METHOD=trust
command: postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready"] test: ["CMD-SHELL", "pg_isready"]
interval: '10s' interval: '10s'

View File

@ -26,14 +26,6 @@ LogStore:
Stdout: Stdout:
Enabled: true Enabled: true
Quotas:
Access:
Enabled: true
ExhaustedCookieKey: "zitadel.quota.limiting"
ExhaustedCookieMaxAge: "60s"
Execution:
Enabled: true
Projections: Projections:
HandleActiveInstances: 60s HandleActiveInstances: 60s
Customizations: Customizations:

View File

@ -261,6 +261,14 @@ func (s *Tester) WithAuthorizationToken(ctx context.Context, token string) conte
return metadata.NewOutgoingContext(ctx, md) return metadata.NewOutgoingContext(ctx, md)
} }
func (s *Tester) BearerToken(ctx context.Context) string {
md, ok := metadata.FromOutgoingContext(ctx)
if !ok {
return ""
}
return md.Get("Authorization")[0]
}
func (s *Tester) ensureSystemUser() { func (s *Tester) ensureSystemUser() {
const ISSUER = "tester" const ISSUER = "tester"
if s.Users.Get(FirstInstanceUsersKey, SystemUser) != nil { if s.Users.Get(FirstInstanceUsersKey, SystemUser) != nil {
@ -303,16 +311,18 @@ func (s *Tester) Done() {
// //
// Note: the database must already be setup and initialized before // Note: the database must already be setup and initialized before
// using NewTester. See the CONTRIBUTING.md document for details. // using NewTester. See the CONTRIBUTING.md document for details.
func NewTester(ctx context.Context) *Tester { func NewTester(ctx context.Context, zitadelConfigYAML ...string) *Tester {
args := strings.Split(commandLine, " ") args := strings.Split(commandLine, " ")
sc := make(chan *start.Server) sc := make(chan *start.Server)
//nolint:contextcheck //nolint:contextcheck
cmd := cmd.New(os.Stdout, os.Stdin, args, sc) cmd := cmd.New(os.Stdout, os.Stdin, args, sc)
cmd.SetArgs(args) cmd.SetArgs(args)
err := viper.MergeConfig(bytes.NewBuffer(zitadelYAML)) for _, yaml := range append([]string{string(zitadelYAML)}, zitadelConfigYAML...) {
logging.OnError(err).Fatal() err := viper.MergeConfig(bytes.NewBuffer([]byte(yaml)))
logging.OnError(err).Fatal()
}
var err error
flavor := os.Getenv("INTEGRATION_DB_FLAVOR") flavor := os.Getenv("INTEGRATION_DB_FLAVOR")
switch flavor { switch flavor {
case "cockroach", "": case "cockroach", "":

View File

@ -23,7 +23,11 @@ func TestMain(m *testing.M) {
defer cancel() defer cancel()
CTX = ctx CTX = ctx
Tester = integration.NewTester(ctx) Tester = integration.NewTester(ctx, `
Quotas:
Access:
Enabled: true
`)
defer Tester.Done() defer Tester.Done()
SystemCTX = Tester.WithAuthorization(ctx, integration.SystemUser) SystemCTX = Tester.WithAuthorization(ctx, integration.SystemUser)

View File

@ -8,7 +8,6 @@ import (
"github.com/zitadel/zitadel/internal/api/call" "github.com/zitadel/zitadel/internal/api/call"
"github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/telemetry/tracing" "github.com/zitadel/zitadel/internal/telemetry/tracing"
"github.com/zitadel/zitadel/internal/zerrors"
) )
type Event struct { type Event struct {
@ -44,12 +43,8 @@ func (q *Queries) SearchEvents(ctx context.Context, query *eventstore.SearchQuer
ctx, span := tracing.NewSpan(ctx) ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }() defer func() { span.EndWithError(err) }()
auditLogRetention := q.defaultAuditLogRetention auditLogRetention := q.defaultAuditLogRetention
instanceLimits, err := q.Limits(ctx, authz.GetInstance(ctx).InstanceID()) if instanceAuditLogRetention := authz.GetInstance(ctx).AuditLogRetention(); instanceAuditLogRetention != nil {
if err != nil && !zerrors.IsNotFound(err) { auditLogRetention = *instanceAuditLogRetention
return nil, err
}
if instanceLimits != nil && instanceLimits.AuditLogRetention != nil {
auditLogRetention = *instanceLimits.AuditLogRetention
} }
if auditLogRetention != 0 { if auditLogRetention != 0 {
query = filterAuditLogRetention(ctx, auditLogRetention, query) query = filterAuditLogRetention(ctx, auditLogRetention, query)

View File

@ -29,6 +29,10 @@ var (
name: projection.InstanceProjectionTable, name: projection.InstanceProjectionTable,
instanceIDCol: projection.InstanceColumnID, instanceIDCol: projection.InstanceColumnID,
} }
limitsTable = table{
name: projection.LimitsProjectionTable,
instanceIDCol: projection.LimitsColumnInstanceID,
}
InstanceColumnID = Column{ InstanceColumnID = Column{
name: projection.InstanceColumnID, name: projection.InstanceColumnID,
table: instanceTable, table: instanceTable,
@ -69,6 +73,18 @@ var (
name: projection.InstanceColumnDefaultLanguage, name: projection.InstanceColumnDefaultLanguage,
table: instanceTable, table: instanceTable,
} }
LimitsColumnInstanceID = Column{
name: projection.LimitsColumnInstanceID,
table: limitsTable,
}
LimitsColumnAuditLogRetention = Column{
name: projection.LimitsColumnAuditLogRetention,
table: limitsTable,
}
LimitsColumnBlock = Column{
name: projection.LimitsColumnBlock,
table: limitsTable,
}
) )
type Instance struct { type Instance struct {
@ -78,14 +94,16 @@ type Instance struct {
Sequence uint64 Sequence uint64
Name string Name string
DefaultOrgID string DefaultOrgID string
IAMProjectID string IAMProjectID string
ConsoleID string ConsoleID string
ConsoleAppID string ConsoleAppID string
DefaultLang language.Tag DefaultLang language.Tag
Domains []*InstanceDomain Domains []*InstanceDomain
host string host string
csp csp csp csp
block *bool
auditLogRetention *time.Duration
} }
type csp struct { type csp struct {
@ -137,6 +155,14 @@ func (i *Instance) SecurityPolicyAllowedOrigins() []string {
return i.csp.allowedOrigins return i.csp.allowedOrigins
} }
func (i *Instance) Block() *bool {
return i.block
}
func (i *Instance) AuditLogRetention() *time.Duration {
return i.auditLogRetention
}
type InstanceSearchQueries struct { type InstanceSearchQueries struct {
SearchRequest SearchRequest
Queries []SearchQuery Queries []SearchQuery
@ -260,8 +286,10 @@ func prepareInstanceQuery(ctx context.Context, db prepareDatabase, host string)
From(instanceTable.identifier() + db.Timetravel(call.Took(ctx))). From(instanceTable.identifier() + db.Timetravel(call.Took(ctx))).
PlaceholderFormat(sq.Dollar), PlaceholderFormat(sq.Dollar),
func(row *sql.Row) (*Instance, error) { func(row *sql.Row) (*Instance, error) {
instance := &Instance{host: host} var (
lang := "" instance = &Instance{host: host}
lang = ""
)
err := row.Scan( err := row.Scan(
&instance.ID, &instance.ID,
&instance.CreationDate, &instance.CreationDate,
@ -491,10 +519,13 @@ func prepareAuthzInstanceQuery(ctx context.Context, db prepareDatabase, host str
InstanceDomainSequenceCol.identifier(), InstanceDomainSequenceCol.identifier(),
SecurityPolicyColumnEnabled.identifier(), SecurityPolicyColumnEnabled.identifier(),
SecurityPolicyColumnAllowedOrigins.identifier(), SecurityPolicyColumnAllowedOrigins.identifier(),
LimitsColumnAuditLogRetention.identifier(),
LimitsColumnBlock.identifier(),
). ).
From(instanceTable.identifier()). From(instanceTable.identifier()).
LeftJoin(join(InstanceDomainInstanceIDCol, InstanceColumnID)). LeftJoin(join(InstanceDomainInstanceIDCol, InstanceColumnID)).
LeftJoin(join(SecurityPolicyColumnInstanceID, InstanceColumnID) + db.Timetravel(call.Took(ctx))). LeftJoin(join(SecurityPolicyColumnInstanceID, InstanceColumnID)).
LeftJoin(join(LimitsColumnInstanceID, InstanceColumnID) + db.Timetravel(call.Took(ctx))).
PlaceholderFormat(sq.Dollar), PlaceholderFormat(sq.Dollar),
func(rows *sql.Rows) (*Instance, error) { func(rows *sql.Rows) (*Instance, error) {
instance := &Instance{ instance := &Instance{
@ -511,6 +542,8 @@ func prepareAuthzInstanceQuery(ctx context.Context, db prepareDatabase, host str
creationDate sql.NullTime creationDate sql.NullTime
sequence sql.NullInt64 sequence sql.NullInt64
securityPolicyEnabled sql.NullBool securityPolicyEnabled sql.NullBool
auditLogRetention database.NullDuration
block sql.NullBool
) )
err := rows.Scan( err := rows.Scan(
&instance.ID, &instance.ID,
@ -531,6 +564,8 @@ func prepareAuthzInstanceQuery(ctx context.Context, db prepareDatabase, host str
&sequence, &sequence,
&securityPolicyEnabled, &securityPolicyEnabled,
&instance.csp.allowedOrigins, &instance.csp.allowedOrigins,
&auditLogRetention,
&block,
) )
if err != nil { if err != nil {
return nil, zerrors.ThrowInternal(err, "QUERY-d3fas", "Errors.Internal") return nil, zerrors.ThrowInternal(err, "QUERY-d3fas", "Errors.Internal")
@ -547,6 +582,12 @@ func prepareAuthzInstanceQuery(ctx context.Context, db prepareDatabase, host str
IsGenerated: isGenerated.Bool, IsGenerated: isGenerated.Bool,
InstanceID: instance.ID, InstanceID: instance.ID,
}) })
if auditLogRetention.Valid {
instance.auditLogRetention = &auditLogRetention.Duration
}
if block.Valid {
instance.block = &block.Bool
}
instance.csp.enabled = securityPolicyEnabled.Bool instance.csp.enabled = securityPolicyEnabled.Bool
} }
if instance.ID == "" { if instance.ID == "" {

View File

@ -1,119 +0,0 @@
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"
"github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/query/projection"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
"github.com/zitadel/zitadel/internal/zerrors"
)
var (
limitSettingsTable = table{
name: projection.LimitsProjectionTable,
instanceIDCol: projection.LimitsColumnInstanceID,
}
LimitsColumnAggregateID = Column{
name: projection.LimitsColumnAggregateID,
table: limitSettingsTable,
}
LimitsColumnCreationDate = Column{
name: projection.LimitsColumnCreationDate,
table: limitSettingsTable,
}
LimitsColumnChangeDate = Column{
name: projection.LimitsColumnChangeDate,
table: limitSettingsTable,
}
LimitsColumnResourceOwner = Column{
name: projection.LimitsColumnResourceOwner,
table: limitSettingsTable,
}
LimitsColumnInstanceID = Column{
name: projection.LimitsColumnInstanceID,
table: limitSettingsTable,
}
LimitsColumnSequence = Column{
name: projection.LimitsColumnSequence,
table: limitSettingsTable,
}
LimitsColumnAuditLogRetention = Column{
name: projection.LimitsColumnAuditLogRetention,
table: limitSettingsTable,
}
)
type Limits struct {
AggregateID string
CreationDate time.Time
ChangeDate time.Time
ResourceOwner string
Sequence uint64
AuditLogRetention *time.Duration
}
func (q *Queries) Limits(ctx context.Context, resourceOwner string) (limits *Limits, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
stmt, scan := prepareLimitsQuery(ctx, q.client)
query, args, err := stmt.Where(sq.Eq{
LimitsColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(),
LimitsColumnResourceOwner.identifier(): resourceOwner,
}).ToSql()
if err != nil {
return nil, zerrors.ThrowInternal(err, "QUERY-jJe80", "Errors.Query.SQLStatment")
}
err = q.client.QueryRowContext(ctx, func(row *sql.Row) error {
limits, err = scan(row)
return err
}, query, args...)
return limits, err
}
func prepareLimitsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (*Limits, error)) {
return sq.Select(
LimitsColumnAggregateID.identifier(),
LimitsColumnCreationDate.identifier(),
LimitsColumnChangeDate.identifier(),
LimitsColumnResourceOwner.identifier(),
LimitsColumnSequence.identifier(),
LimitsColumnAuditLogRetention.identifier(),
).
From(limitSettingsTable.identifier() + db.Timetravel(call.Took(ctx))).
PlaceholderFormat(sq.Dollar),
func(row *sql.Row) (*Limits, error) {
var (
limits = new(Limits)
auditLogRetention database.NullDuration
)
err := row.Scan(
&limits.AggregateID,
&limits.CreationDate,
&limits.ChangeDate,
&limits.ResourceOwner,
&limits.Sequence,
&auditLogRetention,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, zerrors.ThrowNotFound(err, "QUERY-GU1em", "Errors.Limits.NotFound")
}
return nil, zerrors.ThrowInternal(err, "QUERY-00jgy", "Errors.Internal")
}
if auditLogRetention.Valid {
limits.AuditLogRetention = &auditLogRetention.Duration
}
return limits, nil
}
}

View File

@ -1,116 +0,0 @@
package query
import (
"database/sql"
"database/sql/driver"
"errors"
"fmt"
"regexp"
"testing"
"time"
"github.com/muhlemmer/gu"
"github.com/zitadel/zitadel/internal/zerrors"
)
var (
expectedLimitsQuery = regexp.QuoteMeta("SELECT projections.limits.aggregate_id," +
" projections.limits.creation_date," +
" projections.limits.change_date," +
" projections.limits.resource_owner," +
" projections.limits.sequence," +
" projections.limits.audit_log_retention" +
" FROM projections.limits" +
" AS OF SYSTEM TIME '-1 ms'",
)
limitsCols = []string{
"aggregate_id",
"creation_date",
"change_date",
"resource_owner",
"sequence",
"audit_log_retention",
}
)
func Test_LimitsPrepare(t *testing.T) {
type want struct {
sqlExpectations sqlExpectation
err checkErr
}
tests := []struct {
name string
prepare interface{}
want want
object interface{}
}{
{
name: "prepareLimitsQuery no result",
prepare: prepareLimitsQuery,
want: want{
sqlExpectations: mockQueriesScanErr(
expectedLimitsQuery,
nil,
nil,
),
err: func(err error) (error, bool) {
if !zerrors.IsNotFound(err) {
return fmt.Errorf("err should be zitadel.NotFoundError got: %w", err), false
}
return nil, true
},
},
object: (*Limits)(nil),
},
{
name: "prepareLimitsQuery",
prepare: prepareLimitsQuery,
want: want{
sqlExpectations: mockQuery(
expectedLimitsQuery,
limitsCols,
[]driver.Value{
"limits1",
testNow,
testNow,
"instance1",
0,
intervalDriverValue(t, time.Hour),
},
),
},
object: &Limits{
AggregateID: "limits1",
CreationDate: testNow,
ChangeDate: testNow,
ResourceOwner: "instance1",
Sequence: 0,
AuditLogRetention: gu.Ptr(time.Hour),
},
},
{
name: "prepareLimitsQuery sql err",
prepare: prepareLimitsQuery,
want: want{
sqlExpectations: mockQueryErr(
expectedLimitsQuery,
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: (*Limits)(nil),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assertPrepare(t, tt.prepare, tt.object, tt.want.sqlExpectations, tt.want.err, defaultPrepareArgs...)
})
}
}

View File

@ -21,6 +21,7 @@ const (
LimitsColumnSequence = "sequence" LimitsColumnSequence = "sequence"
LimitsColumnAuditLogRetention = "audit_log_retention" LimitsColumnAuditLogRetention = "audit_log_retention"
LimitsColumnBlock = "block"
) )
type limitsProjection struct{} type limitsProjection struct{}
@ -43,6 +44,7 @@ func (*limitsProjection) Init() *old_handler.Check {
handler.NewColumn(LimitsColumnInstanceID, handler.ColumnTypeText), handler.NewColumn(LimitsColumnInstanceID, handler.ColumnTypeText),
handler.NewColumn(LimitsColumnSequence, handler.ColumnTypeInt64), handler.NewColumn(LimitsColumnSequence, handler.ColumnTypeInt64),
handler.NewColumn(LimitsColumnAuditLogRetention, handler.ColumnTypeInterval, handler.Nullable()), handler.NewColumn(LimitsColumnAuditLogRetention, handler.ColumnTypeInterval, handler.Nullable()),
handler.NewColumn(LimitsColumnBlock, handler.ColumnTypeBool, handler.Nullable()),
}, },
handler.NewPrimaryKey(LimitsColumnInstanceID, LimitsColumnResourceOwner), handler.NewPrimaryKey(LimitsColumnInstanceID, LimitsColumnResourceOwner),
), ),
@ -96,6 +98,9 @@ func (p *limitsProjection) reduceLimitsSet(event eventstore.Event) (*handler.Sta
if e.AuditLogRetention != nil { if e.AuditLogRetention != nil {
updateCols = append(updateCols, handler.NewCol(LimitsColumnAuditLogRetention, *e.AuditLogRetention)) updateCols = append(updateCols, handler.NewCol(LimitsColumnAuditLogRetention, *e.AuditLogRetention))
} }
if e.Block != nil {
updateCols = append(updateCols, handler.NewCol(LimitsColumnBlock, *e.Block))
}
return handler.NewUpsertStatement(e, conflictCols, updateCols), nil return handler.NewUpsertStatement(e, conflictCols, updateCols), nil
} }

View File

@ -21,7 +21,7 @@ func TestLimitsProjection_reduces(t *testing.T) {
want wantReduce want wantReduce
}{ }{
{ {
name: "reduceLimitsSet", name: "reduceLimitsSet auditLogRetention",
args: args{ args: args{
event: getEvent(testEvent( event: getEvent(testEvent(
limits.SetEventType, limits.SetEventType,
@ -53,7 +53,107 @@ func TestLimitsProjection_reduces(t *testing.T) {
}, },
}, },
}, },
{
name: "reduceLimitsSet block true",
args: args{
event: getEvent(testEvent(
limits.SetEventType,
limits.AggregateType,
[]byte(`{
"block": true
}`),
), limits.SetEventMapper),
},
reduce: (&limitsProjection{}).reduceLimitsSet,
want: wantReduce{
aggregateType: eventstore.AggregateType("limits"),
sequence: 15,
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "INSERT INTO projections.limits (instance_id, resource_owner, creation_date, change_date, sequence, aggregate_id, block) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (instance_id, resource_owner) DO UPDATE SET (creation_date, change_date, sequence, aggregate_id, block) = (EXCLUDED.creation_date, EXCLUDED.change_date, EXCLUDED.sequence, EXCLUDED.aggregate_id, EXCLUDED.block)",
expectedArgs: []interface{}{
"instance-id",
"ro-id",
anyArg{},
anyArg{},
uint64(15),
"agg-id",
true,
},
},
},
},
},
},
{
name: "reduceLimitsSet block false",
args: args{
event: getEvent(testEvent(
limits.SetEventType,
limits.AggregateType,
[]byte(`{
"block": false
}`),
), limits.SetEventMapper),
},
reduce: (&limitsProjection{}).reduceLimitsSet,
want: wantReduce{
aggregateType: eventstore.AggregateType("limits"),
sequence: 15,
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "INSERT INTO projections.limits (instance_id, resource_owner, creation_date, change_date, sequence, aggregate_id, block) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (instance_id, resource_owner) DO UPDATE SET (creation_date, change_date, sequence, aggregate_id, block) = (EXCLUDED.creation_date, EXCLUDED.change_date, EXCLUDED.sequence, EXCLUDED.aggregate_id, EXCLUDED.block)",
expectedArgs: []interface{}{
"instance-id",
"ro-id",
anyArg{},
anyArg{},
uint64(15),
"agg-id",
false,
},
},
},
},
},
},
{
name: "reduceLimitsSet all",
args: args{
event: getEvent(testEvent(
limits.SetEventType,
limits.AggregateType,
[]byte(`{
"auditLogRetention": 300000000000,
"block": true
}`),
), limits.SetEventMapper),
},
reduce: (&limitsProjection{}).reduceLimitsSet,
want: wantReduce{
aggregateType: eventstore.AggregateType("limits"),
sequence: 15,
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "INSERT INTO projections.limits (instance_id, resource_owner, creation_date, change_date, sequence, aggregate_id, audit_log_retention, block) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (instance_id, resource_owner) DO UPDATE SET (creation_date, change_date, sequence, aggregate_id, audit_log_retention, block) = (EXCLUDED.creation_date, EXCLUDED.change_date, EXCLUDED.sequence, EXCLUDED.aggregate_id, EXCLUDED.audit_log_retention, EXCLUDED.block)",
expectedArgs: []interface{}{
"instance-id",
"ro-id",
anyArg{},
anyArg{},
uint64(15),
"agg-id",
time.Minute * 5,
true,
},
},
},
},
},
},
{ {
name: "reduceLimitsReset", name: "reduceLimitsReset",
args: args{ args: args{

View File

@ -13,14 +13,14 @@ type Aggregate struct {
eventstore.Aggregate eventstore.Aggregate
} }
func NewAggregate(id, instanceId, resourceOwner string) *Aggregate { func NewAggregate(id, instanceId string) *Aggregate {
return &Aggregate{ return &Aggregate{
Aggregate: eventstore.Aggregate{ Aggregate: eventstore.Aggregate{
Type: AggregateType, Type: AggregateType,
Version: AggregateVersion, Version: AggregateVersion,
ID: id, ID: id,
InstanceID: instanceId, InstanceID: instanceId,
ResourceOwner: resourceOwner, ResourceOwner: instanceId,
}, },
} }
} }

View File

@ -17,6 +17,7 @@ const (
type SetEvent struct { type SetEvent struct {
*eventstore.BaseEvent `json:"-"` *eventstore.BaseEvent `json:"-"`
AuditLogRetention *time.Duration `json:"auditLogRetention,omitempty"` AuditLogRetention *time.Duration `json:"auditLogRetention,omitempty"`
Block *bool `json:"block,omitempty"`
} }
func (e *SetEvent) Payload() any { func (e *SetEvent) Payload() any {
@ -52,6 +53,12 @@ func ChangeAuditLogRetention(auditLogRetention *time.Duration) LimitsChange {
} }
} }
func ChangeBlock(block *bool) LimitsChange {
return func(e *SetEvent) {
e.Block = block
}
}
var SetEventMapper = eventstore.GenericEventMapper[SetEvent] var SetEventMapper = eventstore.GenericEventMapper[SetEvent]
type ResetEvent struct { type ResetEvent struct {

View File

@ -31,6 +31,8 @@ Errors:
Limits: Limits:
NotFound: Лимитът не е намерен NotFound: Лимитът не е намерен
NoneSpecified: Не са посочени лимити NoneSpecified: Не са посочени лимити
Instance:
Blocked: Инстанцията е блокирана
Restrictions: Restrictions:
NoneSpecified: Не са посочени ограничения NoneSpecified: Не са посочени ограничения
DefaultLanguageMustBeAllowed: Езикът по подразбиране трябва да бъде разрешен DefaultLanguageMustBeAllowed: Езикът по подразбиране трябва да бъде разрешен

View File

@ -31,6 +31,8 @@ Errors:
Limits: Limits:
NotFound: Limity nebyly nalezeny NotFound: Limity nebyly nalezeny
NoneSpecified: Nebyly určeny žádné limity NoneSpecified: Nebyly určeny žádné limity
Instance:
Blocked: Instance je blokována
Restrictions: Restrictions:
NoneSpecified: Nebyla určena žádná omezení NoneSpecified: Nebyla určena žádná omezení
DefaultLanguageMustBeAllowed: Výchozí jazyk musí být povolen DefaultLanguageMustBeAllowed: Výchozí jazyk musí být povolen

View File

@ -31,6 +31,8 @@ Errors:
Limits: Limits:
NotFound: Limits konnten nicht gefunden werden NotFound: Limits konnten nicht gefunden werden
NoneSpecified: Keine Limits angegeben NoneSpecified: Keine Limits angegeben
Instance:
Blocked: Instanz ist blockiert
Restrictions: Restrictions:
NoneSpecified: Keine Restriktionen angegeben NoneSpecified: Keine Restriktionen angegeben
DefaultLanguageMustBeAllowed: Default Sprache muss erlaubt sein DefaultLanguageMustBeAllowed: Default Sprache muss erlaubt sein

View File

@ -31,6 +31,8 @@ Errors:
Limits: Limits:
NotFound: Limits not found NotFound: Limits not found
NoneSpecified: No limits specified NoneSpecified: No limits specified
Instance:
Blocked: Instance is blocked
Restrictions: Restrictions:
NoneSpecified: No restrictions specified NoneSpecified: No restrictions specified
DefaultLanguageMustBeAllowed: The default language must be allowed DefaultLanguageMustBeAllowed: The default language must be allowed

View File

@ -31,6 +31,8 @@ Errors:
Limits: Limits:
NotFound: Límite no encontrado NotFound: Límite no encontrado
NoneSpecified: No se especificaron límites NoneSpecified: No se especificaron límites
Instance:
Blocked: La instancia está bloqueada
Restrictions: Restrictions:
NoneSpecified: No se especificaron restricciones NoneSpecified: No se especificaron restricciones
DefaultLanguageMustBeAllowed: El idioma por defecto debe estar permitido DefaultLanguageMustBeAllowed: El idioma por defecto debe estar permitido

View File

@ -31,6 +31,8 @@ Errors:
Limits: Limits:
NotFound: Limites non trouvée NotFound: Limites non trouvée
NoneSpecified: Aucune limite spécifiée NoneSpecified: Aucune limite spécifiée
Instance:
Blocked: Instance bloquée
Restrictions: Restrictions:
NoneSpecified: Aucune restriction spécifiée NoneSpecified: Aucune restriction spécifiée
DefaultLanguageMustBeAllowed: La langue par défaut doit être autorisée DefaultLanguageMustBeAllowed: La langue par défaut doit être autorisée

View File

@ -31,6 +31,8 @@ Errors:
Limits: Limits:
NotFound: Limite non trovato NotFound: Limite non trovato
NoneSpecified: Nessun limite specificato NoneSpecified: Nessun limite specificato
Instance:
Blocked: L'istanza è bloccata
Restrictions: Restrictions:
NoneSpecified: Nessuna restrizione specificata NoneSpecified: Nessuna restrizione specificata
DefaultLanguageMustBeAllowed: La lingua predefinita deve essere consentita DefaultLanguageMustBeAllowed: La lingua predefinita deve essere consentita

View File

@ -31,6 +31,8 @@ Errors:
Limits: Limits:
NotFound: 制限が見つかりません NotFound: 制限が見つかりません
NoneSpecified: 制限が指定されていません NoneSpecified: 制限が指定されていません
Instance:
Blocked: インスタンスはブロックされています
Restrictions: Restrictions:
NoneSpecified: 制限が指定されていません NoneSpecified: 制限が指定されていません
DefaultLanguageMustBeAllowed: デフォルト言語は許可されている必要があります DefaultLanguageMustBeAllowed: デフォルト言語は許可されている必要があります

View File

@ -31,6 +31,8 @@ Errors:
Limits: Limits:
NotFound: Лимитот не е пронајден NotFound: Лимитот не е пронајден
NoneSpecified: Не се наведени лимити NoneSpecified: Не се наведени лимити
Instance:
Blocked: Инстанцата е блокирана
Restrictions: Restrictions:
NoneSpecified: Не се наведени ограничувања NoneSpecified: Не се наведени ограничувања
DefaultLanguageMustBeAllowed: Стандардниот јазик мора да биде дозволен DefaultLanguageMustBeAllowed: Стандардниот јазик мора да биде дозволен

View File

@ -31,6 +31,8 @@ Errors:
Limits: Limits:
NotFound: Limieten niet gevonden NotFound: Limieten niet gevonden
NoneSpecified: Geen limieten gespecificeerd NoneSpecified: Geen limieten gespecificeerd
Instance:
Blocked: Instantie is geblokkeerd
Restrictions: Restrictions:
NoneSpecified: Geen beperkingen gespecificeerd NoneSpecified: Geen beperkingen gespecificeerd
DefaultLanguageMustBeAllowed: De standaardtaal moet worden toegestaan DefaultLanguageMustBeAllowed: De standaardtaal moet worden toegestaan

View File

@ -31,6 +31,8 @@ Errors:
Limits: Limits:
NotFound: Limit nie znaleziony NotFound: Limit nie znaleziony
NoneSpecified: Nie określono limitów NoneSpecified: Nie określono limitów
Instance:
Blocked: Instancja jest zablokowana
Restrictions: Restrictions:
NoneSpecified: Nie określono ograniczeń NoneSpecified: Nie określono ograniczeń
DefaultLanguageMustBeAllowed: Domyślny język musi być dozwolony DefaultLanguageMustBeAllowed: Domyślny język musi być dozwolony

View File

@ -31,6 +31,8 @@ Errors:
Limits: Limits:
NotFound: Limite não encontrado NotFound: Limite não encontrado
NoneSpecified: Nenhum limite especificado NoneSpecified: Nenhum limite especificado
Instance:
Blocked: A instância está bloqueada
Restrictions: Restrictions:
NoneSpecified: Nenhuma restrição especificada NoneSpecified: Nenhuma restrição especificada
DefaultLanguageMustBeAllowed: O idioma padrão deve ser permitido DefaultLanguageMustBeAllowed: O idioma padrão deve ser permitido

View File

@ -31,6 +31,8 @@ Errors:
Limits: Limits:
NotFound: Лимиты не найдены NotFound: Лимиты не найдены
NoneSpecified: Не указаны лимиты NoneSpecified: Не указаны лимиты
Instance:
Blocked: Экземпляр заблокирован
Restrictions: Restrictions:
NoneSpecified: Не указаны ограничения NoneSpecified: Не указаны ограничения
DefaultLanguageMustBeAllowed: Язык по умолчанию должен быть разрешен DefaultLanguageMustBeAllowed: Язык по умолчанию должен быть разрешен

View File

@ -31,6 +31,8 @@ Errors:
Limits: Limits:
NotFound: 未找到限制 NotFound: 未找到限制
NoneSpecified: 未指定限制 NoneSpecified: 未指定限制
Instance:
Blocked: 实例被阻止
Restrictions: Restrictions:
NoneSpecified: 未指定限制 NoneSpecified: 未指定限制
DefaultLanguageMustBeAllowed: 默认语言必须被允许 DefaultLanguageMustBeAllowed: 默认语言必须被允许

View File

@ -458,6 +458,41 @@ service SystemService {
}; };
} }
// Sets many instance level limits
rpc BulkSetLimits(BulkSetLimitsRequest) returns (BulkSetLimitsResponse) {
option (google.api.http) = {
put: "/instances/limits/_bulk"
body: "*"
};
option (zitadel.v1.auth_option) = {
permission: "system.limits.write";
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
tags: ["Usage Control", "Limits"];
responses: {
key: "200";
value: {
description: "Instance limits set";
};
};
responses: {
key: "400";
value: {
description: "At least one limit must be specified for each instance";
schema: {
json_schema: {
ref: "#/definitions/rpcStatus";
};
};
};
};
};
}
// Resets instance level limits // Resets instance level limits
rpc ResetLimits(ResetLimitsRequest) returns (ResetLimitsResponse) { rpc ResetLimits(ResetLimitsRequest) returns (ResetLimitsResponse) {
option (google.api.http) = { option (google.api.http) = {
@ -769,12 +804,27 @@ message SetLimitsRequest {
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.";
} }
]; ];
optional bool block = 3 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "if block is true, requests are responded with a resource exhausted error code.";
}
];
} }
message SetLimitsResponse { message SetLimitsResponse {
zitadel.v1.ObjectDetails details = 1; zitadel.v1.ObjectDetails details = 1;
} }
message BulkSetLimitsRequest {
repeated SetLimitsRequest limits = 1;
}
message BulkSetLimitsResponse {
zitadel.v1.ObjectDetails details = 1;
repeated zitadel.v1.ObjectDetails target_details = 2;
}
message ResetLimitsRequest { message ResetLimitsRequest {
string instance_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; string instance_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}];
} }