feat: add quotas (#4779)

adds possibilities to cap authenticated requests and execution seconds of actions on a defined intervall
This commit is contained in:
Elio Bischof 2023-02-15 02:52:11 +01:00 committed by GitHub
parent 45f6a4436e
commit 681541f41b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
117 changed files with 4652 additions and 510 deletions

View File

@ -1,5 +1,5 @@
issues:
new: true
new-from-rev: main
# Set to 0 to disable.
max-issues-per-linter: 0
# Set to 0 to disable.

View File

@ -1,7 +1,7 @@
module.exports = {
branches: [
{name: 'main'},
{name: '1.87.x', range: '1.87.x', channel: '1.87.x'},
{name: '1.87.x', range: '1.87.x', channel: '1.87.x'}
],
plugins: [
"@semantic-release/commit-analyzer"

View File

@ -105,3 +105,4 @@ RUN go install github.com/rakyll/statik \
#######################
FROM scratch as go-codecov
COPY --from=go-test /go/src/github.com/zitadel/zitadel/profile.cov profile.cov

View File

@ -320,6 +320,55 @@ Actions:
- localhost
- "127.0.0.1"
LogStore:
Access:
Database:
# If enabled, all access logs are stored in the database table logstore.access
Enabled: false
# Logs that are older than the keep duration are cleaned up continuously
Keep: 2160h # 90 days
# CleanupInterval defines the time between cleanup iterations
CleanupInterval: 4h
# Debouncing enables to asynchronously emit log entries, so the normal execution performance is not impaired
# Log entries are held in-memory until one of the conditions MinFrequency or MaxBulkSize meets.
Debounce:
MinFrequency: 2m
MaxBulkSize: 100
Stdout:
# If enabled, all access logs are printed to the binaries standard output
Enabled: false
# Debouncing enables to asynchronously emit log entries, so the normal execution performance is not impaired
# Log entries are held in-memory until one of the conditions MinFrequency or MaxBulkSize meets.
Debounce:
MinFrequency: 0s
MaxBulkSize: 0
Execution:
Database:
# If enabled, all action execution logs are stored in the database table logstore.execution
Enabled: false
# Logs that are older than the keep duration are cleaned up continuously
Keep: 2160h # 90 days
# CleanupInterval defines the time between cleanup iterations
CleanupInterval: 4h
# Debouncing enables to asynchronously emit log entries, so the normal execution performance is not impaired
# Log entries are held in-memory until one of the conditions MinFrequency or MaxBulkSize meets.
Debounce:
MinFrequency: 0s
MaxBulkSize: 0
Stdout:
# If enabled, all execution logs are printed to the binaries standard output
Enabled: true
# Debouncing enables to asynchronously emit log entries, so the normal execution performance is not impaired
# Log entries are held in-memory until one of the conditions MinFrequency or MaxBulkSize meets.
Debounce:
MinFrequency: 0s
MaxBulkSize: 0
Quotas:
Access:
ExhaustedCookieKey: "zitadel.quota.exhausted"
ExhaustedCookieMaxAge: "300s"
Eventstore:
PushTimeout: 15s
@ -573,6 +622,40 @@ DefaultInstance:
Text: The password of your user has changed. If this change was not done by you, please be advised to immediately reset your password.
ButtonText: Login
Quotas:
# Items takes a slice of quota configurations, whereas for each unit type and instance, one or zero quotas may exist.
# The following unit types are supported
# "requests.all.authenticated"
# The sum of all requests to the ZITADEL API with an authorization header,
# excluding the following exceptions
# - Calls to the System API
# - Calls that cause internal server errors
# - Failed authorizations
# - Requests after the quota already exceeded
# "actions.all.runs.seconds"
# The sum of all actions run durations in seconds
Items:
# - Unit: "requests.all.authenticated"
# # From defines the starting time from which the current quota period is calculated from.
# # This is relevant for querying the current usage.
# From: "2023-01-01T00:00:00Z"
# # ResetInterval defines the quota periods duration
# ResetInterval: 720h # 30 days
# # Amount defines the number of units for this quota
# Amount: 25000
# # Limit defines whether ZITADEL should block further usage when the configured amount is used
# Limit: false
# # Notifications are emitted by ZITADEL when certain quota percentages are reached
# Notifications:
# # Percent defines the relative amount of used units, after which a notification should be emitted.
# - Percent: 100
# # Repeat defines, whether a notification should be emitted each time when a multitude of the configured Percent is used.
# Repeat: true
# # CallURL is called when a relative amount of the quota is used.
# CallURL: "https://httpbin.org/post"
InternalAuthZ:
RolePermissionMappings:
- Role: "IAM_OWNER"

View File

@ -37,7 +37,7 @@ func New() *cobra.Command {
Short: "initialize ZITADEL instance",
Long: `Sets up the minimum requirements to start ZITADEL.
Prereqesits:
Prerequisites:
- cockroachdb
The user provided by flags needs privileges to

32
cmd/setup/07.go Normal file
View File

@ -0,0 +1,32 @@
package setup
import (
"context"
"database/sql"
_ "embed"
"strings"
)
var (
//go:embed 07/logstore.sql
createLogstoreSchema07 string
//go:embed 07/access.sql
createAccessLogsTable07 string
//go:embed 07/execution.sql
createExecutionLogsTable07 string
)
type LogstoreTables struct {
dbClient *sql.DB
username string
}
func (mig *LogstoreTables) Execute(ctx context.Context) error {
stmt := strings.ReplaceAll(createLogstoreSchema07, "%[1]s", mig.username) + createAccessLogsTable07 + createExecutionLogsTable07
_, err := mig.dbClient.ExecContext(ctx, stmt)
return err
}
func (mig *LogstoreTables) String() string {
return "07_logstore"
}

14
cmd/setup/07/access.sql Normal file
View File

@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS logstore.access (
log_date TIMESTAMPTZ NOT NULL
, protocol INT NOT NULL
, request_url TEXT NOT NULL
, response_status INT NOT NULL
, request_headers JSONB
, response_headers JSONB
, instance_id TEXT NOT NULL
, project_id TEXT NOT NULL
, requested_domain TEXT
, requested_host TEXT
, INDEX protocol_date_desc (instance_id, protocol, log_date DESC) STORING (request_url, response_status, request_headers)
);

View File

@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS logstore.execution (
log_date TIMESTAMPTZ NOT NULL
, took INTERVAL
, message TEXT NOT NULL
, loglevel INT NOT NULL
, instance_id TEXT NOT NULL
, action_id TEXT NOT NULL
, metadata JSONB
, INDEX log_date_desc (instance_id, log_date DESC) STORING (took)
);

View File

@ -0,0 +1,3 @@
CREATE SCHEMA IF NOT EXISTS logstore;
GRANT ALL ON ALL TABLES IN SCHEMA logstore TO %[1]s;

View File

@ -62,6 +62,7 @@ type Steps struct {
s4EventstoreIndexes *EventstoreIndexes
s5LastFailed *LastFailed
s6OwnerRemoveColumns *OwnerRemoveColumns
s7LogstoreTables *LogstoreTables
}
type encryptionKeyConfig struct {

View File

@ -84,6 +84,7 @@ func Setup(config *Config, steps *Steps, masterKey string) {
steps.s4EventstoreIndexes = &EventstoreIndexes{dbClient: dbClient, dbType: config.Database.Type()}
steps.s5LastFailed = &LastFailed{dbClient: dbClient}
steps.s6OwnerRemoveColumns = &OwnerRemoveColumns{dbClient: dbClient}
steps.s7LogstoreTables = &LogstoreTables{dbClient: dbClient, username: config.Database.Username()}
err = projection.Create(ctx, dbClient, eventstoreClient, config.Projections, nil, nil)
logging.OnError(err).Fatal("unable to start projections")
@ -113,6 +114,8 @@ func Setup(config *Config, steps *Steps, masterKey string) {
logging.OnError(err).Fatal("unable to migrate step 5")
err = migration.Migrate(ctx, eventstoreClient, steps.s6OwnerRemoveColumns)
logging.OnError(err).Fatal("unable to migrate step 6")
err = migration.Migrate(ctx, eventstoreClient, steps.s7LogstoreTables)
logging.OnError(err).Fatal("unable to migrate step 7")
for _, repeatableStep := range repeatableSteps {
err = migration.Migrate(ctx, eventstoreClient, repeatableStep)

View File

@ -24,6 +24,7 @@ import (
"github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/id"
"github.com/zitadel/zitadel/internal/logstore"
"github.com/zitadel/zitadel/internal/query/projection"
static_config "github.com/zitadel/zitadel/internal/static/config"
metrics "github.com/zitadel/zitadel/internal/telemetry/metrics/config"
@ -62,6 +63,12 @@ type Config struct {
Machine *id.Config
Actions *actions.Config
Eventstore *eventstore.Config
LogStore *logstore.Configs
Quotas *QuotasConfig
}
type QuotasConfig struct {
Access *middleware.AccessConfig
}
func MustNewConfig(v *viper.Viper) *Config {

View File

@ -15,8 +15,7 @@ import (
"github.com/zitadel/saml/pkg/provider"
"github.com/zitadel/zitadel/internal/api/saml"
clockpkg "github.com/benbjohnson/clock"
"github.com/gorilla/mux"
"github.com/spf13/cobra"
"github.com/spf13/viper"
@ -27,6 +26,7 @@ import (
"github.com/zitadel/zitadel/cmd/key"
cmd_tls "github.com/zitadel/zitadel/cmd/tls"
"github.com/zitadel/zitadel/internal/actions"
admin_es "github.com/zitadel/zitadel/internal/admin/repository/eventsourcing"
"github.com/zitadel/zitadel/internal/api"
"github.com/zitadel/zitadel/internal/api/assets"
@ -38,6 +38,7 @@ import (
http_util "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/api/http/middleware"
"github.com/zitadel/zitadel/internal/api/oidc"
"github.com/zitadel/zitadel/internal/api/saml"
"github.com/zitadel/zitadel/internal/api/ui/console"
"github.com/zitadel/zitadel/internal/api/ui/login"
auth_es "github.com/zitadel/zitadel/internal/auth/repository/eventsourcing"
@ -48,6 +49,10 @@ import (
"github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/id"
"github.com/zitadel/zitadel/internal/logstore"
"github.com/zitadel/zitadel/internal/logstore/emitters/access"
"github.com/zitadel/zitadel/internal/logstore/emitters/execution"
"github.com/zitadel/zitadel/internal/logstore/emitters/stdout"
"github.com/zitadel/zitadel/internal/notification"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/static"
@ -146,6 +151,23 @@ func startZitadel(config *Config, masterKey string) error {
return fmt.Errorf("cannot start commands: %w", err)
}
clock := clockpkg.New()
actionsExecutionStdoutEmitter, err := logstore.NewEmitter(ctx, clock, config.LogStore.Execution.Stdout, stdout.NewStdoutEmitter())
if err != nil {
return err
}
actionsExecutionDBEmitter, err := logstore.NewEmitter(ctx, clock, config.LogStore.Execution.Database, execution.NewDatabaseLogStorage(dbClient))
if err != nil {
return err
}
usageReporter := logstore.UsageReporterFunc(commands.ReportUsage)
actionsLogstoreSvc := logstore.New(commands, usageReporter, actionsExecutionDBEmitter, actionsExecutionStdoutEmitter)
if actionsLogstoreSvc.Enabled() {
logging.Warn("execution logs are currently in beta")
}
actions.SetLogstoreService(actionsLogstoreSvc)
notification.Start(ctx, config.Projections.Customizations["notifications"], config.ExternalPort, config.ExternalSecure, commands, queries, eventstoreClient, assets.AssetAPIFromDomain(config.ExternalSecure, config.ExternalPort), config.SystemDefaults.Notifications.FileSystemPath, keys.User, keys.SMTP, keys.SMS)
router := mux.NewRouter()
@ -153,14 +175,28 @@ func startZitadel(config *Config, masterKey string) error {
if err != nil {
return err
}
err = startAPIs(ctx, router, commands, queries, eventstoreClient, dbClient, config, storage, authZRepo, keys)
err = startAPIs(ctx, clock, router, commands, queries, eventstoreClient, dbClient, config, storage, authZRepo, keys, commands, usageReporter)
if err != nil {
return err
}
return listen(ctx, router, config.Port, tlsConfig)
}
func startAPIs(ctx context.Context, router *mux.Router, commands *command.Commands, queries *query.Queries, eventstore *eventstore.Eventstore, dbClient *sql.DB, config *Config, store static.Storage, authZRepo authz_repo.Repository, keys *encryptionKeys) error {
func startAPIs(
ctx context.Context,
clock clockpkg.Clock,
router *mux.Router,
commands *command.Commands,
queries *query.Queries,
eventstore *eventstore.Eventstore,
dbClient *sql.DB,
config *Config,
store static.Storage,
authZRepo authz_repo.Repository,
keys *encryptionKeys,
quotaQuerier logstore.QuotaQuerier,
usageReporter logstore.UsageReporter,
) error {
repo := struct {
authz_repo.Repository
*query.Queries
@ -173,7 +209,22 @@ func startAPIs(ctx context.Context, router *mux.Router, commands *command.Comman
if err != nil {
return err
}
apis := api.New(config.Port, router, queries, verifier, config.InternalAuthZ, config.ExternalSecure, tlsConfig, config.HTTP2HostHeader, config.HTTP1HostHeader)
accessStdoutEmitter, err := logstore.NewEmitter(ctx, clock, config.LogStore.Access.Stdout, stdout.NewStdoutEmitter())
if err != nil {
return err
}
accessDBEmitter, err := logstore.NewEmitter(ctx, clock, config.LogStore.Access.Database, access.NewDatabaseLogStorage(dbClient))
if err != nil {
return err
}
accessSvc := logstore.New(quotaQuerier, usageReporter, accessDBEmitter, accessStdoutEmitter)
if accessSvc.Enabled() {
logging.Warn("access logs are currently in beta")
}
accessInterceptor := middleware.NewAccessInterceptor(accessSvc, config.Quotas.Access)
apis := api.New(config.Port, router, queries, verifier, config.InternalAuthZ, config.ExternalSecure, tlsConfig, config.HTTP2HostHeader, config.HTTP1HostHeader, accessSvc)
authRepo, err := auth_es.Start(ctx, config.Auth, config.SystemDefaults, commands, queries, dbClient, eventstore, keys.OIDC, keys.User)
if err != nil {
return fmt.Errorf("error starting auth repo: %w", err)
@ -194,40 +245,40 @@ func startAPIs(ctx context.Context, router *mux.Router, commands *command.Comman
if err := apis.RegisterServer(ctx, auth.CreateServer(commands, queries, authRepo, config.SystemDefaults, keys.User, config.ExternalSecure, config.AuditLogRetention)); err != nil {
return err
}
instanceInterceptor := middleware.InstanceInterceptor(queries, config.HTTP1HostHeader, login.IgnoreInstanceEndpoints...)
assetsCache := middleware.AssetsCacheInterceptor(config.AssetStorage.Cache.MaxAge, config.AssetStorage.Cache.SharedMaxAge)
apis.RegisterHandler(assets.HandlerPrefix, assets.NewHandler(commands, verifier, config.InternalAuthZ, id.SonyFlakeGenerator(), store, queries, instanceInterceptor.Handler, assetsCache.Handler))
apis.RegisterHandler(assets.HandlerPrefix, assets.NewHandler(commands, verifier, config.InternalAuthZ, id.SonyFlakeGenerator(), store, queries, instanceInterceptor.Handler, assetsCache.Handler, accessInterceptor.Handle))
userAgentInterceptor, err := middleware.NewUserAgentHandler(config.UserAgentCookie, keys.UserAgentCookieKey, id.SonyFlakeGenerator(), config.ExternalSecure, login.EndpointResources)
if err != nil {
return err
}
// TODO: Record openapi access logs?
openAPIHandler, err := openapi.Start()
if err != nil {
return fmt.Errorf("unable to start openapi handler: %w", err)
}
apis.RegisterHandler(openapi.HandlerPrefix, openAPIHandler)
oidcProvider, err := oidc.NewProvider(ctx, config.OIDC, login.DefaultLoggedOutPath, config.ExternalSecure, commands, queries, authRepo, keys.OIDC, keys.OIDCKey, eventstore, dbClient, userAgentInterceptor, instanceInterceptor.Handler)
oidcProvider, err := oidc.NewProvider(ctx, config.OIDC, login.DefaultLoggedOutPath, config.ExternalSecure, commands, queries, authRepo, keys.OIDC, keys.OIDCKey, eventstore, dbClient, userAgentInterceptor, instanceInterceptor.Handler, accessInterceptor.Handle)
if err != nil {
return fmt.Errorf("unable to start oidc provider: %w", err)
}
samlProvider, err := saml.NewProvider(ctx, config.SAML, config.ExternalSecure, commands, queries, authRepo, keys.OIDC, keys.SAML, eventstore, dbClient, instanceInterceptor.Handler, userAgentInterceptor)
samlProvider, err := saml.NewProvider(ctx, config.SAML, config.ExternalSecure, commands, queries, authRepo, keys.OIDC, keys.SAML, eventstore, dbClient, instanceInterceptor.Handler, userAgentInterceptor, accessInterceptor.Handle)
if err != nil {
return fmt.Errorf("unable to start saml provider: %w", err)
}
apis.RegisterHandler(saml.HandlerPrefix, samlProvider.HttpHandler())
c, err := console.Start(config.Console, config.ExternalSecure, oidcProvider.IssuerFromRequest, instanceInterceptor.Handler, config.CustomerPortal)
c, err := console.Start(config.Console, config.ExternalSecure, oidcProvider.IssuerFromRequest, instanceInterceptor.Handler, accessInterceptor.Handle, config.CustomerPortal)
if err != nil {
return fmt.Errorf("unable to start console: %w", err)
}
apis.RegisterHandler(console.HandlerPrefix, c)
l, err := login.CreateLogin(config.Login, commands, queries, authRepo, store, console.HandlerPrefix+"/", op.AuthCallbackURL(oidcProvider), provider.AuthCallbackURL(samlProvider), config.ExternalSecure, userAgentInterceptor, op.NewIssuerInterceptor(oidcProvider.IssuerFromRequest).Handler, provider.NewIssuerInterceptor(samlProvider.IssuerFromRequest).Handler, instanceInterceptor.Handler, assetsCache.Handler, keys.User, keys.IDPConfig, keys.CSRFCookieKey)
l, err := login.CreateLogin(config.Login, commands, queries, authRepo, store, console.HandlerPrefix+"/", op.AuthCallbackURL(oidcProvider), provider.AuthCallbackURL(samlProvider), config.ExternalSecure, userAgentInterceptor, op.NewIssuerInterceptor(oidcProvider.IssuerFromRequest).Handler, provider.NewIssuerInterceptor(samlProvider.IssuerFromRequest).Handler, instanceInterceptor.Handler, assetsCache.Handler, accessInterceptor.Handle, keys.User, keys.IDPConfig, keys.CSRFCookieKey)
if err != nil {
return fmt.Errorf("unable to start login: %w", err)
}

View File

@ -88,7 +88,7 @@ This might take some time
> **rpc** RemoveInstance([RemoveInstanceRequest](#removeinstancerequest))
[RemoveInstanceResponse](#removeinstanceresponse)
Removes a instances
Removes an instance
This might take some time
@ -228,6 +228,30 @@ failed event. You can find out if it worked on the `failure_count`
DELETE: /failedevents/{database}/{view_name}/{failed_sequence}
### AddQuota
> **rpc** AddQuota([AddQuotaRequest](#addquotarequest))
[AddQuotaResponse](#addquotaresponse)
Creates a new quota
POST: /instances/{instance_id}/quotas
### RemoveQuota
> **rpc** RemoveQuota([RemoveQuotaRequest](#removequotarequest))
[RemoveQuotaResponse](#removequotaresponse)
Removes a quota
DELETE: /instances/{instance_id}/quotas/{unit}
@ -326,6 +350,34 @@ failed event. You can find out if it worked on the `failure_count`
### AddQuotaRequest
| Field | Type | Description | Validation |
| ----- | ---- | ----------- | ----------- |
| instance_id | string | - | string.min_len: 1<br /> string.max_len: 200<br /> |
| unit | zitadel.quota.v1.Unit | the unit a quota should be imposed on | enum.defined_only: true<br /> enum.not_in: [0]<br /> |
| from | google.protobuf.Timestamp | the starting time from which the current quota period is calculated from. This is relevant for querying the current usage. | timestamp.required: true<br /> |
| reset_interval | google.protobuf.Duration | the quota periods duration | duration.required: true<br /> |
| amount | uint64 | the quota amount of units | uint64.gt: 0<br /> |
| limit | bool | whether ZITADEL should block further usage when the configured amount is used | |
| notifications | repeated zitadel.quota.v1.Notification | the handlers, ZITADEL executes when certain quota percentages are reached | |
### AddQuotaResponse
| Field | Type | Description | Validation |
| ----- | ---- | ----------- | ----------- |
| details | zitadel.v1.ObjectDetails | - | |
### ChangeSubscriptionRequest
@ -558,19 +610,6 @@ This is an empty response
### GetUsageResponse
| Field | Type | Description | Validation |
| ----- | ---- | ----------- | ----------- |
| details | zitadel.v1.ObjectDetails | - | |
| executed_requests | uint64 | - | |
| executed_action_mins | uint64 | - | |
### HealthzRequest
This is an empty request
@ -760,6 +799,29 @@ This is an empty response
### RemoveQuotaRequest
| Field | Type | Description | Validation |
| ----- | ---- | ----------- | ----------- |
| instance_id | string | - | string.min_len: 1<br /> string.max_len: 200<br /> |
| unit | zitadel.quota.v1.Unit | - | |
### RemoveQuotaResponse
| Field | Type | Description | Validation |
| ----- | ---- | ----------- | ----------- |
| details | zitadel.v1.ObjectDetails | - | |
### SetPrimaryDomainRequest

View File

@ -140,3 +140,7 @@ DefaultInstance:
- Probably, you also want to [apply your custom branding](/guides/manage/customize/branding), [hook into certain events](/guides/manage/customize/behavior), [customize texts](/guides/manage/customize/texts) or [add metadata to your users](/guides/manage/customize/user-metadata).
- If you want to automatically create ZITADEL resources, you can use the [ZITADEL Terraform Provider](/guides/manage/terraform/basics).
## Quotas
If you host ZITADEL as a service,
you might want to [limit usage and/or execute tasks on certain usage units and levels](/self-hosting/manage/quotas).

View File

@ -0,0 +1,60 @@
---
title: Usage Quotas
---
Quotas is an enterprise feature that is relevant if you want to host ZITADEL as a service.
It enables you to limit usage and/or register webhooks that trigger on configurable usage levels for certain units.
For example, you might want to report usage to an external billing tool and notify users when 80 percent of a quota is exhausted.
Quotas are currently supported [for the instance level only](/concepts/structure/instance).
Please refer to the [system API docs](/apis/proto/system#addquota) for detailed explanations about how to use the quotas feature.
ZITADEL supports limiting authenticated requests and action run seconds
## Authenticated Requests
For using the quotas feature for authenticated requests you have to enable the database logstore for access logs in your ZITADEL configurations LogStore section:
```yaml
LogStore:
Access:
Database:
# If enabled, all access logs are stored in the database table logstore.access
Enabled: false
# Logs that are older than the keep duration are cleaned up continuously
Keep: 2160h # 90 days
# CleanupInterval defines the time between cleanup iterations
CleanupInterval: 4h
# Debouncing enables to asynchronously emit log entries, so the normal execution performance is not impaired
# Log entries are held in-memory until one of the conditions MinFrequency or MaxBulkSize meets.
Debounce:
MinFrequency: 2m
MaxBulkSize: 100
```
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.
Also, a cookie is set, to make it easier to block further traffic before it reaches your ZITADEL runtime.
## Action Run Seconds
For using the quotas feature for action run seconds you have to enable the database logstore for execution logs in your ZITADEL configurations LogStore section:
```yaml
LogStore:
Execution:
Database:
# If enabled, all action execution logs are stored in the database table logstore.execution
Enabled: false
# Logs that are older than the keep duration are cleaned up continuously
Keep: 2160h # 90 days
# CleanupInterval defines the time between cleanup iterations
CleanupInterval: 4h
# Debouncing enables to asynchronously emit log entries, so the normal execution performance is not impaired
# Log entries are held in-memory until one of the conditions MinFrequency or MaxBulkSize meets.
Debounce:
MinFrequency: 0s
MaxBulkSize: 0
```
If a quota is configured to limit action run seconds and the quotas amount is exhausted, all further actions will fail immediately with a context timeout exceeded error.
The action that runs into the limit also fails with the context timeout exceeded error.

View File

@ -265,7 +265,7 @@ module.exports = {
"self-hosting/deploy/compose",
"self-hosting/deploy/knative",
"self-hosting/deploy/kubernetes",
"self-hosting/deploy/loadbalancing-example/loadbalancing-example",
"self-hosting/deploy/loadbalancing-example/loadbalancing-example"
],
},
{
@ -282,6 +282,7 @@ module.exports = {
"self-hosting/manage/tls_modes",
"self-hosting/manage/database/database",
"self-hosting/manage/updating_scaling",
"self-hosting/manage/quotas"
],
},
],
@ -334,7 +335,10 @@ module.exports = {
type: "category",
label: "Features",
collapsed: false,
items: ["concepts/features/actions", "concepts/features/selfservice"],
items: [
"concepts/features/actions",
"concepts/features/selfservice"
],
},
],
manuals: [

View File

@ -1,7 +1,42 @@
import { defineConfig } from 'cypress';
import { Client } from "pg";
import { createServer } from 'http'
import { ZITADELWebhookEvent } from 'cypress/support/types';
const jwt = require('jsonwebtoken');
const privateKey = `-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAzi+FFSJL7f5yw4KTwzgMP34ePGycm/M+kT0M7V4Cgx5V3EaD
IvTQKTLfBaEB45zb9LtjIXzDw0rXRoS2hO6th+CYQCz3KCvh09C0IzxZiB2IS3H/
aT+5Bx9EFY+vnAkZjccbyG5YNRvmtOlnvIeIH7qZ0tEwkPfF5GEZNPJPtmy3UGV7
iofdVQS1xRj73+aMw5rvH4D8IdyiAC3VekIbpt0Vj0SUX3DwKtog337BzTiPk3aX
RF0sbFhQoqdJRI8NqgZjCwjq9yfI5tyxYswn+JGzHGdHvW3idODlmwEt5K2pasiR
IWK2OGfq+w0EcltQHabuqEPgZlmhCkRdNfixBwIDAQABAoIBAA9jNoBkRdxmH/R9
Wz+3gBqA9Aq4ZFuzJJk8QCm62V8ltWyyCnliYeKhPEm0QWrWOwghr/1AzW9Wt4g4
wVJcabD5TwODF5L0626eZcM3bsscwR44TMJzEgD5EWC2j3mKqFCPaoBj08tq4KXh
wW8tgjgz+eTk3cYD583qfTIZX1+SzSMBpetTBsssQtGhhOB/xPiuL7hi+fXmV2rh
8mc9X6+wJ5u3zepsyK0vBeEDmurD4ZUIXFrZ0WCB/wNkSW9VKyoH+RC1asQAgqTz
glJ/NPbDJSKGvSBQydoKkqoXx7MVJ8VObFddfgo4dtOoz6YCfUVBHt8qy+E5rz5y
CICjL/kCgYEA9MnHntVVKNXtEFZPo02xgCwS3eG27ZwjYgJ1ZkCHM5BuL4MS7qbr
743/POs1Ctaok0udHl1PFB4uAG0URnmkUnWzcoJYb6Plv03F0LRdsnfuhehfIxLP
nWvxSm5n21H4ytfxm0BWY09JkLDnJZtXrgTILbuqb9Wy6TmAvUaF2YUCgYEA16Ec
ywSaLVdqPaVpsTxi7XpRJAB2Isjp6RffNEecta4S0LL7s/IO3QXDH9SYpgmgCTah
3aXhpT4hIFlpg3eBjVfbOwgqub8DgirnSQyQt99edUtHIK+K8nMdGxz6X6pfTKzK
asSH7qPlt5tz1621vC0ocXSZR7zm99/FgwILwBsCgYBOsP8nJFV4By1qbxSy3qsN
FR4LjiAMSoFlZHzxHhVYkjmZtH1FkwuNuwwuPT6T+WW/1DLyK/Tb9se7A1XdQgV9
LLE/Qn/Dg+C7mvjYmuL0GHHpQkYzNDzh0m2DC/L/Il7kdn8I9anPyxFPHk9wW3vY
SVlAum+T/BLDvuSP9DfbMQKBgCc1j7PG8XYfOB1fj7l/volqPYjrYI/wssAE7Dxo
bTGIJrm2YhiVgmhkXNfT47IFfAlQ2twgBsjyZDmqqIoUWAVonV+9m29NMYkg3g+l
bkdRIa74ckWaRgzSK8+7VDfDFjMuFFyXwhP9z460gLsORkaie4Et75Vg3yrhkNvC
qnpTAoGBAMguDSWBbCewXnHlKGFpm+LH+OIvVKGEhtCSvfZojtNrg/JBeBebSL1n
mmT1cONO+0O5bz7uVaRd3JdnH2JFevY698zFfhVsjVCrm+fz31i5cxAgC39G2Lfl
YkTaa1AFLstnf348ZjuvBN3USUYZo3X3mxnS+uluVuRSGwIKsN0a
-----END RSA PRIVATE KEY-----`
let tokensCache = new Map<string,string>()
let webhookEvents = new Array<ZITADELWebhookEvent>()
export default defineConfig({
reporter: 'mochawesome',
@ -17,23 +52,56 @@ export default defineConfig({
env: {
ORGANIZATION: process.env.CYPRESS_ORGANIZATION || 'zitadel',
BACKEND_URL: process.env.CYPRESS_BACKEND_URL || baseUrl().replace("/ui/console", "")
BACKEND_URL: backendUrl(),
WEBHOOK_HANDLER_PORT: webhookHandlerPort(),
WEBHOOK_HANDLER_HOST: process.env.CYPRESS_WEBHOOK_HANDLER_HOST || 'localhost',
},
e2e: {
baseUrl: baseUrl(),
experimentalSessionAndOrigin: true,
experimentalRunAllSpecs: true,
setupNodeEvents(on, config) {
startWebhookEventHandler()
on('task', {
safetoken({key, token}) {
tokensCache.set(key,token);
return null
}
})
on('task', {
},
loadtoken({key}): string | null {
return tokensCache.get(key) || null;
},
systemToken(): Promise<string> {
let iat = Math.floor(Date.now() / 1000);
let exp = iat + (999*12*30*24*60*60) // ~ 999 years
return jwt.sign({
"iss": "cypress",
"sub": "cypress",
"aud": backendUrl(),
"iat": iat,
"exp": exp
}, Buffer.from(privateKey, 'ascii').toString('ascii'), { algorithm: 'RS256' })
},
async runSQL(statement: string) {
const client = new Client({
connectionString: process.env.CYPRESS_DATABASE_CONNECTION_URL || 'postgresql://root@localhost:26257/zitadel'
});
return client.connect().then(() => {
return client.query(statement).then((result) => {
return client.end().then(() => {
return result
})
})
})
},
resetWebhookEvents() {
webhookEvents = []
return null
},
handledWebhookEvents(){
return webhookEvents
}
})
},
@ -43,3 +111,31 @@ export default defineConfig({
function baseUrl(){
return process.env.CYPRESS_BASE_URL || 'http://localhost:8080/ui/console'
}
function backendUrl(){
return process.env.CYPRESS_BACKEND_URL || baseUrl().replace("/ui/console", "")
}
function webhookHandlerPort() {
return process.env.CYPRESS_WEBHOOK_HANDLER_PORT || '8900'
}
function startWebhookEventHandler() {
const port = webhookHandlerPort()
const server = createServer((req, res) => {
const chunks = [];
req.on("data", (chunk) => {
chunks.push(chunk);
});
req.on("end", () => {
webhookEvents.push(JSON.parse(Buffer.concat(chunks).toString()));
});
res.writeHead(200);
res.end()
});
server.listen(port, () => {
console.log(`Server is running on http://:${port}`);
});
}

View File

@ -1,22 +1,26 @@
import { Apps, ensureProjectExists, ensureProjectResourceDoesntExist } from '../../support/api/projects';
import { apiAuth } from '../../support/api/apiauth';
import { Context } from 'support/commands';
const testProjectName = 'e2eprojectapplication';
const testAppName = 'e2eappundertest';
describe('applications', () => {
const testProjectName = 'e2eprojectapplication';
const testAppName = 'e2eappundertest';
beforeEach(() => {
apiAuth()
.as('api')
.then((api) => {
ensureProjectExists(api, testProjectName).as('projectId');
cy.context()
.as('ctx')
.then((ctx) => {
ensureProjectExists(ctx.api, testProjectName).as('projectId');
});
});
describe('add app', function () {
beforeEach(`ensure it doesn't exist already`, function () {
ensureProjectResourceDoesntExist(this.api, this.projectId, Apps, testAppName);
cy.visit(`/projects/${this.projectId}`);
describe('add app', () => {
beforeEach(`ensure it doesn't exist already`, () => {
cy.get<Context>('@ctx').then((ctx) => {
cy.get<string>('@projectId').then((projectId) => {
ensureProjectResourceDoesntExist(ctx.api, projectId, Apps, testAppName);
cy.visit(`/projects/${projectId}`);
});
});
});
it('add app', () => {

View File

@ -1,25 +1,28 @@
import { apiAuth } from '../../support/api/apiauth';
import { ensureHumanUserExists, ensureUserDoesntExist } from '../../support/api/users';
import { loginname } from '../../support/login/users';
import { ensureDomainPolicy } from '../../support/api/policies';
import { Context } from 'support/commands';
describe('humans', () => {
const humansPath = `/users?type=human`;
beforeEach(() => {
cy.context().as('ctx');
});
[
{ mustBeDomain: false, addName: 'e2ehumanusernameaddGlobal', removeName: 'e2ehumanusernameremoveGlobal' },
{ mustBeDomain: false, addName: 'e2ehumanusernameadd@test.com', removeName: 'e2ehumanusernameremove@test.com' },
{ mustBeDomain: true, addName: 'e2ehumanusernameadd', removeName: 'e2ehumanusernameremove' },
// TODO:Changing the policy return 409 User already exists (SQL-M0dsf)
// { mustBeDomain: true, addName: 'e2ehumanusernameadd', removeName: 'e2ehumanusernameremove' },
].forEach((user) => {
beforeEach(() => {
apiAuth().as('api');
});
describe(`add "${user.addName}" with domain setting "${user.mustBeDomain}"`, () => {
beforeEach(`ensure it doesn't exist already`, function () {
ensureDomainPolicy(this.api, user.mustBeDomain, true, false);
ensureUserDoesntExist(this.api, user.addName);
cy.visit(humansPath);
beforeEach(`ensure it doesn't exist already`, () => {
cy.get<Context>('@ctx').then((ctx) => {
ensureUserDoesntExist(ctx.api, user.addName);
ensureDomainPolicy(ctx.api, user.mustBeDomain, true, false);
cy.visit(humansPath);
});
});
it('should add a user', () => {
@ -44,19 +47,17 @@ describe('humans', () => {
});
describe(`remove "${user.removeName}" with domain setting "${user.mustBeDomain}"`, () => {
beforeEach('ensure it exists', function () {
ensureHumanUserExists(this.api, user.removeName);
beforeEach('ensure it exists', () => {
cy.get<Context>('@ctx').then((ctx) => {
ensureHumanUserExists(ctx.api, user.removeName);
});
cy.visit(humansPath);
});
let loginName = user.removeName;
if (user.mustBeDomain) {
loginName = loginname(user.removeName, Cypress.env('ORGANIZATION'));
}
it('should delete a human user', () => {
const rowSelector = `tr:contains(${user.removeName})`;
cy.get(rowSelector).find('[data-e2e="enabled-delete-button"]').click({ force: true });
cy.get('[data-e2e="confirm-dialog-input"]').focus().type(loginName);
cy.get('[data-e2e="confirm-dialog-input"]').focus().type(user.removeName);
cy.get('[data-e2e="confirm-dialog-button"]').click();
cy.shouldConfirmSuccess();
cy.shouldNotExist({

View File

@ -0,0 +1,23 @@
import { getInstance } from 'support/api/instances';
import { ensureQuotaIsRemoved, removeQuota, Unit } from 'support/api/quota';
import { Context } from 'support/commands';
describe('api internationalization', () => {
beforeEach(() => {
cy.context()
.as('ctx')
.then((ctx) => {
ensureQuotaIsRemoved(ctx, Unit.ExecutionSeconds);
});
});
it('instance not found error should be translated', () => {
cy.get<Context>('@ctx').then((ctx) => {
removeQuota(ctx, Unit.ExecutionSeconds, false).then((res) => {
expect(res.body.message).to.contain('Quota not found for this unit');
});
getInstance(ctx.system, "this ID clearly doesn't exist", false).then((res) => {
expect(res.body.message).to.contain('Instance not found');
});
});
});
});

View File

@ -1,25 +1,28 @@
import { apiAuth } from '../../support/api/apiauth';
import { ensureMachineUserExists, ensureUserDoesntExist } from '../../support/api/users';
import { loginname } from '../../support/login/users';
import { ensureDomainPolicy } from '../../support/api/policies';
import { Context } from 'support/commands';
describe('machines', () => {
const machinesPath = `/users?type=machine`;
beforeEach(() => {
apiAuth().as('api');
cy.context().as('ctx');
});
[
{ mustBeDomain: false, addName: 'e2emachineusernameaddGlobal', removeName: 'e2emachineusernameremoveGlobal' },
{ mustBeDomain: false, addName: 'e2emachineusernameadd@test.com', removeName: 'e2emachineusernameremove@test.com' },
{ mustBeDomain: true, addName: 'e2emachineusernameadd', removeName: 'e2emachineusernameremove' },
// TODO:Changing the policy return 409 User already exists (SQL-M0dsf)
// { mustBeDomain: true, addName: 'e2emachineusernameadd', removeName: 'e2emachineusernameremove' },
].forEach((machine) => {
describe(`add "${machine.addName}" with domain setting "${machine.mustBeDomain}"`, () => {
beforeEach(`ensure it doesn't exist already`, function () {
ensureDomainPolicy(this.api, machine.mustBeDomain, false, false);
ensureUserDoesntExist(this.api, machine.addName);
cy.visit(machinesPath);
beforeEach(`ensure it doesn't exist already`, () => {
cy.get<Context>('@ctx').then((ctx) => {
ensureUserDoesntExist(ctx.api, machine.addName);
ensureDomainPolicy(ctx.api, machine.mustBeDomain, false, false);
cy.visit(machinesPath);
});
});
it('should add a machine', () => {
@ -41,9 +44,11 @@ describe('machines', () => {
});
describe(`remove "${machine.removeName}" with domain setting "${machine.mustBeDomain}"`, () => {
beforeEach('ensure it exists', function () {
ensureMachineUserExists(this.api, machine.removeName);
cy.visit(machinesPath);
beforeEach('ensure it exists', () => {
cy.get<Context>('@ctx').then((ctx) => {
ensureMachineUserExists(ctx.api, machine.removeName);
cy.visit(machinesPath);
});
});
let loginName = machine.removeName;

View File

@ -1,26 +1,25 @@
import { ensureOrgExists, ensureOrgIsDefault, isDefaultOrg } from 'support/api/orgs';
import { apiAuth } from '../../support/api/apiauth';
import { v4 as uuidv4 } from 'uuid';
import { Context } from 'support/commands';
const orgPath = `/org`;
const orgNameOnCreation = 'e2eorgrename';
const testOrgNameChange = uuidv4();
beforeEach(() => {
cy.context().as('ctx');
});
describe('organizations', () => {
describe('rename', () => {
beforeEach(() => {
apiAuth()
.as('api')
.then((api) => {
ensureOrgExists(api, orgNameOnCreation)
.as('newOrgId')
.then((newOrgId) => {
cy.visit(`${orgPath}?org=${newOrgId}`).as('orgsite');
});
cy.get<Context>('@ctx').then((ctx) => {
ensureOrgExists(ctx, orgNameOnCreation).then((newOrgId) => {
cy.visit(`${orgPath}?org=${newOrgId}`).as('orgsite');
});
});
});
it('should rename the organization', () => {
cy.get('[data-e2e="actions"]').click();
cy.get('[data-e2e="rename"]', { timeout: 1000 }).should('be.visible').click();
@ -31,24 +30,21 @@ describe('organizations', () => {
cy.visit(orgPath);
cy.get('[data-e2e="top-view-title"').should('contain', testOrgNameChange);
});
});
const orgOverviewPath = `/orgs`;
const initialDefaultOrg = 'e2eorgolddefault';
const orgNameForNewDefault = 'e2eorgnewdefault';
const orgOverviewPath = `/orgs`;
const initialDefaultOrg = 'e2eorgolddefault';
const orgNameForNewDefault = 'e2eorgnewdefault';
describe('set default org', () => {
beforeEach(() => {
apiAuth()
.as('api')
.then((api) => {
ensureOrgExists(api, orgNameForNewDefault)
describe('set default org', () => {
beforeEach(() => {
cy.get<Context>('@ctx').then((ctx) => {
ensureOrgExists(ctx, orgNameForNewDefault)
.as('newDefaultOrgId')
.then(() => {
ensureOrgExists(api, initialDefaultOrg)
ensureOrgExists(ctx, initialDefaultOrg)
.as('defaultOrg')
.then((id) => {
ensureOrgIsDefault(api, id)
ensureOrgIsDefault(ctx, id)
.as('orgWasDefault')
.then(() => {
cy.visit(`${orgOverviewPath}`).as('orgsite');
@ -56,19 +52,24 @@ describe('organizations', () => {
});
});
});
});
it('should rename the organization', () => {
cy.get<Context>('@ctx').then((ctx) => {
const rowSelector = `tr:contains(${orgNameForNewDefault})`;
cy.get(rowSelector).find('[data-e2e="table-actions-button"]').click({ force: true });
cy.get('[data-e2e="set-default-button"]', { timeout: 1000 }).should('be.visible').click();
cy.shouldConfirmSuccess();
cy.get<string>('@newDefaultOrgId').then((newDefaultOrgId) => {
isDefaultOrg(ctx, newDefaultOrgId);
});
});
});
});
it('should rename the organization', function () {
const rowSelector = `tr:contains(${orgNameForNewDefault})`;
cy.get(rowSelector).find('[data-e2e="table-actions-button"]').click({ force: true });
cy.get('[data-e2e="set-default-button"]', { timeout: 1000 }).should('be.visible').click();
cy.shouldConfirmSuccess();
isDefaultOrg(this.api, this.newDefaultOrgId);
it('should add an organization with the personal account as org owner');
describe('changing the current organization', () => {
it('should update displayed organization details');
});
});
it('should add an organization with the personal account as org owner');
describe('changing the current organization', () => {
it('should update displayed organization details');
});
});

View File

@ -6,36 +6,46 @@ import {
ensureHumanIsProjectMember,
} from 'support/api/members';
import { ensureOrgExists } from 'support/api/orgs';
import { ensureDomainPolicy } from 'support/api/policies';
import { ensureHumanUserExists, ensureUserDoesntExist } from 'support/api/users';
import { loginname } from 'support/login/users';
import { apiAuth } from '../../support/api/apiauth';
import { Context } from 'support/commands';
import { ensureProjectExists, ensureProjectResourceDoesntExist, Roles } from '../../support/api/projects';
describe('permissions', () => {
beforeEach(() => {
apiAuth().as('api');
cy.context()
.as('ctx')
.then((ctx) => {
ensureDomainPolicy(ctx.api, false, true, false);
});
});
describe('management', () => {
const testManagerLoginname = loginname('e2ehumanmanager', Cypress.env('ORGANIZATION'));
const testManagerUsername = 'e2ehumanmanager';
function testAuthorizations(
roles: string[],
beforeCreate: Mocha.HookFunction,
beforeMutate: Mocha.HookFunction,
navigate: Mocha.HookFunction,
beforeCreate: (ctx: Context) => void,
beforeMutate: (ctx: Context) => void,
navigate: () => void,
) {
beforeEach(function () {
ensureUserDoesntExist(this.api, testManagerLoginname);
ensureHumanUserExists(this.api, testManagerLoginname);
beforeEach(() => {
cy.get<Context>('@ctx').then((ctx) => {
ensureUserDoesntExist(ctx.api, testManagerUsername);
ensureHumanUserExists(ctx.api, testManagerUsername);
});
});
describe('create authorization', () => {
beforeEach(beforeCreate);
beforeEach(navigate);
beforeEach(() => {
cy.get<Context>('@ctx').then((ctx) => {
beforeCreate(ctx);
navigate();
});
});
it('should add a manager', () => {
cy.get('[data-e2e="add-member-button"]').click();
cy.get('[data-e2e="add-member-input"]').type(testManagerLoginname);
cy.get('[data-e2e="add-member-input"]').type(testManagerUsername);
cy.get('[data-e2e="user-option"]').first().click();
cy.contains('[data-e2e="role-checkbox"]', roles[0]).click();
cy.get('[data-e2e="confirm-add-member-button"]').click();
@ -45,14 +55,15 @@ describe('permissions', () => {
});
describe('mutate authorization', () => {
const rowSelector = `tr:contains(${testManagerLoginname})`;
beforeEach(beforeMutate);
beforeEach(navigate);
const rowSelector = `tr:contains(${testManagerUsername})`;
beforeEach(() => {
cy.contains('[data-e2e="member-avatar"]', 'ee').click();
cy.get(rowSelector).as('managerRow');
cy.get<Context>('@ctx').then((ctx) => {
beforeMutate(ctx);
navigate();
cy.contains('[data-e2e="member-avatar"]', 'ee').click();
cy.get(rowSelector).as('managerRow');
});
});
it('should remove a manager', () => {
@ -88,14 +99,14 @@ describe('permissions', () => {
testAuthorizations(
roles.map((role) => role.display),
function () {
ensureHumanIsNotOrgMember(this.api, testManagerLoginname);
function (ctx: Context) {
ensureHumanIsNotOrgMember(ctx.api, testManagerUsername);
},
function () {
ensureHumanIsNotOrgMember(this.api, testManagerLoginname);
function (ctx: Context) {
ensureHumanIsNotOrgMember(ctx.api, testManagerUsername);
ensureHumanIsOrgMember(
this.api,
testManagerLoginname,
ctx.api,
testManagerUsername,
roles.map((role) => role.internal),
);
},
@ -108,12 +119,16 @@ describe('permissions', () => {
describe('projects', () => {
describe('owned projects', () => {
beforeEach(function () {
ensureProjectExists(this.api, 'e2eprojectpermission').as('projectId');
beforeEach(() => {
cy.get<Context>('@ctx').then((ctx) => {
ensureProjectExists(ctx.api, 'e2eprojectpermission').as('projectId');
});
});
const visitOwnedProject: Mocha.HookFunction = function () {
cy.visit(`/projects/${this.projectId}`);
const visitOwnedProject = () => {
cy.get<number>('@projectId').then((projectId) => {
cy.visit(`/projects/${projectId}`);
});
};
describe('authorizations', () => {
@ -124,17 +139,21 @@ describe('permissions', () => {
testAuthorizations(
roles.map((role) => role.display),
function () {
ensureHumanIsNotProjectMember(this.api, this.projectId, testManagerLoginname);
function (ctx) {
cy.get<string>('@projectId').then((projectId) => {
ensureHumanIsNotProjectMember(ctx.api, projectId, testManagerUsername);
});
},
function () {
ensureHumanIsNotProjectMember(this.api, this.projectId, testManagerLoginname);
ensureHumanIsProjectMember(
this.api,
this.projectId,
testManagerLoginname,
roles.map((role) => role.internal),
);
function (ctx) {
cy.get<string>('@projectId').then((projectId) => {
ensureHumanIsNotProjectMember(ctx.api, projectId, testManagerUsername);
ensureHumanIsProjectMember(
ctx.api,
projectId,
testManagerUsername,
roles.map((role) => role.internal),
);
});
},
visitOwnedProject,
);
@ -143,12 +162,15 @@ describe('permissions', () => {
describe('roles', () => {
const testRoleName = 'e2eroleundertestname';
beforeEach(function () {
ensureProjectResourceDoesntExist(this.api, this.projectId, Roles, testRoleName);
beforeEach(() => {
cy.get<Context>('@ctx').then((ctx) => {
cy.get<string>('@projectId').then((projectId) => {
ensureProjectResourceDoesntExist(ctx.api, projectId, Roles, testRoleName);
visitOwnedProject();
});
});
});
beforeEach(visitOwnedProject);
it('should add a role', () => {
cy.get('[data-e2e="sidenav-element-roles"]').click();
cy.get('[data-e2e="add-new-role"]').click();
@ -164,21 +186,25 @@ describe('permissions', () => {
});
describe('granted projects', () => {
beforeEach(function () {
ensureOrgExists(this.api, 'e2eforeignorg')
.as('foreignOrgId')
.then((foreignOrgId) => {
ensureProjectExists(this.api, 'e2eprojectgrants', foreignOrgId)
.as('projectId')
.then((projectId) => {
ensureProjectGrantExists(this.api, foreignOrgId, projectId).as('grantId');
beforeEach(() => {
cy.get<Context>('@ctx').then((ctx) => {
ensureOrgExists(ctx, 'e2eforeignorg').then((foreignOrgId) => {
ensureProjectExists(ctx.api, 'e2eprojectgrants', foreignOrgId)
.as('foreignProjectId')
.then((foreignProjectId) => {
ensureProjectGrantExists(ctx, foreignOrgId, foreignProjectId).as('grantId');
});
});
});
});
const visitGrantedProject: Mocha.HookFunction = function () {
cy.visit(`/granted-projects/${this.projectId}/grant/${this.grantId}`);
};
function visitGrantedProject() {
cy.get<string>('@foreignProjectId').then((foreignProjectId) => {
cy.get<string>('@grantId').then((grantId) => {
cy.visit(`/granted-projects/${foreignProjectId}/grant/${grantId}`);
});
});
}
describe('authorizations', () => {
const roles = [
@ -188,18 +214,26 @@ describe('permissions', () => {
testAuthorizations(
roles.map((role) => role.display),
function () {
ensureHumanIsNotProjectMember(this.api, this.projectId, testManagerLoginname, this.grantId);
function (ctx: Context) {
cy.get<string>('@foreignProjectId').then((foreignProjectId) => {
cy.get<string>('@grantId').then((grantId) => {
ensureHumanIsNotProjectMember(ctx.api, foreignProjectId, testManagerUsername, grantId);
});
});
},
function () {
ensureHumanIsNotProjectMember(this.api, this.projectId, testManagerLoginname, this.grantId);
ensureHumanIsProjectMember(
this.api,
this.projectId,
testManagerLoginname,
roles.map((role) => role.internal),
this.grantId,
);
function (ctx: Context) {
cy.get<string>('@foreignProjectId').then((foreignProjectId) => {
cy.get<string>('@grantId').then((grantId) => {
ensureHumanIsNotProjectMember(ctx.api, foreignProjectId, testManagerUsername, grantId);
ensureHumanIsProjectMember(
ctx.api,
foreignProjectId,
testManagerUsername,
roles.map((role) => role.internal),
grantId,
);
});
});
},
visitGrantedProject,
);
@ -207,42 +241,42 @@ describe('permissions', () => {
});
});
});
});
describe('validations', () => {
describe('owned projects', () => {
describe('no ownership', () => {
it('a user without project global ownership can ...');
it('a user without project global ownership can not ...');
});
describe('project owner viewer global', () => {
it('a project owner viewer global additionally can ...');
it('a project owner viewer global still can not ...');
});
describe('project owner global', () => {
it('a project owner global additionally can ...');
it('a project owner global still can not ...');
});
describe('validations', () => {
describe('owned projects', () => {
describe('no ownership', () => {
it('a user without project global ownership can ...');
it('a user without project global ownership can not ...');
});
describe('project owner viewer global', () => {
it('a project owner viewer global additionally can ...');
it('a project owner viewer global still can not ...');
});
describe('project owner global', () => {
it('a project owner global additionally can ...');
it('a project owner global still can not ...');
});
});
describe('granted projects', () => {
describe('no ownership', () => {
it('a user without project grant ownership can ...');
it('a user without project grant ownership can not ...');
});
describe('project grant owner viewer', () => {
it('a project grant owner viewer additionally can ...');
it('a project grant owner viewer still can not ...');
});
describe('project grant owner', () => {
it('a project grant owner additionally can ...');
it('a project grant owner still can not ...');
});
describe('granted projects', () => {
describe('no ownership', () => {
it('a user without project grant ownership can ...');
it('a user without project grant ownership can not ...');
});
describe('organization', () => {
describe('org owner', () => {
it('a project owner global can ...');
it('a project owner global can not ...');
});
describe('project grant owner viewer', () => {
it('a project grant owner viewer additionally can ...');
it('a project grant owner viewer still can not ...');
});
describe('project grant owner', () => {
it('a project grant owner additionally can ...');
it('a project grant owner still can not ...');
});
});
describe('organization', () => {
describe('org owner', () => {
it('a project owner global can ...');
it('a project owner global can not ...');
});
});
});

View File

@ -1,18 +1,20 @@
import { apiAuth } from '../../support/api/apiauth';
import { Context } from 'support/commands';
import { ensureProjectDoesntExist, ensureProjectExists } from '../../support/api/projects';
describe('projects', () => {
beforeEach(() => {
apiAuth().as('api');
cy.context().as('ctx');
});
const testProjectNameCreate = 'e2eprojectcreate';
const testProjectNameDelete = 'e2eprojectdelete';
describe('add project', () => {
beforeEach(`ensure it doesn't exist already`, function () {
ensureProjectDoesntExist(this.api, testProjectNameCreate);
cy.visit(`/projects`);
beforeEach(`ensure it doesn't exist already`, () => {
cy.get<Context>('@ctx').then((ctx) => {
ensureProjectDoesntExist(ctx.api, testProjectNameCreate);
cy.visit(`/projects`);
});
});
it('should add a project', () => {
@ -26,9 +28,11 @@ describe('projects', () => {
});
describe('edit project', () => {
beforeEach('ensure it exists', function () {
ensureProjectExists(this.api, testProjectNameDelete);
cy.visit(`/projects`);
beforeEach('ensure it exists', () => {
cy.get<Context>('@ctx').then((ctx) => {
ensureProjectExists(ctx.api, testProjectNameDelete);
cy.visit(`/projects`);
});
});
describe('remove project', () => {

View File

@ -0,0 +1,248 @@
import { addQuota, ensureQuotaIsAdded, ensureQuotaIsRemoved, removeQuota, Unit } from 'support/api/quota';
import { createHumanUser, ensureUserDoesntExist } from 'support/api/users';
import { Context } from 'support/commands';
import { ZITADELWebhookEvent } from 'support/types';
beforeEach(() => {
cy.context().as('ctx');
});
describe('quotas', () => {
describe('management', () => {
describe('add one quota', () => {
it('should add a quota only once per unit', () => {
cy.get<Context>('@ctx').then((ctx) => {
addQuota(ctx, Unit.AuthenticatedRequests, true, 1);
addQuota(ctx, Unit.AuthenticatedRequests, true, 1, undefined, undefined, undefined, false).then((res) => {
expect(res.status).to.equal(409);
});
});
});
describe('add two quotas', () => {
it('should add a quota for each unit', () => {
cy.get<Context>('@ctx').then((ctx) => {
addQuota(ctx, Unit.AuthenticatedRequests, true, 1);
addQuota(ctx, Unit.ExecutionSeconds, true, 1);
});
});
});
});
describe('edit', () => {
describe('remove one quota', () => {
beforeEach(() => {
cy.get<Context>('@ctx').then((ctx) => {
ensureQuotaIsAdded(ctx, Unit.AuthenticatedRequests, true, 1);
});
});
it('should remove a quota only once per unit', () => {
cy.get<Context>('@ctx').then((ctx) => {
removeQuota(ctx, Unit.AuthenticatedRequests);
});
cy.get<Context>('@ctx').then((ctx) => {
removeQuota(ctx, Unit.AuthenticatedRequests, false).then((res) => {
expect(res.status).to.equal(404);
});
});
});
describe('remove two quotas', () => {
beforeEach(() => {
cy.get<Context>('@ctx').then((ctx) => {
ensureQuotaIsAdded(ctx, Unit.AuthenticatedRequests, true, 1);
ensureQuotaIsAdded(ctx, Unit.ExecutionSeconds, true, 1);
});
});
it('should remove a quota for each unit', () => {
cy.get<Context>('@ctx').then((ctx) => {
removeQuota(ctx, Unit.AuthenticatedRequests);
removeQuota(ctx, Unit.ExecutionSeconds);
});
});
});
});
});
});
describe('usage', () => {
beforeEach(() => {
cy.get<Context>('@ctx')
.then((ctx) => {
return [
`${ctx.api.oidcBaseURL}/userinfo`,
`${ctx.api.authBaseURL}/users/me`,
`${ctx.api.mgmtBaseURL}/iam`,
`${ctx.api.adminBaseURL}/instances/me`,
`${ctx.api.oauthBaseURL}/keys`,
`${ctx.api.samlBaseURL}/certificate`,
];
})
.as('authenticatedUrls');
});
describe('authenticated requests', () => {
const testUserName = 'shouldNotBeCreated';
beforeEach(() => {
cy.get<Array<string>>('@authenticatedUrls').then((urls) => {
cy.get<Context>('@ctx').then((ctx) => {
ensureUserDoesntExist(ctx.api, testUserName);
ensureQuotaIsAdded(ctx, Unit.AuthenticatedRequests, true, urls.length);
cy.task('runSQL', `TRUNCATE logstore.access;`);
});
});
});
it('authenticated requests are limited', () => {
cy.get<Array<string>>('@authenticatedUrls').then((urls) => {
cy.get<Context>('@ctx').then((ctx) => {
const start = new Date();
urls.forEach((url) => {
cy.request({
url: url,
method: 'GET',
auth: {
bearer: ctx.api.token,
},
});
});
const expiresMax = new Date();
expiresMax.setMinutes(expiresMax.getMinutes() + 2);
cy.getCookie('zitadel.quota.limiting').then((cookie) => {
expect(cookie.value).to.equal('false');
const cookieExpiry = new Date();
cookieExpiry.setTime(cookie.expiry * 1000);
expect(cookieExpiry).to.be.within(start, expiresMax);
});
cy.request({
url: urls[0],
method: 'GET',
auth: {
bearer: ctx.api.token,
},
failOnStatusCode: false,
}).then((res) => {
expect(res.status).to.equal(429);
});
cy.getCookie('zitadel.quota.limiting').then((cookie) => {
expect(cookie.value).to.equal('true');
});
createHumanUser(ctx.api, testUserName, false).then((res) => {
expect(res.status).to.equal(429);
});
ensureQuotaIsRemoved(ctx, Unit.AuthenticatedRequests);
createHumanUser(ctx.api, testUserName);
});
});
});
});
describe('notifications', () => {
const callURL = `http://${Cypress.env('WEBHOOK_HANDLER_HOST')}:${Cypress.env('WEBHOOK_HANDLER_PORT')}/do_something`;
beforeEach(() => cy.task('resetWebhookEvents'));
const amount = 100;
const percent = 10;
const usage = 25;
describe('without repetition', () => {
beforeEach(() => {
cy.get<Context>('@ctx').then((ctx) => {
ensureQuotaIsAdded(ctx, Unit.AuthenticatedRequests, false, amount, [
{
callUrl: callURL,
percent: percent,
repeat: false,
},
]);
cy.task('runSQL', `TRUNCATE logstore.access;`);
});
});
it('fires once with the expected payload', () => {
cy.get<Array<string>>('@authenticatedUrls').then((urls) => {
cy.get<Context>('@ctx').then((ctx) => {
for (let i = 0; i < usage; i++) {
cy.request({
url: urls[0],
method: 'GET',
auth: {
bearer: ctx.api.token,
},
});
}
});
cy.waitUntil(() =>
cy.task<Array<ZITADELWebhookEvent>>('handledWebhookEvents').then((events) => {
if (events.length != 1) {
return false;
}
return Cypress._.matches(<ZITADELWebhookEvent>{
callURL: callURL,
threshold: percent,
unit: 1,
usage: percent,
})(events[0]);
}),
);
});
});
});
describe('with repetition', () => {
beforeEach(() => {
cy.get<Array<string>>('@authenticatedUrls').then((urls) => {
cy.get<Context>('@ctx').then((ctx) => {
ensureQuotaIsAdded(ctx, Unit.AuthenticatedRequests, false, amount, [
{
callUrl: callURL,
percent: percent,
repeat: true,
},
]);
cy.task('runSQL', `TRUNCATE logstore.access;`);
});
});
});
it('fires repeatedly with the expected payloads', () => {
cy.get<Array<string>>('@authenticatedUrls').then((urls) => {
cy.get<Context>('@ctx').then((ctx) => {
for (let i = 0; i < usage; i++) {
cy.request({
url: urls[0],
method: 'GET',
auth: {
bearer: ctx.api.token,
},
});
}
});
});
cy.waitUntil(() =>
cy.task<Array<ZITADELWebhookEvent>>('handledWebhookEvents').then((events) => {
if (events.length != 1) {
return false;
}
for (let i = 0; i < events.length; i++) {
const threshold = percent * (i + 1);
if (
!Cypress._.matches(<ZITADELWebhookEvent>{
callURL: callURL,
threshold: threshold,
unit: 1,
usage: threshold,
})(events[i])
) {
return false;
}
}
return true;
}),
);
});
});
});
});
});

View File

@ -1,4 +1,5 @@
import { apiAuth, API } from '../../support/api/apiauth';
import { Context } from 'mocha';
import { apiAuth } from '../../support/api/apiauth';
import { Policy, resetPolicy } from '../../support/api/policies';
import { login, User } from '../../support/login/users';
@ -7,8 +8,6 @@ describe('private labeling', () => {
[User.OrgOwner].forEach((user) => {
describe(`as user "${user}"`, () => {
let api: API;
beforeEach(() => {
login(user);
cy.visit(orgPath);
@ -37,34 +36,27 @@ function customize(theme: string, user: User) {
});
it('should update a logo', () => {
cy.get('[data-e2e="image-part-logo"]')
.find('input')
.then(function (el) {
const blob = Cypress.Blob.base64StringToBlob(this.logo, 'image/png');
const file = new File([blob], 'images/logo.png', { type: 'image/png' });
const list = new DataTransfer();
cy.get<Context>('@ctx').then((ctx) => {
cy.get('[data-e2e="image-part-logo"]')
.find('input')
.then(function (el) {
const blob = Cypress.Blob.base64StringToBlob(ctx.logo, 'image/png');
const file = new File([blob], 'images/logo.png', { type: 'image/png' });
const list = new DataTransfer();
list.items.add(file);
const myFileList = list.files;
list.items.add(file);
const myFileList = list.files;
el[0].files = myFileList;
el[0].dispatchEvent(new Event('change', { bubbles: true }));
});
el[0].files = myFileList;
el[0].dispatchEvent(new Event('change', { bubbles: true }));
});
});
});
it('should delete a logo');
});
it('should update an icon');
it('should delete an icon');
it.skip('should update the background color', () => {
cy.contains('[data-e2e="color"]', 'Background Color').find('button').click(); // TODO: select data-e2e
cy.get('color-editable-input').find('input').clear().type('#ae44dc');
cy.get('[data-e2e="save-colors-button"]').click();
cy.get('[data-e2e="header-user-avatar"]').click();
cy.contains('Logout All Users').click(); // TODO: select data-e2e
login(User.LoginPolicyUser, undefined, true, null, () => {
cy.pause();
});
});
it('should update the background color');
it('should update the primary color');
it('should update the warning color');
it('should update the font color');

View File

@ -1,21 +1,36 @@
import { login, User } from 'support/login/users';
import { API } from './types';
import { API, SystemAPI, Token } from './types';
const authHeaderKey = 'Authorization',
orgIdHeaderKey = 'x-zitadel-orgid';
orgIdHeaderKey = 'x-zitadel-orgid',
backendUrl = Cypress.env('BACKEND_URL');
export function apiAuth(): Cypress.Chainable<API> {
return login(User.IAMAdminUser, 'Password1!', false, true).then((token) => {
return <API>{
token: token,
mgmtBaseURL: `${Cypress.env('BACKEND_URL')}/management/v1`,
adminBaseURL: `${Cypress.env('BACKEND_URL')}/admin/v1`,
mgmtBaseURL: `${backendUrl}/management/v1`,
adminBaseURL: `${backendUrl}/admin/v1`,
authBaseURL: `${backendUrl}/auth/v1`,
assetsBaseURL: `${backendUrl}/assets/v1`,
oauthBaseURL: `${backendUrl}/oauth/v2`,
oidcBaseURL: `${backendUrl}/oidc/v1`,
samlBaseURL: `${backendUrl}/saml/v2`,
};
});
}
export function requestHeaders(api: API, orgId?: number): object {
const headers = { [authHeaderKey]: `Bearer ${api.token}` };
export function systemAuth(): Cypress.Chainable<SystemAPI> {
return cy.task('systemToken').then((token) => {
return <SystemAPI>{
token: token,
baseURL: `${backendUrl}/system/v1`,
};
});
}
export function requestHeaders(token: Token, orgId?: string): object {
const headers = { [authHeaderKey]: `Bearer ${token.token}` };
if (orgId) {
headers[orgIdHeaderKey] = orgId;
}

View File

@ -1,20 +1,20 @@
import { requestHeaders } from './apiauth';
import { findFromList as mapFromList, searchSomething } from './search';
import { API, Entity, SearchResult } from './types';
import { API, Entity, SearchResult, Token } from './types';
export function ensureItemExists(
api: API,
token: Token,
searchPath: string,
findInList: (entity: Entity) => boolean,
createPath: string,
body: Entity,
orgId?: number,
orgId?: string,
newItemIdField: string = 'id',
searchItemIdField?: string,
): Cypress.Chainable<number> {
) {
return ensureSomething(
api,
() => searchSomething(api, searchPath, 'POST', mapFromList(findInList, searchItemIdField), orgId),
token,
() => searchSomething(token, searchPath, 'POST', mapFromList(findInList, searchItemIdField), orgId),
() => createPath,
'POST',
body,
@ -25,15 +25,15 @@ export function ensureItemExists(
}
export function ensureItemDoesntExist(
api: API,
token: Token,
searchPath: string,
findInList: (entity: Entity) => boolean,
deletePath: (entity: Entity) => string,
orgId?: number,
orgId?: string,
): Cypress.Chainable<null> {
return ensureSomething(
api,
() => searchSomething(api, searchPath, 'POST', mapFromList(findInList), orgId),
token,
() => searchSomething(token, searchPath, 'POST', mapFromList(findInList), orgId),
deletePath,
'DELETE',
null,
@ -47,8 +47,8 @@ export function ensureSetting(
mapResult: (entity: any) => SearchResult,
createPath: string,
body: any,
orgId?: number,
): Cypress.Chainable<number> {
orgId?: string,
): Cypress.Chainable<string> {
return ensureSomething(
api,
() => searchSomething(api, path, 'GET', mapResult, orgId),
@ -79,38 +79,38 @@ function awaitDesired(
}
interface EnsuredResult {
id: number;
id: string;
sequence: number;
}
export function ensureSomething(
api: API,
token: Token,
search: () => Cypress.Chainable<SearchResult>,
apiPath: (entity: Entity) => string,
ensureMethod: string,
body: Entity,
expectEntity: (entity: Entity) => boolean,
mapId?: (body: any) => number,
orgId?: number,
): Cypress.Chainable<number> {
mapId?: (body: any) => string,
orgId?: string,
): Cypress.Chainable<string> {
return search()
.then<EnsuredResult>((sRes) => {
.then((sRes) => {
if (expectEntity(sRes.entity)) {
return cy.wrap({ id: sRes.id, sequence: sRes.sequence });
return cy.wrap(<EnsuredResult>{ id: sRes.id, sequence: sRes.sequence });
}
return cy
.request({
method: ensureMethod,
url: apiPath(sRes.entity),
headers: requestHeaders(api, orgId),
headers: requestHeaders(token, orgId),
body: body,
failOnStatusCode: false,
followRedirect: false,
})
.then((cRes) => {
expect(cRes.status).to.equal(200);
return {
return <EnsuredResult>{
id: mapId ? mapId(cRes.body) : undefined,
sequence: sRes.sequence,
};
@ -118,7 +118,7 @@ export function ensureSomething(
})
.then((data) => {
return awaitDesired(90, expectEntity, search, data.sequence).then(() => {
return cy.wrap<number>(data.id);
return cy.wrap(data.id);
});
});
}

View File

@ -1,18 +1,14 @@
import { Context } from 'support/commands';
import { ensureItemExists } from './ensure';
import { getOrgUnderTest } from './orgs';
import { API } from './types';
export function ensureProjectGrantExists(
api: API,
foreignOrgId: number,
foreignProjectId: number,
): Cypress.Chainable<number> {
return getOrgUnderTest(api).then((orgUnderTest) => {
export function ensureProjectGrantExists(ctx: Context, foreignOrgId: string, foreignProjectId: string) {
return getOrgUnderTest(ctx).then((orgUnderTest) => {
return ensureItemExists(
api,
`${api.mgmtBaseURL}/projectgrants/_search`,
ctx.api,
`${ctx.api.mgmtBaseURL}/projectgrants/_search`,
(grant: any) => grant.grantedOrgId == orgUnderTest && grant.projectId == foreignProjectId,
`${api.mgmtBaseURL}/projects/${foreignProjectId}/grants`,
`${ctx.api.mgmtBaseURL}/projects/${foreignProjectId}/grants`,
{ granted_org_id: orgUnderTest },
foreignOrgId,
'grantId',

View File

@ -0,0 +1,31 @@
import { SystemAPI } from './types';
export function instanceUnderTest(api: SystemAPI): Cypress.Chainable<string> {
return cy
.request({
method: 'POST',
url: `${api.baseURL}/instances/_search`,
auth: {
bearer: api.token,
},
})
.then((res) => {
const instances = <Array<any>>res.body.result;
expect(instances.length).to.equal(
1,
'instanceUnderTest just supports running against an API with exactly one instance, yet',
);
return instances[0].id;
});
}
export function getInstance(api: SystemAPI, instanceId: string, failOnStatusCode = true) {
return cy.request({
method: 'GET',
url: `${api.baseURL}/instances/${instanceId}`,
auth: {
bearer: api.token,
},
failOnStatusCode: failOnStatusCode,
});
}

View File

@ -2,7 +2,7 @@ import { ensureItemDoesntExist, ensureItemExists } from './ensure';
import { findFromList, searchSomething } from './search';
import { API } from './types';
export function ensureHumanIsNotOrgMember(api: API, username: string): Cypress.Chainable<number> {
export function ensureHumanIsNotOrgMember(api: API, username: string) {
return ensureItemDoesntExist(
api,
`${api.mgmtBaseURL}/orgs/me/members/_search`,
@ -11,7 +11,7 @@ export function ensureHumanIsNotOrgMember(api: API, username: string): Cypress.C
);
}
export function ensureHumanIsOrgMember(api: API, username: string, roles: string[]): Cypress.Chainable<number> {
export function ensureHumanIsOrgMember(api: API, username: string, roles: string[]) {
return searchSomething(
api,
`${api.mgmtBaseURL}/users/_search`,
@ -37,13 +37,13 @@ export function ensureHumanIsNotProjectMember(
api: API,
projectId: string,
username: string,
grantId?: number,
): Cypress.Chainable<number> {
grantId?: string,
): Cypress.Chainable<string> {
return ensureItemDoesntExist(
api,
`${api.mgmtBaseURL}/projects/${projectId}/${grantId ? `grants/${grantId}/` : ''}members/_search`,
(member: any) => (<string>member.preferredLoginName).startsWith(username),
(member) => `${api.mgmtBaseURL}/projects/${projectId}${grantId ? `grants/${grantId}/` : ''}/members/${member.userId}`,
(member) => `${api.mgmtBaseURL}/projects/${projectId}/${grantId ? `grants/${grantId}/` : ''}members/${member.userId}`,
);
}
@ -52,8 +52,8 @@ export function ensureHumanIsProjectMember(
projectId: string,
username: string,
roles: string[],
grantId?: number,
): Cypress.Chainable<number> {
grantId?: string,
): Cypress.Chainable<string> {
return searchSomething(
api,
`${api.mgmtBaseURL}/users/_search`,

View File

@ -7,7 +7,7 @@ export function ensureOIDCSettingsSet(
idTokenLifetime: number,
refreshTokenExpiration: number,
refreshTokenIdleExpiration: number,
): Cypress.Chainable<number> {
) {
return ensureSetting(
api,
`${api.adminBaseURL}/settings/oidc`,

View File

@ -3,20 +3,21 @@ import { searchSomething } from './search';
import { API } from './types';
import { host } from '../login/users';
import { requestHeaders } from './apiauth';
import { Context } from 'support/commands';
export function ensureOrgExists(api: API, name: string): Cypress.Chainable<number> {
export function ensureOrgExists(ctx: Context, name: string) {
return ensureSomething(
api,
ctx.api,
() =>
searchSomething(
api,
encodeURI(`${api.mgmtBaseURL}/global/orgs/_by_domain?domain=${name}.${host(Cypress.config('baseUrl'))}`),
ctx.api,
encodeURI(`${ctx.api.mgmtBaseURL}/global/orgs/_by_domain?domain=${name}.${host(Cypress.config('baseUrl'))}`),
'GET',
(res) => {
return { entity: res.org, id: res.org?.id, sequence: parseInt(<string>res.org?.details?.sequence) };
},
),
() => `${api.mgmtBaseURL}/orgs`,
() => `${ctx.api.mgmtBaseURL}/orgs`,
'POST',
{ name: name },
(org) => org?.name === name,
@ -24,13 +25,13 @@ export function ensureOrgExists(api: API, name: string): Cypress.Chainable<numbe
);
}
export function isDefaultOrg(api: API, orgId: number): Cypress.Chainable<boolean> {
export function isDefaultOrg(ctx: Context, orgId: string): Cypress.Chainable<boolean> {
console.log('huhu', orgId);
return cy
.request({
method: 'GET',
url: encodeURI(`${api.mgmtBaseURL}/iam`),
headers: requestHeaders(api, orgId),
url: encodeURI(`${ctx.api.mgmtBaseURL}/iam`),
headers: requestHeaders(ctx.api, orgId),
})
.then((res) => {
const { defaultOrgId } = res.body;
@ -39,12 +40,12 @@ export function isDefaultOrg(api: API, orgId: number): Cypress.Chainable<boolean
});
}
export function ensureOrgIsDefault(api: API, orgId: number): Cypress.Chainable<boolean> {
export function ensureOrgIsDefault(ctx: Context, orgId: string): Cypress.Chainable<boolean> {
return cy
.request({
method: 'GET',
url: encodeURI(`${api.mgmtBaseURL}/iam`),
headers: requestHeaders(api, orgId),
url: encodeURI(`${ctx.api.mgmtBaseURL}/iam`),
headers: requestHeaders(ctx.api, orgId),
})
.then((res) => {
return res.body;
@ -56,8 +57,8 @@ export function ensureOrgIsDefault(api: API, orgId: number): Cypress.Chainable<b
return cy
.request({
method: 'PUT',
url: `${api.adminBaseURL}/orgs/default/${orgId}`,
headers: requestHeaders(api, orgId),
url: `${ctx.api.adminBaseURL}/orgs/default/${orgId}`,
headers: requestHeaders(ctx.api, orgId),
failOnStatusCode: true,
followRedirect: false,
})
@ -69,8 +70,8 @@ export function ensureOrgIsDefault(api: API, orgId: number): Cypress.Chainable<b
});
}
export function getOrgUnderTest(api: API): Cypress.Chainable<number> {
return searchSomething(api, `${api.mgmtBaseURL}/orgs/me`, 'GET', (res) => {
export function getOrgUnderTest(ctx: Context): Cypress.Chainable<number> {
return searchSomething(ctx.api, `${ctx.api.mgmtBaseURL}/orgs/me`, 'GET', (res) => {
return { entity: res.org, id: res.org.id, sequence: parseInt(<string>res.org.details.sequence) };
}).then((res) => res.entity.id);
}

View File

@ -22,7 +22,7 @@ export function ensureDomainPolicy(
userLoginMustBeDomain: boolean,
validateOrgDomains: boolean,
smtpSenderAddressMatchesInstanceDomain: boolean,
): Cypress.Chainable<number> {
) {
return ensureSetting(
api,
`${api.adminBaseURL}/policies/domain`,

View File

@ -1,7 +1,7 @@
import { ensureItemDoesntExist, ensureItemExists } from './ensure';
import { API } from './types';
import { API, Entity } from './types';
export function ensureProjectExists(api: API, projectName: string, orgId?: number): Cypress.Chainable<number> {
export function ensureProjectExists(api: API, projectName: string, orgId?: string) {
return ensureItemExists(
api,
`${api.mgmtBaseURL}/projects/_search`,
@ -12,7 +12,7 @@ export function ensureProjectExists(api: API, projectName: string, orgId?: numbe
);
}
export function ensureProjectDoesntExist(api: API, projectName: string, orgId?: number): Cypress.Chainable<null> {
export function ensureProjectDoesntExist(api: API, projectName: string, orgId?: string) {
return ensureItemDoesntExist(
api,
`${api.mgmtBaseURL}/projects/_search`,
@ -32,22 +32,22 @@ export const Roles = new ResourceType('roles', 'key', 'key');
export function ensureProjectResourceDoesntExist(
api: API,
projectId: number,
projectId: string,
resourceType: ResourceType,
resourceName: string,
orgId?: number,
orgId?: string,
): Cypress.Chainable<null> {
return ensureItemDoesntExist(
api,
`${api.mgmtBaseURL}/projects/${projectId}/${resourceType.resourcePath}/_search`,
(resource: any) => resource[resourceType.compareProperty] === resourceName,
(resource) =>
(resource: Entity) => resource[resourceType.compareProperty] === resourceName,
(resource: Entity) =>
`${api.mgmtBaseURL}/projects/${projectId}/${resourceType.resourcePath}/${resource[resourceType.identifierProperty]}`,
orgId,
);
}
export function ensureApplicationExists(api: API, projectId: number, appName: string): Cypress.Chainable<number> {
export function ensureApplicationExists(api: API, projectId: number, appName: string) {
return ensureItemExists(
api,
`${api.mgmtBaseURL}/projects/${projectId}/${Apps.resourcePath}/_search`,

View File

@ -0,0 +1,84 @@
import { Context } from 'support/commands';
export enum Unit {
Unimplemented,
AuthenticatedRequests,
ExecutionSeconds,
}
interface notification {
percent: number;
repeat?: boolean;
callUrl: string;
}
export function addQuota(
ctx: Context,
unit: Unit = Unit.AuthenticatedRequests,
limit: boolean,
amount: number,
notifications?: Array<notification>,
from: Date = (() => {
const date = new Date();
date.setMonth(0, 1);
date.setMinutes(0, 0, 0);
// default to start of current year
return date;
})(),
intervalSeconds: string = `${315_576_000_000}s`, // proto max duration is 1000 years
failOnStatusCode = true,
): Cypress.Chainable<Cypress.Response<any>> {
return cy.request({
method: 'POST',
url: `${ctx.system.baseURL}/instances/${ctx.instanceId}/quotas`,
auth: {
bearer: ctx.system.token,
},
body: {
unit: unit,
amount: amount,
resetInterval: intervalSeconds,
limit: limit,
from: from,
notifications: notifications,
},
failOnStatusCode: failOnStatusCode,
});
}
export function ensureQuotaIsAdded(
ctx: Context,
unit: Unit,
limit: boolean,
amount?: number,
notifications?: Array<notification>,
from?: Date,
intervalSeconds?: string,
): Cypress.Chainable<null> {
return addQuota(ctx, unit, limit, amount, notifications, from, intervalSeconds, false).then((res) => {
if (!res.isOkStatusCode) {
expect(res.status).to.equal(409);
}
return null;
});
}
export function removeQuota(ctx: Context, unit: Unit, failOnStatusCode = true): Cypress.Chainable<Cypress.Response<any>> {
return cy.request({
method: 'DELETE',
url: `${ctx.system.baseURL}/instances/${ctx.instanceId}/quotas/${unit}`,
auth: {
bearer: ctx.system.token,
},
failOnStatusCode: failOnStatusCode,
});
}
export function ensureQuotaIsRemoved(ctx: Context, unit?: Unit): Cypress.Chainable<null> {
return removeQuota(ctx, unit, false).then((res) => {
if (!res.isOkStatusCode) {
expect(res.status).to.equal(404);
}
return null;
});
}

View File

@ -1,18 +1,18 @@
import { requestHeaders } from './apiauth';
import { API, Entity, SearchResult } from './types';
import { API, Entity, SearchResult, Token } from './types';
export function searchSomething(
api: API,
token: Token,
searchPath: string,
method: string,
mapResult: (body: any) => SearchResult,
orgId?: number,
orgId?: string,
): Cypress.Chainable<SearchResult> {
return cy
.request({
method: method,
url: searchPath,
headers: requestHeaders(api, orgId),
headers: requestHeaders(token, orgId),
failOnStatusCode: method == 'POST',
})
.then((res) => {

View File

@ -1,13 +1,25 @@
export interface API {
export interface Token {
token: string;
}
export interface API extends Token {
mgmtBaseURL: string;
adminBaseURL: string;
authBaseURL: string;
assetsBaseURL: string;
oidcBaseURL: string;
oauthBaseURL: string;
samlBaseURL: string;
}
export interface SystemAPI extends Token {
baseURL: string;
}
export type SearchResult = {
entity: Entity | null;
sequence: number;
id: number;
id: string;
};
// Entity is an object but not a function

View File

@ -1,31 +1,23 @@
import { requestHeaders } from './apiauth';
import { ensureItemDoesntExist, ensureItemExists } from './ensure';
import { API } from './types';
export function ensureHumanUserExists(api: API, username: string): Cypress.Chainable<number> {
export function ensureHumanUserExists(api: API, username: string) {
return ensureItemExists(
api,
`${api.mgmtBaseURL}/users/_search`,
(user: any) => user.userName === username,
`${api.mgmtBaseURL}/users/human`,
{
...defaultHuman,
user_name: username,
profile: {
first_name: 'e2efirstName',
last_name: 'e2elastName',
},
email: {
email: 'e2e@email.ch',
},
phone: {
phone: '+41 123456789',
},
},
undefined,
'userId',
);
}
export function ensureMachineUserExists(api: API, username: string): Cypress.Chainable<number> {
export function ensureMachineUserExists(api: API, username: string) {
return ensureItemExists(
api,
`${api.mgmtBaseURL}/users/_search`,
@ -41,7 +33,7 @@ export function ensureMachineUserExists(api: API, username: string): Cypress.Cha
);
}
export function ensureUserDoesntExist(api: API, username: string): Cypress.Chainable<null> {
export function ensureUserDoesntExist(api: API, username: string) {
return ensureItemDoesntExist(
api,
`${api.mgmtBaseURL}/users/_search`,
@ -49,3 +41,31 @@ export function ensureUserDoesntExist(api: API, username: string): Cypress.Chain
(user) => `${api.mgmtBaseURL}/users/${user.id}`,
);
}
export function createHumanUser(api: API, username: string, failOnStatusCode = true) {
return cy.request({
method: 'POST',
url: `${api.mgmtBaseURL}/users/human`,
body: {
...defaultHuman,
user_name: username,
},
auth: {
bearer: api.token,
},
failOnStatusCode: failOnStatusCode,
});
}
const defaultHuman = {
profile: {
first_name: 'e2efirstName',
last_name: 'e2elastName',
},
email: {
email: 'e2e@email.ch',
},
phone: {
phone: '+41 123456789',
},
};

View File

@ -1,29 +1,8 @@
import 'cypress-wait-until';
//
//namespace Cypress {
// interface Chainable {
// /**
// * Custom command that authenticates a user.
// *
// * @example cy.consolelogin('hodor', 'hodor1234')
// */
// consolelogin(username: string, password: string): void
// }
//}
//
//Cypress.Commands.add('consolelogin', { prevSubject: false }, (username: string, password: string) => {
//
// window.sessionStorage.removeItem("zitadel:access_token")
// cy.visit(Cypress.config('baseUrl')/ui/console).then(() => {
// // fill the fields and push button
// cy.get('#loginName').type(username, { log: false })
// cy.get('#submit-button').click()
// cy.get('#password').type(password, { log: false })
// cy.get('#submit-button').click()
// cy.location('pathname', {timeout: 5 * 1000}).should('eq', '/');
// })
//})
//
import { apiAuth, systemAuth } from './api/apiauth';
import { API, SystemAPI } from './api/types';
import { ensureQuotaIsRemoved, Unit } from './api/quota';
import { instanceUnderTest } from './api/instances';
interface ShouldNotExistOptions {
selector: string;
@ -46,7 +25,13 @@ declare global {
/**
* Custom command that waits until the selector finds zero elements.
*/
shouldNotExist(options: ShouldNotExistOptions): Cypress.Chainable<null>;
shouldNotExist(options?: ShouldNotExistOptions): Cypress.Chainable<null>;
/**
* Custom command that ensures a reliable testing context and returns it
*/
context(): Cypress.Chainable<Context>;
/**
* Custom command that asserts success is printed after a change.
*/
@ -55,6 +40,12 @@ declare global {
}
}
export interface Context {
api: API;
system: SystemAPI;
instanceId: number;
}
Cypress.Commands.add('clipboardMatches', { prevSubject: false }, (pattern: RegExp | string) => {
/* doesn't work reliably
return cy.window()
@ -106,3 +97,35 @@ Cypress.Commands.add('shouldConfirmSuccess', { prevSubject: false }, () => {
cy.shouldNotExist({ selector: '.data-e2e-failure' });
cy.get('.data-e2e-success');
});
Cypress.Commands.add('context', { prevSubject: false }, () => {
return systemAuth().then((system) => {
return instanceUnderTest(system).then((instanceId) => {
return ensureQuotaIsRemoved(
{
system: system,
api: null,
instanceId: instanceId,
},
Unit.AuthenticatedRequests,
).then(() => {
return ensureQuotaIsRemoved(
{
system: system,
api: null,
instanceId: instanceId,
},
Unit.ExecutionSeconds,
).then(() => {
return apiAuth().then((api) => {
return {
system: system,
api: api,
instanceId: instanceId,
};
});
});
});
});
});
});

View File

@ -15,6 +15,4 @@
// Import commands.js using ES2015 syntax:
import './commands';
// Alternatively you can use CommonJS syntax:
// require('./commands')
import './types';

View File

@ -1,10 +0,0 @@
require('cypress-terminal-report/src/installLogsCollector')();
/**
* @type {Cypress.PluginConfig}
*/
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
};
//import './commands'

View File

@ -82,8 +82,10 @@ export function login(
onAuthenticated ? onAuthenticated() : null;
cy.visit('/');
cy.get('[data-e2e=authenticated-welcome]', {
timeout: 10_000,
timeout: 50_000,
});
},
{

View File

@ -0,0 +1,10 @@
let webhookEventSchema = {
unit: 0,
id: '',
callURL: '',
periodStart: new Date(),
threshold: 0,
usage: 0,
};
export type ZITADELWebhookEvent = typeof webhookEventSchema;

View File

@ -0,0 +1,31 @@
FirstInstance:
Org:
Human:
PasswordChangeRequired: false
LogStore:
Access:
Database:
Enabled: true
Debounce:
MinFrequency: 0s
MaxBulkSize: 0
Execution:
Database:
Enabled: true
Stdout:
Enabled: false
Quotas:
Access:
ExhaustedCookieKey: "zitadel.quota.limiting"
ExhaustedCookieMaxAge: "60s"
DefaultInstance:
LoginPolicy:
MfaInitSkipLifetime: 0
SystemAPIUsers:
- cypress:
KeyData: "LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUF6aStGRlNKTDdmNXl3NEtUd3pnTQpQMzRlUEd5Y20vTStrVDBNN1Y0Q2d4NVYzRWFESXZUUUtUTGZCYUVCNDV6YjlMdGpJWHpEdzByWFJvUzJoTzZ0CmgrQ1lRQ3ozS0N2aDA5QzBJenhaaUIySVMzSC9hVCs1Qng5RUZZK3ZuQWtaamNjYnlHNVlOUnZtdE9sbnZJZUkKSDdxWjB0RXdrUGZGNUdFWk5QSlB0bXkzVUdWN2lvZmRWUVMxeFJqNzMrYU13NXJ2SDREOElkeWlBQzNWZWtJYgpwdDBWajBTVVgzRHdLdG9nMzM3QnpUaVBrM2FYUkYwc2JGaFFvcWRKUkk4TnFnWmpDd2pxOXlmSTV0eXhZc3duCitKR3pIR2RIdlczaWRPRGxtd0V0NUsycGFzaVJJV0syT0dmcSt3MEVjbHRRSGFidXFFUGdabG1oQ2tSZE5maXgKQndJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg=="

View File

@ -2,20 +2,16 @@ version: '3.8'
services:
zitadel:
user: '$UID'
restart: 'always'
image: '${ZITADEL_IMAGE:-ghcr.io/zitadel/zitadel:latest}'
command: 'start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled'
environment:
ZITADEL_DATABASE_COCKROACH_HOST: db
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: false
ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_MFAINITSKIPLIFETIME: 0
command: 'start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled --config /zitadel.yaml --steps /zitadel.yaml'
depends_on:
db:
condition: 'service_healthy'
ports:
- '8080:8080'
networks:
- zitadel
volumes:
- ./docker-compose-zitadel.yaml:/zitadel.yaml
network_mode: host
db:
restart: 'always'
@ -27,11 +23,7 @@ services:
timeout: '30s'
retries: 5
start_period: '20s'
ports:
- '9090:8080'
- '26257:26257'
networks:
- zitadel
network_mode: host
prepare:
image: node:18-alpine3.15
@ -43,7 +35,7 @@ services:
network_mode: host
e2e:
image: cypress/included:10.9.0
image: cypress/included:12.2.0
depends_on:
zitadel:
condition: 'service_started'

454
e2e/package-lock.json generated
View File

@ -8,8 +8,11 @@
"name": "zitadel-e2e",
"version": "0.0.0",
"dependencies": {
"@types/pg": "^8.6.6",
"cypress-wait-until": "^1.7.2",
"jsonwebtoken": "^8.5.1",
"mochawesome": "^7.1.3",
"pg": "^8.8.0",
"prettier": "^2.7.1",
"typescript": "^4.8.4",
"uuid": "^9.0.0",
@ -17,7 +20,7 @@
},
"devDependencies": {
"@types/node": "^18.8.3",
"cypress": "^10.9.0"
"cypress": "^12.2.0"
}
},
"node_modules/@colors/colors": {
@ -121,8 +124,17 @@
"node_modules/@types/node": {
"version": "18.8.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.8.3.tgz",
"integrity": "sha512-0os9vz6BpGwxGe9LOhgP/ncvYN5Tx1fNcd2TM3rD/aCGBkysb+ZWpXEocG24h6ZzOi13+VB8HndAQFezsSOw1w==",
"dev": true
"integrity": "sha512-0os9vz6BpGwxGe9LOhgP/ncvYN5Tx1fNcd2TM3rD/aCGBkysb+ZWpXEocG24h6ZzOi13+VB8HndAQFezsSOw1w=="
},
"node_modules/@types/pg": {
"version": "8.6.6",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.6.6.tgz",
"integrity": "sha512-O2xNmXebtwVekJDD+02udOncjVcMZQuTEQEMpKJ0ZRf5E7/9JJX3izhKUcUifBkyKpljyUM6BTgy2trmviKlpw==",
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^2.2.0"
}
},
"node_modules/@types/sinonjs__fake-timers": {
"version": "8.1.1",
@ -436,6 +448,19 @@
"node": "*"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
},
"node_modules/buffer-writer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz",
"integrity": "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==",
"engines": {
"node": ">=4"
}
},
"node_modules/cachedir": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.3.0.tgz",
@ -671,9 +696,9 @@
}
},
"node_modules/cypress": {
"version": "10.9.0",
"resolved": "https://registry.npmjs.org/cypress/-/cypress-10.9.0.tgz",
"integrity": "sha512-MjIWrRpc+bQM9U4kSSdATZWZ2hUqHGFEQTF7dfeZRa4MnalMtc88FIE49USWP2ZVtfy5WPBcgfBX+YorFqGElA==",
"version": "12.2.0",
"resolved": "https://registry.npmjs.org/cypress/-/cypress-12.2.0.tgz",
"integrity": "sha512-kvl95ri95KK8mAy++tEU/wUgzAOMiIciZSL97LQvnOinb532m7dGvwN0mDSIGbOd71RREtmT9o4h088RjK5pKw==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
@ -724,7 +749,7 @@
"cypress": "bin/cypress"
},
"engines": {
"node": ">=12.0.0"
"node": "^14.0.0 || ^16.0.0 || >=18.0.0"
}
},
"node_modules/cypress-wait-until": {
@ -819,6 +844,14 @@
"safer-buffer": "^2.1.0"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@ -1483,6 +1516,35 @@
"graceful-fs": "^4.1.6"
}
},
"node_modules/jsonwebtoken": {
"version": "8.5.1",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz",
"integrity": "sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==",
"dependencies": {
"jws": "^3.2.2",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^5.6.0"
},
"engines": {
"node": ">=4",
"npm": ">=1.4.28"
}
},
"node_modules/jsonwebtoken/node_modules/semver": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
"integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
"bin": {
"semver": "bin/semver"
}
},
"node_modules/jsprim": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz",
@ -1498,6 +1560,25 @@
"verror": "1.10.0"
}
},
"node_modules/jwa": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz",
"integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==",
"dependencies": {
"buffer-equal-constant-time": "1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
"dependencies": {
"jwa": "^1.4.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/lazy-ass": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz",
@ -1554,6 +1635,16 @@
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="
},
"node_modules/lodash.isempty": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.isempty/-/lodash.isempty-4.4.0.tgz",
@ -1564,11 +1655,26 @@
"resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz",
"integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw=="
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="
},
"node_modules/lodash.isobject": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/lodash.isobject/-/lodash.isobject-3.0.2.tgz",
"integrity": "sha512-3/Qptq2vr7WeJbB4KHUSKlq8Pl7ASXi3UG6CMbBm8WRtXi8+GHm7mKaU3urfpSEzWe2wCIChs6/sdocUsTKJiA=="
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
@ -1577,8 +1683,7 @@
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"dev": true
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="
},
"node_modules/log-symbols": {
"version": "4.1.0",
@ -2004,6 +2109,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/packet-reader": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz",
"integrity": "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ=="
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@ -2042,6 +2152,80 @@
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"dev": true
},
"node_modules/pg": {
"version": "8.8.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.8.0.tgz",
"integrity": "sha512-UXYN0ziKj+AeNNP7VDMwrehpACThH7LUl/p8TDFpEUuSejCUIwGSfxpHsPvtM6/WXFy6SU4E5RG4IJV/TZAGjw==",
"dependencies": {
"buffer-writer": "2.0.0",
"packet-reader": "1.0.0",
"pg-connection-string": "^2.5.0",
"pg-pool": "^3.5.2",
"pg-protocol": "^1.5.0",
"pg-types": "^2.1.0",
"pgpass": "1.x"
},
"engines": {
"node": ">= 8.0.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-connection-string": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.5.0.tgz",
"integrity": "sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ=="
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.5.2.tgz",
"integrity": "sha512-His3Fh17Z4eg7oANLob6ZvH8xIVen3phEZh2QuyrIl4dQSDVEabNducv6ysROKpDNPSD+12tONZVWfSgMvDD9w==",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.5.0.tgz",
"integrity": "sha512-muRttij7H8TqRNu/DxrAJQITO4Ac7RmX3Klyr/9mJEOBeIpgnF8f9jAfRz5d3XwQZl5qBjF9gLsUtMPJE0vezQ=="
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
@ -2063,6 +2247,41 @@
"node": ">=0.10.0"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/prettier": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz",
@ -2314,6 +2533,14 @@
"node": ">=8"
}
},
"node_modules/split2": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.1.0.tgz",
"integrity": "sha512-VBiJxFkxiXRlUIeyMQi8s4hgvKCSjtknJv/LVYbrgALPwf5zSKmEwV9Lst25AkvMDnvxODugjdl6KZgwKM1WYQ==",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/sshpk": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.17.0.tgz",
@ -2614,6 +2841,14 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"engines": {
"node": ">=0.4"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
@ -2790,8 +3025,17 @@
"@types/node": {
"version": "18.8.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.8.3.tgz",
"integrity": "sha512-0os9vz6BpGwxGe9LOhgP/ncvYN5Tx1fNcd2TM3rD/aCGBkysb+ZWpXEocG24h6ZzOi13+VB8HndAQFezsSOw1w==",
"dev": true
"integrity": "sha512-0os9vz6BpGwxGe9LOhgP/ncvYN5Tx1fNcd2TM3rD/aCGBkysb+ZWpXEocG24h6ZzOi13+VB8HndAQFezsSOw1w=="
},
"@types/pg": {
"version": "8.6.6",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.6.6.tgz",
"integrity": "sha512-O2xNmXebtwVekJDD+02udOncjVcMZQuTEQEMpKJ0ZRf5E7/9JJX3izhKUcUifBkyKpljyUM6BTgy2trmviKlpw==",
"requires": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^2.2.0"
}
},
"@types/sinonjs__fake-timers": {
"version": "8.1.1",
@ -3018,6 +3262,16 @@
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
"dev": true
},
"buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
},
"buffer-writer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz",
"integrity": "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw=="
},
"cachedir": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.3.0.tgz",
@ -3191,9 +3445,9 @@
}
},
"cypress": {
"version": "10.9.0",
"resolved": "https://registry.npmjs.org/cypress/-/cypress-10.9.0.tgz",
"integrity": "sha512-MjIWrRpc+bQM9U4kSSdATZWZ2hUqHGFEQTF7dfeZRa4MnalMtc88FIE49USWP2ZVtfy5WPBcgfBX+YorFqGElA==",
"version": "12.2.0",
"resolved": "https://registry.npmjs.org/cypress/-/cypress-12.2.0.tgz",
"integrity": "sha512-kvl95ri95KK8mAy++tEU/wUgzAOMiIciZSL97LQvnOinb532m7dGvwN0mDSIGbOd71RREtmT9o4h088RjK5pKw==",
"dev": true,
"requires": {
"@cypress/request": "^2.88.10",
@ -3308,6 +3562,14 @@
"safer-buffer": "^2.1.0"
}
},
"ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"requires": {
"safe-buffer": "^5.0.1"
}
},
"emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@ -3791,6 +4053,30 @@
"universalify": "^2.0.0"
}
},
"jsonwebtoken": {
"version": "8.5.1",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz",
"integrity": "sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==",
"requires": {
"jws": "^3.2.2",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^5.6.0"
},
"dependencies": {
"semver": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
"integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ=="
}
}
},
"jsprim": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz",
@ -3803,6 +4089,25 @@
"verror": "1.10.0"
}
},
"jwa": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz",
"integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==",
"requires": {
"buffer-equal-constant-time": "1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"jws": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
"requires": {
"jwa": "^1.4.1",
"safe-buffer": "^5.0.1"
}
},
"lazy-ass": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz",
@ -3839,6 +4144,16 @@
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="
},
"lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="
},
"lodash.isempty": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.isempty/-/lodash.isempty-4.4.0.tgz",
@ -3849,11 +4164,26 @@
"resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz",
"integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw=="
},
"lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="
},
"lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="
},
"lodash.isobject": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/lodash.isobject/-/lodash.isobject-3.0.2.tgz",
"integrity": "sha512-3/Qptq2vr7WeJbB4KHUSKlq8Pl7ASXi3UG6CMbBm8WRtXi8+GHm7mKaU3urfpSEzWe2wCIChs6/sdocUsTKJiA=="
},
"lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="
},
"lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
@ -3862,8 +4192,7 @@
"lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"dev": true
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="
},
"log-symbols": {
"version": "4.1.0",
@ -4178,6 +4507,11 @@
"aggregate-error": "^3.0.0"
}
},
"packet-reader": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz",
"integrity": "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ=="
},
"path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@ -4207,6 +4541,61 @@
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"dev": true
},
"pg": {
"version": "8.8.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.8.0.tgz",
"integrity": "sha512-UXYN0ziKj+AeNNP7VDMwrehpACThH7LUl/p8TDFpEUuSejCUIwGSfxpHsPvtM6/WXFy6SU4E5RG4IJV/TZAGjw==",
"requires": {
"buffer-writer": "2.0.0",
"packet-reader": "1.0.0",
"pg-connection-string": "^2.5.0",
"pg-pool": "^3.5.2",
"pg-protocol": "^1.5.0",
"pg-types": "^2.1.0",
"pgpass": "1.x"
}
},
"pg-connection-string": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.5.0.tgz",
"integrity": "sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ=="
},
"pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="
},
"pg-pool": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.5.2.tgz",
"integrity": "sha512-His3Fh17Z4eg7oANLob6ZvH8xIVen3phEZh2QuyrIl4dQSDVEabNducv6ysROKpDNPSD+12tONZVWfSgMvDD9w==",
"requires": {}
},
"pg-protocol": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.5.0.tgz",
"integrity": "sha512-muRttij7H8TqRNu/DxrAJQITO4Ac7RmX3Klyr/9mJEOBeIpgnF8f9jAfRz5d3XwQZl5qBjF9gLsUtMPJE0vezQ=="
},
"pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"requires": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
}
},
"pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"requires": {
"split2": "^4.1.0"
}
},
"picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
@ -4219,6 +4608,29 @@
"integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
"dev": true
},
"postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="
},
"postgres-bytea": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w=="
},
"postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="
},
"postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"requires": {
"xtend": "^4.0.0"
}
},
"prettier": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz",
@ -4405,6 +4817,11 @@
"is-fullwidth-code-point": "^3.0.0"
}
},
"split2": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.1.0.tgz",
"integrity": "sha512-VBiJxFkxiXRlUIeyMQi8s4hgvKCSjtknJv/LVYbrgALPwf5zSKmEwV9Lst25AkvMDnvxODugjdl6KZgwKM1WYQ=="
},
"sshpk": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.17.0.tgz",
@ -4618,6 +5035,11 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
},
"xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="
},
"y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@ -11,8 +11,11 @@
},
"private": true,
"dependencies": {
"@types/pg": "^8.6.6",
"cypress-wait-until": "^1.7.2",
"jsonwebtoken": "^8.5.1",
"mochawesome": "^7.1.3",
"pg": "^8.8.0",
"prettier": "^2.7.1",
"typescript": "^4.8.4",
"uuid": "^9.0.0",
@ -20,6 +23,6 @@
},
"devDependencies": {
"@types/node": "^18.8.3",
"cypress": "^10.9.0"
"cypress": "^12.2.0"
}
}

3
go.mod
View File

@ -12,6 +12,7 @@ require (
github.com/VictoriaMetrics/fastcache v1.8.0
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b
github.com/allegro/bigcache v1.2.1
github.com/benbjohnson/clock v1.2.0
github.com/boombuler/barcode v1.0.1
github.com/cockroachdb/cockroach-go/v2 v2.2.18
github.com/dop251/goja v0.0.0-20220815083517-0c74f9139fd6
@ -170,7 +171,7 @@ require (
github.com/rs/xid v1.2.1 // indirect
github.com/russellhaering/goxmldsig v1.2.0 // indirect
github.com/satori/go.uuid v1.2.0 // indirect
github.com/sirupsen/logrus v1.9.0 // indirect
github.com/sirupsen/logrus v1.9.0
github.com/spf13/afero v1.8.1 // indirect
github.com/spf13/cast v1.4.1 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect

View File

@ -6,6 +6,8 @@ import (
"fmt"
"github.com/dop251/goja_nodejs/require"
"github.com/sirupsen/logrus"
z_errs "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/query"
)
@ -14,15 +16,45 @@ type Config struct {
HTTP HTTPConfig
}
var (
ErrHalt = errors.New("interrupt")
)
var ErrHalt = errors.New("interrupt")
type jsAction func(fields, fields) error
func Run(ctx context.Context, ctxParam contextFields, apiParam apiFields, script, name string, opts ...Option) error {
config, err := prepareRun(ctx, ctxParam, apiParam, script, opts)
if err != nil {
const (
actionStartedMessage = "action run started"
actionSucceededMessage = "action run succeeded"
)
func actionFailedMessage(err error) string {
return fmt.Sprintf("action run failed: %s", err.Error())
}
func Run(ctx context.Context, ctxParam contextFields, apiParam apiFields, script, name string, opts ...Option) (err error) {
config := newRunConfig(ctx, append(opts, withLogger(ctx))...)
if config.functionTimeout == 0 {
return z_errs.ThrowInternal(nil, "ACTIO-uCpCx", "Errrors.Internal")
}
remaining := logstoreService.Limit(ctx, config.instanceID)
config.cutTimeouts(remaining)
config.logger.Log(actionStartedMessage)
if remaining != nil && *remaining == 0 {
return z_errs.ThrowResourceExhausted(nil, "ACTIO-f19Ii", "Errors.Quota.Execution.Exhausted")
}
defer func() {
if err != nil {
config.logger.log(actionFailedMessage(err), logrus.ErrorLevel, true)
} else {
config.logger.log(actionSucceededMessage, logrus.InfoLevel, true)
}
if config.allowedToFail {
err = nil
}
}()
if err := executeScript(config, ctxParam, apiParam, script); err != nil {
return err
}
@ -31,12 +63,11 @@ func Run(ctx context.Context, ctxParam contextFields, apiParam apiFields, script
if jsFn == nil {
return errors.New("function not found")
}
err = config.vm.ExportTo(jsFn, &fn)
if err != nil {
if err := config.vm.ExportTo(jsFn, &fn); err != nil {
return err
}
t := config.Start()
t := config.StartFunction()
defer func() {
t.Stop()
}()
@ -44,12 +75,8 @@ func Run(ctx context.Context, ctxParam contextFields, apiParam apiFields, script
return executeFn(config, fn)
}
func prepareRun(ctx context.Context, ctxParam contextFields, apiParam apiFields, script string, opts []Option) (config *runConfig, err error) {
config = newRunConfig(ctx, opts...)
if config.timeout == 0 {
return nil, z_errs.ThrowInternal(nil, "ACTIO-uCpCx", "Errrors.Internal")
}
t := config.Prepare()
func executeScript(config *runConfig, ctxParam contextFields, apiParam apiFields, script string) (err error) {
t := config.StartScript()
defer func() {
t.Stop()
}()
@ -67,7 +94,6 @@ func prepareRun(ctx context.Context, ctxParam contextFields, apiParam apiFields,
for name, loader := range config.modules {
registry.RegisterNativeModule(name, loader)
}
// overload error if function panics
defer func() {
r := recover()
@ -76,29 +102,31 @@ func prepareRun(ctx context.Context, ctxParam contextFields, apiParam apiFields,
return
}
}()
_, err = config.vm.RunString(script)
return config, err
return err
}
func executeFn(config *runConfig, fn jsAction) (err error) {
defer func() {
r := recover()
if r != nil && !config.allowedToFail {
var ok bool
if err, ok = r.(error); ok {
return
}
e, ok := r.(string)
if ok {
err = errors.New(e)
return
}
err = fmt.Errorf("unknown error occured: %v", r)
if r == nil {
return
}
var ok bool
if err, ok = r.(error); ok {
return
}
e, ok := r.(string)
if ok {
err = errors.New(e)
return
}
err = fmt.Errorf("unknown error occurred: %v", r)
}()
err = fn(config.ctxParam.fields, config.apiParam.fields)
if err != nil && !config.allowedToFail {
if err = fn(config.ctxParam.fields, config.apiParam.fields); err != nil {
return err
}
return nil

View File

@ -7,9 +7,11 @@ import (
"time"
"github.com/dop251/goja"
"github.com/zitadel/zitadel/internal/logstore"
)
func TestRun(t *testing.T) {
SetLogstoreService(logstore.New(nil, nil, nil))
type args struct {
timeout time.Duration
api apiFields

View File

@ -23,13 +23,14 @@ func WithAllowedToFail() Option {
type runConfig struct {
allowedToFail bool
timeout,
prepareTimeout time.Duration
modules map[string]require.ModuleLoader
vm *goja.Runtime
ctxParam *ctxConfig
apiParam *apiConfig
functionTimeout,
scriptTimeout time.Duration
modules map[string]require.ModuleLoader
logger *logger
instanceID string
vm *goja.Runtime
ctxParam *ctxConfig
apiParam *apiConfig
}
func newRunConfig(ctx context.Context, opts ...Option) *runConfig {
@ -42,10 +43,10 @@ func newRunConfig(ctx context.Context, opts ...Option) *runConfig {
vm.SetFieldNameMapper(goja.UncapFieldNameMapper())
config := &runConfig{
timeout: time.Until(deadline),
prepareTimeout: maxPrepareTimeout,
modules: map[string]require.ModuleLoader{},
vm: vm,
functionTimeout: time.Until(deadline),
scriptTimeout: maxPrepareTimeout,
modules: map[string]require.ModuleLoader{},
vm: vm,
ctxParam: &ctxConfig{
FieldConfig: FieldConfig{
Runtime: vm,
@ -64,23 +65,37 @@ func newRunConfig(ctx context.Context, opts ...Option) *runConfig {
opt(config)
}
if config.prepareTimeout > config.timeout {
config.prepareTimeout = config.timeout
if config.scriptTimeout > config.functionTimeout {
config.scriptTimeout = config.functionTimeout
}
return config
}
func (c *runConfig) Start() *time.Timer {
func (c *runConfig) StartFunction() *time.Timer {
c.vm.ClearInterrupt()
return time.AfterFunc(c.timeout, func() {
return time.AfterFunc(c.functionTimeout, func() {
c.vm.Interrupt(ErrHalt)
})
}
func (c *runConfig) Prepare() *time.Timer {
func (c *runConfig) StartScript() *time.Timer {
c.vm.ClearInterrupt()
return time.AfterFunc(c.prepareTimeout, func() {
return time.AfterFunc(c.scriptTimeout, func() {
c.vm.Interrupt(ErrHalt)
})
}
func (c *runConfig) cutTimeouts(remainingSeconds *uint64) {
if remainingSeconds == nil {
return
}
remainingDur := time.Duration(*remainingSeconds) * time.Second
if c.functionTimeout > remainingDur {
c.functionTimeout = remainingDur
}
if c.scriptTimeout > remainingDur {
c.scriptTimeout = remainingDur
}
}

View File

@ -5,9 +5,11 @@ import (
"testing"
"github.com/dop251/goja"
"github.com/zitadel/zitadel/internal/logstore"
)
func TestSetFields(t *testing.T) {
SetLogstoreService(logstore.New(nil, nil, nil))
primitveFn := func(a string) { fmt.Println(a) }
complexFn := func(*FieldConfig) interface{} {
return primitveFn

View File

@ -11,9 +11,11 @@ import (
"github.com/dop251/goja"
"github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/logstore"
)
func Test_isHostBlocked(t *testing.T) {
SetLogstoreService(logstore.New(nil, nil, nil))
var denyList = []AddressChecker{
mustNewIPChecker(t, "192.168.5.0/24"),
mustNewIPChecker(t, "127.0.0.1"),

View File

@ -1,30 +1,83 @@
package actions
import (
"github.com/zitadel/logging"
"context"
"time"
"github.com/dop251/goja"
"github.com/dop251/goja_nodejs/console"
"github.com/sirupsen/logrus"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/logstore"
"github.com/zitadel/zitadel/internal/logstore/emitters/execution"
)
var ServerLog *logrus
var (
logstoreService *logstore.Service
_ console.Printer = (*logger)(nil)
)
type logrus struct{}
func (*logrus) Log(s string) {
logging.WithFields("message", s).Info("log from action")
}
func (*logrus) Warn(s string) {
logging.WithFields("message", s).Info("warn from action")
}
func (*logrus) Error(s string) {
logging.WithFields("message", s).Info("error from action")
func SetLogstoreService(svc *logstore.Service) {
logstoreService = svc
}
func WithLogger(logger console.Printer) Option {
type logger struct {
ctx context.Context
started time.Time
instanceID string
}
// newLogger returns a *logger instance that should only be used for a single action run.
// The first log call sets the started field for subsequent log calls
func newLogger(ctx context.Context, instanceID string) *logger {
return &logger{
ctx: ctx,
started: time.Time{},
instanceID: instanceID,
}
}
func (l *logger) Log(msg string) {
l.log(msg, logrus.InfoLevel, false)
}
func (l *logger) Warn(msg string) {
l.log(msg, logrus.WarnLevel, false)
}
func (l *logger) Error(msg string) {
l.log(msg, logrus.ErrorLevel, false)
}
func (l *logger) log(msg string, level logrus.Level, last bool) {
ts := time.Now()
if l.started.IsZero() {
l.started = ts
}
record := &execution.Record{
LogDate: ts,
InstanceID: l.instanceID,
Message: msg,
LogLevel: level,
}
if last {
record.Took = ts.Sub(l.started)
}
logstoreService.Handle(l.ctx, record)
}
func withLogger(ctx context.Context) Option {
instance := authz.GetInstance(ctx)
instanceID := instance.InstanceID()
return func(c *runConfig) {
c.logger = newLogger(ctx, instanceID)
c.instanceID = instanceID
c.modules["zitadel/log"] = func(runtime *goja.Runtime, module *goja.Object) {
console.RequireWithPrinter(logger)(runtime, module)
console.RequireWithPrinter(c.logger)(runtime, module)
}
}
}

View File

@ -16,6 +16,7 @@ import (
http_util "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/api/ui/login"
"github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/logstore"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/telemetry/metrics"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
@ -36,7 +37,18 @@ type health interface {
Instance(ctx context.Context, shouldTriggerBulk bool) (*query.Instance, error)
}
func New(port uint16, router *mux.Router, queries *query.Queries, verifier *internal_authz.TokenVerifier, authZ internal_authz.Config, externalSecure bool, tlsConfig *tls.Config, http2HostName, http1HostName string) *API {
func New(
port uint16,
router *mux.Router,
queries *query.Queries,
verifier *internal_authz.TokenVerifier,
authZ internal_authz.Config,
externalSecure bool,
tlsConfig *tls.Config,
http2HostName,
http1HostName string,
accessSvc *logstore.Service,
) *API {
api := &API{
port: port,
verifier: verifier,
@ -45,7 +57,8 @@ func New(port uint16, router *mux.Router, queries *query.Queries, verifier *inte
externalSecure: externalSecure,
http1HostName: http1HostName,
}
api.grpcServer = server.CreateServer(api.verifier, authZ, queries, http2HostName, tlsConfig)
api.grpcServer = server.CreateServer(api.verifier, authZ, queries, http2HostName, tlsConfig, accessSvc)
api.routeGRPC()
api.RegisterHandler("/debug", api.healthHandler())

View File

@ -82,7 +82,7 @@ func DefaultErrorHandler(w http.ResponseWriter, r *http.Request, err error, code
http.Error(w, err.Error(), code)
}
func NewHandler(commands *command.Commands, verifier *authz.TokenVerifier, authConfig authz.Config, idGenerator id.Generator, storage static.Storage, queries *query.Queries, instanceInterceptor, assetCacheInterceptor func(handler http.Handler) http.Handler) http.Handler {
func NewHandler(commands *command.Commands, verifier *authz.TokenVerifier, authConfig authz.Config, idGenerator id.Generator, storage static.Storage, queries *query.Queries, instanceInterceptor, assetCacheInterceptor, accessInterceptor func(handler http.Handler) http.Handler) http.Handler {
h := &Handler{
commands: commands,
errorHandler: DefaultErrorHandler,
@ -94,7 +94,7 @@ func NewHandler(commands *command.Commands, verifier *authz.TokenVerifier, authC
verifier.RegisterServer("Assets-API", "assets", AssetsService_AuthMethods)
router := mux.NewRouter()
router.Use(instanceInterceptor, assetCacheInterceptor)
router.Use(instanceInterceptor, assetCacheInterceptor, accessInterceptor)
RegisterRoutes(router, h)
router.PathPrefix("/{owner}").Methods("GET").HandlerFunc(DownloadHandleFunc(h, h.GetFile()))
return http_util.CopyHeadersToContext(http_mw.CORSInterceptor(router))

View File

@ -23,7 +23,8 @@ type Instance interface {
}
type InstanceVerifier interface {
InstanceByHost(context.Context, string) (Instance, error)
InstanceByHost(ctx context.Context, host string) (Instance, error)
InstanceByID(ctx context.Context) (Instance, error)
}
type instance struct {

View File

@ -55,6 +55,8 @@ func ExtractCaosError(err error) (c codes.Code, msg, id string, ok bool) {
return codes.Unavailable, caosErr.GetMessage(), caosErr.GetID(), true
case *caos_errs.UnimplementedError:
return codes.Unimplemented, caosErr.GetMessage(), caosErr.GetID(), true
case *caos_errs.ResourceExhaustedError:
return codes.ResourceExhausted, caosErr.GetMessage(), caosErr.GetID(), true
default:
return codes.Unknown, err.Error(), "", false
}

View File

@ -136,6 +136,14 @@ func Test_Extract(t *testing.T) {
"id",
true,
},
{
"exhausted",
args{caos_errs.ThrowResourceExhausted(nil, "id", "exhausted")},
codes.ResourceExhausted,
"exhausted",
"id",
true,
},
{
"unknown",
args{errors.New("unknown")},

View File

@ -0,0 +1,55 @@
package middleware
import (
"context"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/logstore"
"github.com/zitadel/zitadel/internal/logstore/emitters/access"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
)
func AccessStorageInterceptor(svc *logstore.Service) grpc.UnaryServerInterceptor {
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (_ interface{}, err error) {
if !svc.Enabled() {
return handler(ctx, req)
}
reqMd, _ := metadata.FromIncomingContext(ctx)
resp, handlerErr := handler(ctx, req)
interceptorCtx, span := tracing.NewServerInterceptorSpan(ctx)
defer func() { span.EndWithError(err) }()
var respStatus uint32
grpcStatus, ok := status.FromError(handlerErr)
if ok {
respStatus = uint32(grpcStatus.Code())
}
resMd, _ := metadata.FromOutgoingContext(ctx)
instance := authz.GetInstance(ctx)
record := &access.Record{
LogDate: time.Now(),
Protocol: access.GRPC,
RequestURL: info.FullMethod,
ResponseStatus: respStatus,
RequestHeaders: reqMd,
ResponseHeaders: resMd,
InstanceID: instance.InstanceID(),
ProjectID: instance.ProjectID(),
RequestedDomain: instance.RequestedDomain(),
RequestedHost: instance.RequestedHost(),
}
svc.Handle(interceptorCtx, record)
return resp, handlerErr
}
}

View File

@ -2,7 +2,7 @@ package middleware
import (
"context"
"errors"
errs "errors"
"fmt"
"strings"
@ -14,7 +14,7 @@ import (
"google.golang.org/grpc/status"
"github.com/zitadel/zitadel/internal/api/authz"
caos_errors "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/i18n"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
)
@ -23,27 +23,36 @@ const (
HTTP1Host = "x-zitadel-http1-host"
)
type InstanceVerifier interface {
GetInstance(ctx context.Context)
}
func InstanceInterceptor(verifier authz.InstanceVerifier, headerName string, ignoredServices ...string) grpc.UnaryServerInterceptor {
func InstanceInterceptor(verifier authz.InstanceVerifier, headerName string, explicitInstanceIdServices ...string) grpc.UnaryServerInterceptor {
translator, err := newZitadelTranslator(language.English)
logging.OnError(err).Panic("unable to get translator")
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
return setInstance(ctx, req, info, handler, verifier, headerName, translator, ignoredServices...)
return setInstance(ctx, req, info, handler, verifier, headerName, translator, explicitInstanceIdServices...)
}
}
func setInstance(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler, verifier authz.InstanceVerifier, headerName string, translator *i18n.Translator, ignoredServices ...string) (_ interface{}, err error) {
func setInstance(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler, verifier authz.InstanceVerifier, headerName string, translator *i18n.Translator, idFromRequestsServices ...string) (_ interface{}, err error) {
interceptorCtx, span := tracing.NewServerInterceptorSpan(ctx)
defer func() { span.EndWithError(err) }()
for _, service := range ignoredServices {
for _, service := range idFromRequestsServices {
if !strings.HasPrefix(service, "/") {
service = "/" + service
}
if strings.HasPrefix(info.FullMethod, service) {
return handler(ctx, req)
withInstanceIDProperty, ok := req.(interface{ GetInstanceId() string })
if !ok {
return handler(ctx, req)
}
ctx = authz.WithInstanceID(ctx, withInstanceIDProperty.GetInstanceId())
instance, err := verifier.InstanceByID(ctx)
if err != nil {
notFoundErr := new(errors.NotFoundError)
if errs.As(err, &notFoundErr) {
notFoundErr.Message = translator.LocalizeFromCtx(ctx, notFoundErr.GetMessage(), nil)
}
return nil, status.Error(codes.NotFound, err.Error())
}
return handler(authz.WithInstance(ctx, instance), req)
}
}
@ -53,9 +62,9 @@ func setInstance(ctx context.Context, req interface{}, info *grpc.UnaryServerInf
}
instance, err := verifier.InstanceByHost(interceptorCtx, host)
if err != nil {
caosErr := new(caos_errors.NotFoundError)
if errors.As(err, &caosErr) {
caosErr.Message = translator.LocalizeFromCtx(ctx, caosErr.GetMessage(), nil)
notFoundErr := new(errors.NotFoundError)
if errs.As(err, &notFoundErr) {
notFoundErr.Message = translator.LocalizeFromCtx(ctx, notFoundErr.GetMessage(), nil)
}
return nil, status.Error(codes.NotFound, err.Error())
}

View File

@ -153,13 +153,15 @@ type mockInstanceVerifier struct {
host string
}
func (m *mockInstanceVerifier) InstanceByHost(ctx context.Context, host string) (authz.Instance, error) {
func (m *mockInstanceVerifier) InstanceByHost(_ context.Context, host string) (authz.Instance, error) {
if host != m.host {
return nil, fmt.Errorf("invalid host")
}
return &mockInstance{}, nil
}
func (m *mockInstanceVerifier) InstanceByID(context.Context) (authz.Instance, error) { return nil, nil }
type mockInstance struct{}
func (m *mockInstance) InstanceID() string {

View File

@ -0,0 +1,46 @@
package middleware
import (
"context"
"strings"
"google.golang.org/grpc"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/logstore"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
)
func QuotaExhaustedInterceptor(svc *logstore.Service, ignoreService ...string) grpc.UnaryServerInterceptor {
prunedIgnoredServices := make([]string, len(ignoreService))
for idx, service := range ignoreService {
if !strings.HasPrefix(service, "/") {
service = "/" + service
}
prunedIgnoredServices[idx] = service
}
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (_ interface{}, err error) {
if !svc.Enabled() {
return handler(ctx, req)
}
interceptorCtx, span := tracing.NewServerInterceptorSpan(ctx)
defer func() { span.EndWithError(err) }()
for _, service := range prunedIgnoredServices {
if strings.HasPrefix(info.FullMethod, service) {
return handler(ctx, req)
}
}
instance := authz.GetInstance(ctx)
remaining := svc.Limit(interceptorCtx, instance.InstanceID())
if remaining != nil && *remaining == 0 {
return nil, errors.ThrowResourceExhausted(nil, "QUOTA-vjAy8", "Quota.Access.Exhausted")
}
span.End()
return handler(ctx, req)
}
}

View File

@ -2,7 +2,6 @@ package server
import (
"crypto/tls"
grpc_middleware "github.com/grpc-ecosystem/go-grpc-middleware"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
@ -10,6 +9,7 @@ import (
"github.com/zitadel/zitadel/internal/api/authz"
grpc_api "github.com/zitadel/zitadel/internal/api/grpc"
"github.com/zitadel/zitadel/internal/api/grpc/server/middleware"
"github.com/zitadel/zitadel/internal/logstore"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/telemetry/metrics"
system_pb "github.com/zitadel/zitadel/pkg/grpc/system"
@ -23,7 +23,14 @@ type Server interface {
AuthMethods() authz.MethodMapping
}
func CreateServer(verifier *authz.TokenVerifier, authConfig authz.Config, queries *query.Queries, hostHeaderName string, tlsConfig *tls.Config) *grpc.Server {
func CreateServer(
verifier *authz.TokenVerifier,
authConfig authz.Config,
queries *query.Queries,
hostHeaderName string,
tlsConfig *tls.Config,
accessSvc *logstore.Service,
) *grpc.Server {
metricTypes := []metrics.MetricType{metrics.MetricTypeTotalCount, metrics.MetricTypeRequestCount, metrics.MetricTypeStatusCode}
serverOptions := []grpc.ServerOption{
grpc.UnaryInterceptor(
@ -33,10 +40,12 @@ func CreateServer(verifier *authz.TokenVerifier, authConfig authz.Config, querie
middleware.NoCacheInterceptor(),
middleware.ErrorHandler(),
middleware.InstanceInterceptor(queries, hostHeaderName, system_pb.SystemService_MethodPrefix),
middleware.AccessStorageInterceptor(accessSvc),
middleware.AuthorizationInterceptor(verifier, authConfig),
middleware.TranslationHandler(),
middleware.ValidationHandler(),
middleware.ServiceHandler(),
middleware.QuotaExhaustedInterceptor(accessSvc, system_pb.SystemService_MethodPrefix),
),
),
}

View File

@ -31,7 +31,6 @@ func (s *Server) ListInstances(ctx context.Context, req *system_pb.ListInstances
}
func (s *Server) GetInstance(ctx context.Context, req *system_pb.GetInstanceRequest) (*system_pb.GetInstanceResponse, error) {
ctx = authz.WithInstanceID(ctx, req.InstanceId)
instance, err := s.query.Instance(ctx, true)
if err != nil {
return nil, err
@ -53,7 +52,6 @@ func (s *Server) AddInstance(ctx context.Context, req *system_pb.AddInstanceRequ
}
func (s *Server) UpdateInstance(ctx context.Context, req *system_pb.UpdateInstanceRequest) (*system_pb.UpdateInstanceResponse, error) {
ctx = authz.WithInstanceID(ctx, req.InstanceId)
details, err := s.command.UpdateInstance(ctx, req.InstanceName)
if err != nil {
return nil, err
@ -86,7 +84,6 @@ func (s *Server) CreateInstance(ctx context.Context, req *system_pb.CreateInstan
}
func (s *Server) RemoveInstance(ctx context.Context, req *system_pb.RemoveInstanceRequest) (*system_pb.RemoveInstanceResponse, error) {
ctx = authz.WithInstanceID(ctx, req.InstanceId)
details, err := s.command.RemoveInstance(ctx, req.InstanceId)
if err != nil {
return nil, err
@ -97,7 +94,6 @@ func (s *Server) RemoveInstance(ctx context.Context, req *system_pb.RemoveInstan
}
func (s *Server) ListIAMMembers(ctx context.Context, req *system_pb.ListIAMMembersRequest) (*system_pb.ListIAMMembersResponse, error) {
ctx = authz.WithInstanceID(ctx, req.InstanceId)
queries, err := ListIAMMembersRequestToQuery(req)
if err != nil {
return nil, err
@ -139,7 +135,6 @@ func (s *Server) ExistsDomain(ctx context.Context, req *system_pb.ExistsDomainRe
}
func (s *Server) ListDomains(ctx context.Context, req *system_pb.ListDomainsRequest) (*system_pb.ListDomainsResponse, error) {
ctx = authz.WithInstanceID(ctx, req.InstanceId)
queries, err := ListInstanceDomainsRequestToModel(req)
if err != nil {
return nil, err
@ -156,8 +151,6 @@ func (s *Server) ListDomains(ctx context.Context, req *system_pb.ListDomainsRequ
}
func (s *Server) AddDomain(ctx context.Context, req *system_pb.AddDomainRequest) (*system_pb.AddDomainResponse, error) {
//TODO: should be solved in interceptor
ctx = authz.WithInstanceID(ctx, req.InstanceId)
instance, err := s.query.Instance(ctx, true)
if err != nil {
return nil, err
@ -174,7 +167,6 @@ func (s *Server) AddDomain(ctx context.Context, req *system_pb.AddDomainRequest)
}
func (s *Server) RemoveDomain(ctx context.Context, req *system_pb.RemoveDomainRequest) (*system_pb.RemoveDomainResponse, error) {
ctx = authz.WithInstanceID(ctx, req.InstanceId)
details, err := s.command.RemoveInstanceDomain(ctx, req.Domain)
if err != nil {
return nil, err
@ -185,7 +177,6 @@ func (s *Server) RemoveDomain(ctx context.Context, req *system_pb.RemoveDomainRe
}
func (s *Server) SetPrimaryDomain(ctx context.Context, req *system_pb.SetPrimaryDomainRequest) (*system_pb.SetPrimaryDomainResponse, error) {
ctx = authz.WithInstanceID(ctx, req.InstanceId)
details, err := s.command.SetPrimaryInstanceDomain(ctx, req.Domain)
if err != nil {
return nil, err

View File

@ -0,0 +1,32 @@
package system
import (
"context"
"github.com/zitadel/zitadel/internal/api/grpc/object"
"github.com/zitadel/zitadel/pkg/grpc/system"
system_pb "github.com/zitadel/zitadel/pkg/grpc/system"
)
func (s *Server) AddQuota(ctx context.Context, req *system.AddQuotaRequest) (*system.AddQuotaResponse, error) {
details, err := s.command.AddQuota(
ctx,
instanceQuotaPbToCommand(req),
)
if err != nil {
return nil, err
}
return &system_pb.AddQuotaResponse{
Details: object.AddToDetailsPb(details.Sequence, details.EventDate, details.ResourceOwner),
}, nil
}
func (s *Server) RemoveQuota(ctx context.Context, req *system.RemoveQuotaRequest) (*system.RemoveQuotaResponse, error) {
details, err := s.command.RemoveQuota(ctx, instanceQuotaUnitPbToCommand(req.Unit))
if err != nil {
return nil, err
}
return &system_pb.RemoveQuotaResponse{
Details: object.ChangeToDetailsPb(details.Sequence, details.EventDate, details.ResourceOwner),
}, nil
}

View File

@ -0,0 +1,43 @@
package system
import (
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/pkg/grpc/quota"
"github.com/zitadel/zitadel/pkg/grpc/system"
)
func instanceQuotaPbToCommand(req *system.AddQuotaRequest) *command.AddQuota {
return &command.AddQuota{
Unit: instanceQuotaUnitPbToCommand(req.Unit),
From: req.From.AsTime(),
ResetInterval: req.ResetInterval.AsDuration(),
Amount: req.Amount,
Limit: req.Limit,
Notifications: instanceQuotaNotificationsPbToCommand(req.Notifications),
}
}
func instanceQuotaUnitPbToCommand(unit quota.Unit) command.QuotaUnit {
switch unit {
case quota.Unit_UNIT_REQUESTS_ALL_AUTHENTICATED:
return command.QuotaRequestsAllAuthenticated
case quota.Unit_UNIT_ACTIONS_ALL_RUN_SECONDS:
return command.QuotaActionsAllRunsSeconds
case quota.Unit_UNIT_UNIMPLEMENTED:
fallthrough
default:
return command.QuotaUnit(unit.String())
}
}
func instanceQuotaNotificationsPbToCommand(req []*quota.Notification) command.QuotaNotifications {
notifications := make([]*command.QuotaNotification, len(req))
for idx, item := range req {
notifications[idx] = &command.QuotaNotification{
Percent: uint16(item.Percent),
Repeat: item.Repeat,
CallURL: item.CallUrl,
}
}
return notifications
}

View File

@ -72,7 +72,9 @@ func WithPath(path string) CookieHandlerOpt {
func WithMaxAge(maxAge int) CookieHandlerOpt {
return func(c *CookieHandler) {
c.maxAge = maxAge
c.securecookie.MaxAge(maxAge)
if c.securecookie != nil {
c.securecookie.MaxAge(maxAge)
}
}
}

View File

@ -0,0 +1,102 @@
package middleware
import (
"math"
"net/http"
"net/url"
"strconv"
"time"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/api/authz"
http_utils "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/logstore"
"github.com/zitadel/zitadel/internal/logstore/emitters/access"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
)
type AccessInterceptor struct {
svc *logstore.Service
cookieHandler *http_utils.CookieHandler
limitConfig *AccessConfig
}
type AccessConfig struct {
ExhaustedCookieKey string
ExhaustedCookieMaxAge time.Duration
}
func NewAccessInterceptor(svc *logstore.Service, cookieConfig *AccessConfig) *AccessInterceptor {
return &AccessInterceptor{
svc: svc,
cookieHandler: http_utils.NewCookieHandler(
http_utils.WithUnsecure(),
http_utils.WithMaxAge(int(math.Floor(cookieConfig.ExhaustedCookieMaxAge.Seconds()))),
),
limitConfig: cookieConfig,
}
}
func (a *AccessInterceptor) Handle(next http.Handler) http.Handler {
if !a.svc.Enabled() {
return next
}
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
ctx := request.Context()
var err error
tracingCtx, span := tracing.NewServerInterceptorSpan(ctx)
defer func() { span.EndWithError(err) }()
wrappedWriter := &statusRecorder{ResponseWriter: writer, status: 0}
instance := authz.GetInstance(ctx)
remaining := a.svc.Limit(tracingCtx, instance.InstanceID())
limit := remaining != nil && *remaining == 0
a.cookieHandler.SetCookie(wrappedWriter, a.limitConfig.ExhaustedCookieKey, request.Host, strconv.FormatBool(limit))
if limit {
wrappedWriter.WriteHeader(http.StatusTooManyRequests)
wrappedWriter.ignoreWrites = true
}
next.ServeHTTP(wrappedWriter, request)
requestURL := request.RequestURI
unescapedURL, err := url.QueryUnescape(requestURL)
if err != nil {
logging.WithError(err).WithField("url", requestURL).Warning("failed to unescape request url")
// err = nil is effective because of deferred tracing span end
err = nil
}
a.svc.Handle(tracingCtx, &access.Record{
LogDate: time.Now(),
Protocol: access.HTTP,
RequestURL: unescapedURL,
ResponseStatus: uint32(wrappedWriter.status),
RequestHeaders: request.Header,
ResponseHeaders: writer.Header(),
InstanceID: instance.InstanceID(),
ProjectID: instance.ProjectID(),
RequestedDomain: instance.RequestedDomain(),
RequestedHost: instance.RequestedHost(),
})
})
}
type statusRecorder struct {
http.ResponseWriter
status int
ignoreWrites bool
}
func (r *statusRecorder) WriteHeader(status int) {
if r.ignoreWrites {
return
}
r.status = status
r.ResponseWriter.WriteHeader(status)
}

View File

@ -244,6 +244,10 @@ func (m *mockInstanceVerifier) InstanceByHost(_ context.Context, host string) (a
return &mockInstance{}, nil
}
func (m *mockInstanceVerifier) InstanceByID(context.Context) (authz.Instance, error) {
return nil, nil
}
type mockInstance struct{}
func (m *mockInstance) InstanceID() string {

View File

@ -427,7 +427,7 @@ func (o *OPStorage) userinfoFlows(ctx context.Context, resourceOwner string, use
apiFields,
action.Script,
action.Name,
append(actions.ActionToOptions(action), actions.WithHTTP(actionCtx), actions.WithLogger(actions.ServerLog))...,
append(actions.ActionToOptions(action), actions.WithHTTP(actionCtx))...,
)
cancel()
if err != nil {
@ -583,7 +583,7 @@ func (o *OPStorage) privateClaimsFlows(ctx context.Context, userID string, claim
apiFields,
action.Script,
action.Name,
append(actions.ActionToOptions(action), actions.WithHTTP(actionCtx), actions.WithLogger(actions.ServerLog))...,
append(actions.ActionToOptions(action), actions.WithHTTP(actionCtx))...,
)
cancel()
if err != nil {

View File

@ -73,13 +73,13 @@ type OPStorage struct {
assetAPIPrefix func(ctx context.Context) string
}
func NewProvider(ctx context.Context, config Config, defaultLogoutRedirectURI string, externalSecure bool, command *command.Commands, query *query.Queries, repo repository.Repository, encryptionAlg crypto.EncryptionAlgorithm, cryptoKey []byte, es *eventstore.Eventstore, projections *sql.DB, userAgentCookie, instanceHandler func(http.Handler) http.Handler) (op.OpenIDProvider, error) {
func NewProvider(ctx context.Context, config Config, defaultLogoutRedirectURI string, externalSecure bool, command *command.Commands, query *query.Queries, repo repository.Repository, encryptionAlg crypto.EncryptionAlgorithm, cryptoKey []byte, es *eventstore.Eventstore, projections *sql.DB, userAgentCookie, instanceHandler, accessHandler func(http.Handler) http.Handler) (op.OpenIDProvider, error) {
opConfig, err := createOPConfig(config, defaultLogoutRedirectURI, cryptoKey)
if err != nil {
return nil, caos_errs.ThrowInternal(err, "OIDC-EGrqd", "cannot create op config: %w")
}
storage := newStorage(config, command, query, repo, encryptionAlg, es, projections, externalSecure)
options, err := createOptions(config, externalSecure, userAgentCookie, instanceHandler)
options, err := createOptions(config, externalSecure, userAgentCookie, instanceHandler, accessHandler)
if err != nil {
return nil, caos_errs.ThrowInternal(err, "OIDC-D3gq1", "cannot create options: %w")
}
@ -117,7 +117,7 @@ func createOPConfig(config Config, defaultLogoutRedirectURI string, cryptoKey []
return opConfig, nil
}
func createOptions(config Config, externalSecure bool, userAgentCookie, instanceHandler func(http.Handler) http.Handler) ([]op.Option, error) {
func createOptions(config Config, externalSecure bool, userAgentCookie, instanceHandler, accessHandler func(http.Handler) http.Handler) ([]op.Option, error) {
metricTypes := []metrics.MetricType{metrics.MetricTypeRequestCount, metrics.MetricTypeStatusCode, metrics.MetricTypeTotalCount}
options := []op.Option{
op.WithHttpInterceptors(
@ -127,6 +127,7 @@ func createOptions(config Config, externalSecure bool, userAgentCookie, instance
instanceHandler,
userAgentCookie,
http_utils.CopyHeadersToContext,
accessHandler,
),
}
if !externalSecure {

View File

@ -40,7 +40,8 @@ func NewProvider(
es *eventstore.Eventstore,
projections *sql.DB,
instanceHandler,
userAgentCookie func(http.Handler) http.Handler,
userAgentCookie,
accessHandler func(http.Handler) http.Handler,
) (*provider.Provider, error) {
metricTypes := []metrics.MetricType{metrics.MetricTypeRequestCount, metrics.MetricTypeStatusCode, metrics.MetricTypeTotalCount}
@ -64,6 +65,7 @@ func NewProvider(
middleware.NoCacheInterceptor().Handler,
instanceHandler,
userAgentCookie,
accessHandler,
http_utils.CopyHeadersToContext,
),
}

View File

@ -88,7 +88,7 @@ func (f *file) Stat() (_ fs.FileInfo, err error) {
return f, nil
}
func Start(config Config, externalSecure bool, issuer op.IssuerFromRequest, instanceHandler func(http.Handler) http.Handler, customerPortal string) (http.Handler, error) {
func Start(config Config, externalSecure bool, issuer op.IssuerFromRequest, instanceHandler, accessInterceptor func(http.Handler) http.Handler, customerPortal string) (http.Handler, error) {
fSys, err := fs.Sub(static, "static")
if err != nil {
return nil, err
@ -103,7 +103,7 @@ func Start(config Config, externalSecure bool, issuer op.IssuerFromRequest, inst
handler := mux.NewRouter()
handler.Use(instanceHandler, security)
handler.Use(instanceHandler, security, accessInterceptor)
handler.Handle(envRequestPath, middleware.TelemetryHandler()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
url := http_util.BuildOrigin(r.Host, externalSecure)
environmentJSON, err := createEnvironmentJSON(url, issuer(r), authz.GetInstance(r.Context()).ConsoleClientID(), customerPortal)

View File

@ -106,7 +106,7 @@ func (l *Login) runPostExternalAuthenticationActions(
apiFields,
a.Script,
a.Name,
append(actions.ActionToOptions(a), actions.WithHTTP(actionCtx), actions.WithLogger(actions.ServerLog))...,
append(actions.ActionToOptions(a), actions.WithHTTP(actionCtx))...,
)
cancel()
if err != nil {
@ -175,7 +175,7 @@ func (l *Login) runPostInternalAuthenticationActions(
apiFields,
a.Script,
a.Name,
append(actions.ActionToOptions(a), actions.WithHTTP(actionCtx), actions.WithLogger(actions.ServerLog))...,
append(actions.ActionToOptions(a), actions.WithHTTP(actionCtx))...,
)
cancel()
if err != nil {
@ -274,7 +274,7 @@ func (l *Login) runPreCreationActions(
apiFields,
a.Script,
a.Name,
append(actions.ActionToOptions(a), actions.WithHTTP(actionCtx), actions.WithLogger(actions.ServerLog))...,
append(actions.ActionToOptions(a), actions.WithHTTP(actionCtx))...,
)
cancel()
if err != nil {
@ -332,7 +332,7 @@ func (l *Login) runPostCreationActions(
apiFields,
a.Script,
a.Name,
append(actions.ActionToOptions(a), actions.WithHTTP(actionCtx), actions.WithLogger(actions.ServerLog))...,
append(actions.ActionToOptions(a), actions.WithHTTP(actionCtx))...,
)
cancel()
if err != nil {

View File

@ -69,6 +69,7 @@ func CreateLogin(config Config,
oidcInstanceHandler,
samlInstanceHandler mux.MiddlewareFunc,
assetCache mux.MiddlewareFunc,
accessHandler mux.MiddlewareFunc,
userCodeAlg crypto.EncryptionAlgorithm,
idpConfigAlg crypto.EncryptionAlgorithm,
csrfCookieKey []byte,
@ -94,7 +95,7 @@ func CreateLogin(config Config,
cacheInterceptor := createCacheInterceptor(config.Cache.MaxAge, config.Cache.SharedMaxAge, assetCache)
security := middleware.SecurityHeaders(csp(), login.cspErrorHandler)
login.router = CreateRouter(login, statikFS, middleware.TelemetryHandler(IgnoreInstanceEndpoints...), oidcInstanceHandler, samlInstanceHandler, csrfInterceptor, cacheInterceptor, security, userAgentCookie, issuerInterceptor)
login.router = CreateRouter(login, statikFS, middleware.TelemetryHandler(IgnoreInstanceEndpoints...), oidcInstanceHandler, samlInstanceHandler, csrfInterceptor, cacheInterceptor, security, userAgentCookie, issuerInterceptor, accessHandler)
login.renderer = CreateRenderer(HandlerPrefix, statikFS, staticStorage, config.LanguageCookieName)
login.parser = form.NewParser()
return login, nil

View File

@ -19,6 +19,7 @@ import (
"github.com/zitadel/zitadel/internal/repository/keypair"
"github.com/zitadel/zitadel/internal/repository/org"
proj_repo "github.com/zitadel/zitadel/internal/repository/project"
"github.com/zitadel/zitadel/internal/repository/quota"
usr_repo "github.com/zitadel/zitadel/internal/repository/user"
usr_grant_repo "github.com/zitadel/zitadel/internal/repository/usergrant"
"github.com/zitadel/zitadel/internal/static"
@ -110,6 +111,7 @@ func StartCommands(es *eventstore.Eventstore,
proj_repo.RegisterEventMappers(repo.eventstore)
keypair.RegisterEventMappers(repo.eventstore)
action.RegisterEventMappers(repo.eventstore)
quota.RegisterEventMappers(repo.eventstore)
repo.userPasswordAlg = crypto.NewBCrypt(defaults.SecretGenerators.PasswordSaltCost)
repo.machineKeySize = int(defaults.SecretGenerators.MachineKeySize)
@ -129,6 +131,7 @@ func StartCommands(es *eventstore.Eventstore,
func AppendAndReduce(object interface {
AppendEvents(...eventstore.Event)
// TODO: Why is it allowed to return an error here?
Reduce() error
}, events ...eventstore.Event) error {
object.AppendEvents(events...)

View File

@ -18,6 +18,7 @@ import (
"github.com/zitadel/zitadel/internal/repository/instance"
"github.com/zitadel/zitadel/internal/repository/org"
"github.com/zitadel/zitadel/internal/repository/project"
"github.com/zitadel/zitadel/internal/repository/quota"
"github.com/zitadel/zitadel/internal/repository/user"
)
@ -116,6 +117,9 @@ type InstanceSetup struct {
RefreshTokenIdleExpiration time.Duration
RefreshTokenExpiration time.Duration
}
Quotas *struct {
Items []*AddQuota
}
}
type ZitadelConfig struct {
@ -261,6 +265,19 @@ func (c *Commands) SetUpInstance(ctx context.Context, setup *InstanceSetup) (str
prepareAddDefaultEmailTemplate(instanceAgg, setup.EmailTemplate),
}
if setup.Quotas != nil {
for _, q := range setup.Quotas.Items {
quotaId, err := c.idGenerator.Next()
if err != nil {
return "", "", nil, nil, err
}
quotaAggregate := quota.NewAggregate(quotaId, instanceID, instanceID)
validations = append(validations, c.AddQuotaCommand(quotaAggregate, q))
}
}
for _, msg := range setup.MessageTexts {
validations = append(validations, prepareSetInstanceCustomMessageTexts(instanceAgg, msg))
}

207
internal/command/quota.go Normal file
View File

@ -0,0 +1,207 @@
package command
import (
"context"
"net/url"
"time"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/command/preparation"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/id"
"github.com/zitadel/zitadel/internal/repository/quota"
)
type QuotaUnit string
const (
QuotaRequestsAllAuthenticated QuotaUnit = "requests.all.authenticated"
QuotaActionsAllRunsSeconds QuotaUnit = "actions.all.runs.seconds"
)
func (q *QuotaUnit) Enum() quota.Unit {
switch *q {
case QuotaRequestsAllAuthenticated:
return quota.RequestsAllAuthenticated
case QuotaActionsAllRunsSeconds:
return quota.ActionsAllRunsSeconds
default:
return quota.Unimplemented
}
}
func (c *Commands) AddQuota(
ctx context.Context,
q *AddQuota,
) (*domain.ObjectDetails, error) {
instanceId := authz.GetInstance(ctx).InstanceID()
wm, err := c.getQuotaWriteModel(ctx, instanceId, instanceId, q.Unit.Enum())
if err != nil {
return nil, err
}
if wm.active {
return nil, errors.ThrowAlreadyExists(nil, "COMMAND-WDfFf", "Errors.Quota.AlreadyExists")
}
aggregateId, err := c.idGenerator.Next()
if err != nil {
return nil, err
}
aggregate := quota.NewAggregate(aggregateId, instanceId, instanceId)
cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, c.AddQuotaCommand(aggregate, q))
if err != nil {
return nil, err
}
events, err := c.eventstore.Push(ctx, cmds...)
if err != nil {
return nil, err
}
err = AppendAndReduce(wm, events...)
if err != nil {
return nil, err
}
return writeModelToObjectDetails(&wm.WriteModel), nil
}
func (c *Commands) RemoveQuota(ctx context.Context, unit QuotaUnit) (*domain.ObjectDetails, error) {
instanceId := authz.GetInstance(ctx).InstanceID()
wm, err := c.getQuotaWriteModel(ctx, instanceId, instanceId, unit.Enum())
if err != nil {
return nil, err
}
if !wm.active {
return nil, errors.ThrowNotFound(nil, "COMMAND-WDfFf", "Errors.Quota.NotFound")
}
aggregate := quota.NewAggregate(wm.AggregateID, instanceId, instanceId)
events := []eventstore.Command{
quota.NewRemovedEvent(ctx, &aggregate.Aggregate, unit.Enum()),
}
pushedEvents, err := c.eventstore.Push(ctx, events...)
if err != nil {
return nil, err
}
err = AppendAndReduce(wm, pushedEvents...)
if err != nil {
return nil, err
}
return writeModelToObjectDetails(&wm.WriteModel), nil
}
func (c *Commands) getQuotaWriteModel(ctx context.Context, instanceId, resourceOwner string, unit quota.Unit) (*quotaWriteModel, error) {
wm := newQuotaWriteModel(instanceId, resourceOwner, unit)
return wm, c.eventstore.FilterToQueryReducer(ctx, wm)
}
type QuotaNotification struct {
Percent uint16
Repeat bool
CallURL string
}
type QuotaNotifications []*QuotaNotification
func (q *QuotaNotifications) toAddedEventNotifications(idGenerator id.Generator) ([]*quota.AddedEventNotification, error) {
if q == nil {
return nil, nil
}
notifications := make([]*quota.AddedEventNotification, len(*q))
for idx, notification := range *q {
id, err := idGenerator.Next()
if err != nil {
return nil, err
}
notifications[idx] = &quota.AddedEventNotification{
ID: id,
Percent: notification.Percent,
Repeat: notification.Repeat,
CallURL: notification.CallURL,
}
}
return notifications, nil
}
type AddQuota struct {
Unit QuotaUnit
From time.Time
ResetInterval time.Duration
Amount uint64
Limit bool
Notifications QuotaNotifications
}
func (q *AddQuota) validate() error {
for _, notification := range q.Notifications {
u, err := url.Parse(notification.CallURL)
if err != nil {
return errors.ThrowInvalidArgument(err, "QUOTA-bZ0Fj", "Errors.Quota.Invalid.CallURL")
}
if !u.IsAbs() || u.Host == "" {
return errors.ThrowInvalidArgument(nil, "QUOTA-HAYmN", "Errors.Quota.Invalid.CallURL")
}
if notification.Percent < 1 {
return errors.ThrowInvalidArgument(nil, "QUOTA-pBfjq", "Errors.Quota.Invalid.Percent")
}
}
if q.Unit.Enum() == quota.Unimplemented {
return errors.ThrowInvalidArgument(nil, "QUOTA-OTeSh", "Errors.Quota.Invalid.Unimplemented")
}
if q.Amount < 1 {
return errors.ThrowInvalidArgument(nil, "QUOTA-hOKSJ", "Errors.Quota.Invalid.Amount")
}
if q.ResetInterval < time.Minute {
return errors.ThrowInvalidArgument(nil, "QUOTA-R5otd", "Errors.Quota.Invalid.ResetInterval")
}
if !q.Limit && len(q.Notifications) == 0 {
return errors.ThrowInvalidArgument(nil, "QUOTA-4Nv68", "Errors.Quota.Invalid.Noop")
}
return nil
}
func (c *Commands) AddQuotaCommand(a *quota.Aggregate, q *AddQuota) preparation.Validation {
return func() (preparation.CreateCommands, error) {
if err := q.validate(); err != nil {
return nil, err
}
return func(ctx context.Context, filter preparation.FilterToQueryReducer) (cmd []eventstore.Command, err error) {
notifications, err := q.Notifications.toAddedEventNotifications(c.idGenerator)
if err != nil {
return nil, err
}
return []eventstore.Command{quota.NewAddedEvent(
ctx,
&a.Aggregate,
q.Unit.Enum(),
q.From,
q.ResetInterval,
q.Amount,
q.Limit,
notifications,
)}, err
},
nil
}
}

View File

@ -0,0 +1,54 @@
package command
import (
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/quota"
)
type quotaWriteModel struct {
eventstore.WriteModel
unit quota.Unit
active bool
config *quota.AddedEvent
}
// newQuotaWriteModel aggregateId is filled by reducing unit matching events
func newQuotaWriteModel(instanceId, resourceOwner string, unit quota.Unit) *quotaWriteModel {
return &quotaWriteModel{
WriteModel: eventstore.WriteModel{
InstanceID: instanceId,
ResourceOwner: resourceOwner,
},
unit: unit,
}
}
func (wm *quotaWriteModel) Query() *eventstore.SearchQueryBuilder {
query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
ResourceOwner(wm.ResourceOwner).
AddQuery().
InstanceID(wm.InstanceID).
AggregateTypes(quota.AggregateType).
EventTypes(
quota.AddedEventType,
quota.RemovedEventType,
).EventData(map[string]interface{}{"unit": wm.unit})
return query.Builder()
}
func (wm *quotaWriteModel) Reduce() error {
for _, event := range wm.Events {
switch e := event.(type) {
case *quota.AddedEvent:
wm.AggregateID = e.Aggregate().ID
wm.active = true
wm.config = e
case *quota.RemovedEvent:
wm.AggregateID = e.Aggregate().ID
wm.active = false
wm.config = nil
}
}
return wm.WriteModel.Reduce()
}

View File

@ -0,0 +1,59 @@
package command
import (
"context"
"math"
"time"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/quota"
)
func (c *Commands) GetDueQuotaNotifications(ctx context.Context, config *quota.AddedEvent, periodStart time.Time, usedAbs uint64) ([]*quota.NotifiedEvent, error) {
if len(config.Notifications) == 0 {
return nil, nil
}
aggregate := config.Aggregate()
wm, err := c.getQuotaNotificationsWriteModel(ctx, aggregate, periodStart)
if err != nil {
return nil, err
}
usedRel := uint16(math.Floor(float64(usedAbs*100) / float64(config.Amount)))
var dueNotifications []*quota.NotifiedEvent
for _, notification := range config.Notifications {
if notification.Percent > usedRel {
continue
}
threshold := notification.Percent
if notification.Repeat {
threshold = uint16(math.Min(1, math.Floor(float64(usedRel)/float64(notification.Percent)))) * notification.Percent
}
if wm.latestNotifiedThresholds[notification.ID] < threshold {
dueNotifications = append(
dueNotifications,
quota.NewNotifiedEvent(
ctx,
&aggregate,
config.Unit,
notification.ID,
notification.CallURL,
periodStart,
threshold,
usedAbs,
),
)
}
}
return dueNotifications, nil
}
func (c *Commands) getQuotaNotificationsWriteModel(ctx context.Context, aggregate eventstore.Aggregate, periodStart time.Time) (*quotaNotificationsWriteModel, error) {
wm := newQuotaNotificationsWriteModel(aggregate.ID, aggregate.InstanceID, aggregate.ResourceOwner, periodStart)
return wm, c.eventstore.FilterToQueryReducer(ctx, wm)
}

View File

@ -0,0 +1,45 @@
package command
import (
"time"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/quota"
)
type quotaNotificationsWriteModel struct {
eventstore.WriteModel
periodStart time.Time
latestNotifiedThresholds map[string]uint16
}
func newQuotaNotificationsWriteModel(aggregateId, instanceId, resourceOwner string, periodStart time.Time) *quotaNotificationsWriteModel {
return &quotaNotificationsWriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: aggregateId,
InstanceID: instanceId,
ResourceOwner: resourceOwner,
},
periodStart: periodStart,
latestNotifiedThresholds: make(map[string]uint16),
}
}
func (wm *quotaNotificationsWriteModel) Query() *eventstore.SearchQueryBuilder {
return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
ResourceOwner(wm.ResourceOwner).
AddQuery().
InstanceID(wm.InstanceID).
AggregateTypes(quota.AggregateType).
AggregateIDs(wm.AggregateID).
CreationDateAfter(wm.periodStart).
EventTypes(quota.NotifiedEventType).Builder()
}
func (wm *quotaNotificationsWriteModel) Reduce() error {
for _, event := range wm.Events {
e := event.(*quota.NotifiedEvent)
wm.latestNotifiedThresholds[e.ID] = e.Threshold
}
return wm.WriteModel.Reduce()
}

View File

@ -0,0 +1,25 @@
package command
import (
"context"
"time"
"github.com/zitadel/zitadel/internal/repository/quota"
)
func (c *Commands) GetCurrentQuotaPeriod(ctx context.Context, instanceID string, unit quota.Unit) (*quota.AddedEvent, time.Time, error) {
wm, err := c.getQuotaWriteModel(ctx, instanceID, instanceID, unit)
if err != nil || !wm.active {
return nil, time.Time{}, err
}
return wm.config, pushPeriodStart(wm.config.From, wm.config.ResetInterval, time.Now()), nil
}
func pushPeriodStart(from time.Time, interval time.Duration, now time.Time) time.Time {
next := from.Add(interval)
if next.After(now) {
return from
}
return pushPeriodStart(next, interval, now)
}

View File

@ -0,0 +1,58 @@
package command
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/zitadel/zitadel/internal/repository/quota"
)
// ReportUsage calls notification hooks and emits the notified events
func (c *Commands) ReportUsage(ctx context.Context, dueNotifications []*quota.NotifiedEvent) error {
for _, notification := range dueNotifications {
if err := notify(ctx, notification); err != nil {
if err != nil {
return err
}
}
if _, err := c.eventstore.Push(ctx, notification); err != nil {
return err
}
}
return nil
}
func notify(ctx context.Context, notification *quota.NotifiedEvent) error {
payload, err := json.Marshal(notification)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, notification.CallURL, bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
if err = resp.Body.Close(); err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("calling url %s returned %s", notification.CallURL, resp.Status)
}
return nil
}

View File

@ -0,0 +1,48 @@
package errors
import (
"fmt"
)
var (
_ ResourceExhausted = (*ResourceExhaustedError)(nil)
_ Error = (*ResourceExhaustedError)(nil)
)
type ResourceExhausted interface {
error
IsResourceExhausted()
}
type ResourceExhaustedError struct {
*CaosError
}
func ThrowResourceExhausted(parent error, id, message string) error {
return &ResourceExhaustedError{CreateCaosError(parent, id, message)}
}
func ThrowResourceExhaustedf(parent error, id, format string, a ...interface{}) error {
return ThrowResourceExhausted(parent, id, fmt.Sprintf(format, a...))
}
func (err *ResourceExhaustedError) IsResourceExhausted() {}
func IsResourceExhausted(err error) bool {
//nolint:errorlint
_, ok := err.(ResourceExhausted)
return ok
}
func (err *ResourceExhaustedError) Is(target error) bool {
//nolint:errorlint
t, ok := target.(*ResourceExhaustedError)
if !ok {
return false
}
return err.CaosError.Is(t.CaosError)
}
func (err *ResourceExhaustedError) Unwrap() error {
return err.CaosError
}

View File

@ -0,0 +1,34 @@
package errors_test
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
caos_errs "github.com/zitadel/zitadel/internal/errors"
)
func TestResourceExhaustedError(t *testing.T) {
var err interface{} = new(caos_errs.ResourceExhaustedError)
_, ok := err.(caos_errs.ResourceExhausted)
assert.True(t, ok)
}
func TestThrowResourceExhaustedf(t *testing.T) {
err := caos_errs.ThrowResourceExhaustedf(nil, "id", "msg")
// TODO: refactor errors package
//nolint:errorlint
_, ok := err.(*caos_errs.ResourceExhaustedError)
assert.True(t, ok)
}
func TestIsResourceExhausted(t *testing.T) {
err := caos_errs.ThrowResourceExhausted(nil, "id", "msg")
ok := caos_errs.IsResourceExhausted(err)
assert.True(t, ok)
err = errors.New("I am found!")
ok = caos_errs.IsResourceExhausted(err)
assert.False(t, ok)
}

View File

@ -119,6 +119,7 @@ const (
ColumnTypeJSONB
ColumnTypeBytes
ColumnTypeTimestamp
ColumnTypeInterval
ColumnTypeEnum
ColumnTypeEnumArray
ColumnTypeInt64
@ -389,6 +390,8 @@ func columnType(columnType ColumnType) string {
return "TEXT[]"
case ColumnTypeTimestamp:
return "TIMESTAMPTZ"
case ColumnTypeInterval:
return "INTERVAL"
case ColumnTypeEnum:
return "SMALLINT"
case ColumnTypeEnumArray:

View File

@ -0,0 +1,11 @@
package logstore
type Configs struct {
Access *Config
Execution *Config
}
type Config struct {
Database *EmitterConfig
Stdout *EmitterConfig
}

View File

@ -0,0 +1,92 @@
package logstore
import (
"context"
"sync"
"time"
"github.com/benbjohnson/clock"
"github.com/zitadel/logging"
)
type bulkSink interface {
sendBulk(ctx context.Context, bulk []LogRecord) error
}
var _ bulkSink = bulkSinkFunc(nil)
type bulkSinkFunc func(ctx context.Context, items []LogRecord) error
func (s bulkSinkFunc) sendBulk(ctx context.Context, items []LogRecord) error {
return s(ctx, items)
}
type debouncer struct {
// Storing context.Context in a struct is generally bad practice
// https://go.dev/blog/context-and-structs
// However, debouncer starts a go routine that triggers side effects itself.
// So, there is no incoming context.Context available when these events trigger.
// The only context we can use for the side effects is the app context.
// Because this can be cancelled by os signals, it's the better solution than creating new background contexts.
binarySignaledCtx context.Context
clock clock.Clock
ticker *clock.Ticker
mux sync.Mutex
cfg DebouncerConfig
storage bulkSink
cache []LogRecord
cacheLen uint
}
type DebouncerConfig struct {
MinFrequency time.Duration
MaxBulkSize uint
}
func newDebouncer(binarySignaledCtx context.Context, cfg DebouncerConfig, clock clock.Clock, ship bulkSink) *debouncer {
a := &debouncer{
binarySignaledCtx: binarySignaledCtx,
clock: clock,
cfg: cfg,
storage: ship,
}
if cfg.MinFrequency > 0 {
a.ticker = clock.Ticker(cfg.MinFrequency)
go a.shipOnTicks()
}
return a
}
func (d *debouncer) add(item LogRecord) {
d.mux.Lock()
defer d.mux.Unlock()
d.cache = append(d.cache, item)
d.cacheLen++
if d.cfg.MaxBulkSize > 0 && d.cacheLen >= d.cfg.MaxBulkSize {
// Add should not block and release the lock
go d.ship()
}
}
func (d *debouncer) ship() {
if d.cacheLen == 0 {
return
}
d.mux.Lock()
defer d.mux.Unlock()
if err := d.storage.sendBulk(d.binarySignaledCtx, d.cache); err != nil {
logging.WithError(err).WithField("size", len(d.cache)).Error("storing bulk failed")
}
d.cache = nil
d.cacheLen = 0
if d.cfg.MinFrequency > 0 {
d.ticker.Reset(d.cfg.MinFrequency)
}
}
func (d *debouncer) shipOnTicks() {
for range d.ticker.C {
d.ship()
}
}

View File

@ -0,0 +1,112 @@
package logstore
import (
"context"
"fmt"
"time"
"github.com/benbjohnson/clock"
"github.com/zitadel/logging"
)
type EmitterConfig struct {
Enabled bool
Keep time.Duration
CleanupInterval time.Duration
Debounce *DebouncerConfig
}
type emitter struct {
enabled bool
ctx context.Context
debouncer *debouncer
emitter LogEmitter
clock clock.Clock
}
type LogRecord interface {
Normalize() LogRecord
}
type LogRecordFunc func() LogRecord
func (r LogRecordFunc) Normalize() LogRecord {
return r()
}
type LogEmitter interface {
Emit(ctx context.Context, bulk []LogRecord) error
}
type LogEmitterFunc func(ctx context.Context, bulk []LogRecord) error
func (l LogEmitterFunc) Emit(ctx context.Context, bulk []LogRecord) error {
return l(ctx, bulk)
}
type LogCleanupper interface {
LogEmitter
Cleanup(ctx context.Context, keep time.Duration) error
}
// NewEmitter accepts Clock from github.com/benbjohnson/clock so we can control timers and tickers in the unit tests
func NewEmitter(ctx context.Context, clock clock.Clock, cfg *EmitterConfig, logger LogEmitter) (*emitter, error) {
svc := &emitter{
enabled: cfg != nil && cfg.Enabled,
ctx: ctx,
emitter: logger,
clock: clock,
}
if !svc.enabled {
return svc, nil
}
if cfg.Debounce != nil && (cfg.Debounce.MinFrequency > 0 || cfg.Debounce.MaxBulkSize > 0) {
svc.debouncer = newDebouncer(ctx, *cfg.Debounce, clock, newStorageBulkSink(svc.emitter))
}
cleanupper, ok := logger.(LogCleanupper)
if !ok {
if cfg.Keep != 0 {
return nil, fmt.Errorf("cleaning up for this storage type is not supported, so keep duration must be 0, but is %d", cfg.Keep)
}
if cfg.CleanupInterval != 0 {
return nil, fmt.Errorf("cleaning up for this storage type is not supported, so cleanup interval duration must be 0, but is %d", cfg.Keep)
}
return svc, nil
}
if cfg.Keep != 0 && cfg.CleanupInterval != 0 {
go svc.startCleanupping(cleanupper, cfg.CleanupInterval, cfg.Keep)
}
return svc, nil
}
func (s *emitter) startCleanupping(cleanupper LogCleanupper, cleanupInterval, keep time.Duration) {
for range s.clock.Tick(cleanupInterval) {
if err := cleanupper.Cleanup(s.ctx, keep); err != nil {
logging.WithError(err).Error("cleaning up logs failed")
}
}
}
func (s *emitter) Emit(ctx context.Context, record LogRecord) (err error) {
if !s.enabled {
return nil
}
if s.debouncer != nil {
s.debouncer.add(record)
return nil
}
return s.emitter.Emit(ctx, []LogRecord{record})
}
func newStorageBulkSink(emitter LogEmitter) bulkSinkFunc {
return func(ctx context.Context, bulk []LogRecord) error {
return emitter.Emit(ctx, bulk)
}
}

View File

@ -0,0 +1,159 @@
package access
import (
"context"
"database/sql"
"fmt"
"net/http"
"strings"
"time"
"github.com/Masterminds/squirrel"
"github.com/zitadel/logging"
"google.golang.org/grpc/codes"
zitadel_http "github.com/zitadel/zitadel/internal/api/http"
caos_errors "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/logstore"
"github.com/zitadel/zitadel/internal/repository/quota"
)
const (
accessLogsTable = "logstore.access"
accessTimestampCol = "log_date"
accessProtocolCol = "protocol"
accessRequestURLCol = "request_url"
accessResponseStatusCol = "response_status"
accessRequestHeadersCol = "request_headers"
accessResponseHeadersCol = "response_headers"
accessInstanceIdCol = "instance_id"
accessProjectIdCol = "project_id"
accessRequestedDomainCol = "requested_domain"
accessRequestedHostCol = "requested_host"
)
var _ logstore.UsageQuerier = (*databaseLogStorage)(nil)
var _ logstore.LogCleanupper = (*databaseLogStorage)(nil)
type databaseLogStorage struct {
dbClient *sql.DB
}
func NewDatabaseLogStorage(dbClient *sql.DB) *databaseLogStorage {
return &databaseLogStorage{dbClient: dbClient}
}
func (l *databaseLogStorage) QuotaUnit() quota.Unit {
return quota.RequestsAllAuthenticated
}
func (l *databaseLogStorage) Emit(ctx context.Context, bulk []logstore.LogRecord) error {
builder := squirrel.Insert(accessLogsTable).
Columns(
accessTimestampCol,
accessProtocolCol,
accessRequestURLCol,
accessResponseStatusCol,
accessRequestHeadersCol,
accessResponseHeadersCol,
accessInstanceIdCol,
accessProjectIdCol,
accessRequestedDomainCol,
accessRequestedHostCol,
).
PlaceholderFormat(squirrel.Dollar)
for idx := range bulk {
item := bulk[idx].(*Record)
builder = builder.Values(
item.LogDate,
item.Protocol,
item.RequestURL,
item.ResponseStatus,
item.RequestHeaders,
item.ResponseHeaders,
item.InstanceID,
item.ProjectID,
item.RequestedDomain,
item.RequestedHost,
)
}
stmt, args, err := builder.ToSql()
if err != nil {
return caos_errors.ThrowInternal(err, "ACCESS-KOS7I", "Errors.Internal")
}
result, err := l.dbClient.ExecContext(ctx, stmt, args...)
if err != nil {
return caos_errors.ThrowInternal(err, "ACCESS-alnT9", "Errors.Access.StorageFailed")
}
rows, err := result.RowsAffected()
if err != nil {
return caos_errors.ThrowInternal(err, "ACCESS-7KIpL", "Errors.Internal")
}
logging.WithFields("rows", rows).Debug("successfully stored access logs")
return nil
}
// TODO: AS OF SYSTEM TIME
func (l *databaseLogStorage) QueryUsage(ctx context.Context, instanceId string, start time.Time) (uint64, error) {
stmt, args, err := squirrel.Select(
fmt.Sprintf("count(%s)", accessInstanceIdCol),
).
From(accessLogsTable).
Where(squirrel.And{
squirrel.Eq{accessInstanceIdCol: instanceId},
squirrel.GtOrEq{accessTimestampCol: start},
squirrel.Expr(fmt.Sprintf(`%s #>> '{%s,0}' = '[REDACTED]'`, accessRequestHeadersCol, strings.ToLower(zitadel_http.Authorization))),
squirrel.NotLike{accessRequestURLCol: "%/zitadel.system.v1.SystemService/%"},
squirrel.NotLike{accessRequestURLCol: "%/system/v1/%"},
squirrel.Or{
squirrel.And{
squirrel.Eq{accessProtocolCol: HTTP},
squirrel.NotEq{accessResponseStatusCol: http.StatusForbidden},
squirrel.NotEq{accessResponseStatusCol: http.StatusInternalServerError},
squirrel.NotEq{accessResponseStatusCol: http.StatusTooManyRequests},
},
squirrel.And{
squirrel.Eq{accessProtocolCol: GRPC},
squirrel.NotEq{accessResponseStatusCol: codes.PermissionDenied},
squirrel.NotEq{accessResponseStatusCol: codes.Internal},
squirrel.NotEq{accessResponseStatusCol: codes.ResourceExhausted},
},
},
}).
PlaceholderFormat(squirrel.Dollar).
ToSql()
if err != nil {
return 0, caos_errors.ThrowInternal(err, "ACCESS-V9Sde", "Errors.Internal")
}
var count uint64
if err = l.dbClient.
QueryRowContext(ctx, stmt, args...).
Scan(&count); err != nil {
return 0, caos_errors.ThrowInternal(err, "ACCESS-pBPrM", "Errors.Logstore.Access.ScanFailed")
}
return count, nil
}
func (l *databaseLogStorage) Cleanup(ctx context.Context, keep time.Duration) error {
stmt, args, err := squirrel.Delete(accessLogsTable).
Where(squirrel.LtOrEq{accessTimestampCol: time.Now().Add(-keep)}).
PlaceholderFormat(squirrel.Dollar).
ToSql()
if err != nil {
return caos_errors.ThrowInternal(err, "ACCESS-2oTh6", "Errors.Internal")
}
execCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
_, err = l.dbClient.ExecContext(execCtx, stmt, args...)
return err
}

View File

@ -0,0 +1,91 @@
package access
import (
"strings"
"time"
zitadel_http "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/logstore"
)
var _ logstore.LogRecord = (*Record)(nil)
type Record struct {
LogDate time.Time `json:"logDate"`
Protocol Protocol `json:"protocol"`
RequestURL string `json:"requestUrl"`
ResponseStatus uint32 `json:"responseStatus"`
// RequestHeaders are plain maps so varying implementation
// between HTTP and gRPC don't interfere with each other
RequestHeaders map[string][]string `json:"requestHeaders"`
// ResponseHeaders are plain maps so varying implementation
// between HTTP and gRPC don't interfere with each other
ResponseHeaders map[string][]string `json:"responseHeaders"`
InstanceID string `json:"instanceId"`
ProjectID string `json:"projectId"`
RequestedDomain string `json:"requestedDomain"`
RequestedHost string `json:"requestedHost"`
}
type Protocol uint8
const (
GRPC Protocol = iota
HTTP
redacted = "[REDACTED]"
)
func (a Record) Normalize() logstore.LogRecord {
a.RequestedDomain = cutString(a.RequestedDomain, 200)
a.RequestURL = cutString(a.RequestURL, 200)
normalizeHeaders(a.RequestHeaders, strings.ToLower(zitadel_http.Authorization), "grpcgateway-authorization", "cookie", "grpcgateway-cookie")
normalizeHeaders(a.ResponseHeaders, "set-cookie")
return &a
}
const maxValuesPerKey = 10
// normalizeHeaders lowers all header keys and redacts secrets
func normalizeHeaders(header map[string][]string, redactKeysLower ...string) {
lowerKeys(header)
redactKeys(header, redactKeysLower...)
pruneKeys(header)
}
func lowerKeys(header map[string][]string) {
for k, v := range header {
delete(header, k)
header[strings.ToLower(k)] = v
}
}
func redactKeys(header map[string][]string, redactKeysLower ...string) {
for _, redactKey := range redactKeysLower {
if _, ok := header[redactKey]; ok {
header[redactKey] = []string{redacted}
}
}
}
func pruneKeys(header map[string][]string) {
for key, value := range header {
valueItems := make([]string, 0, maxValuesPerKey)
for i, valueItem := range value {
// Max 10 header values per key
if i > maxValuesPerKey {
break
}
// Max 200 value length
valueItems = append(valueItems, cutString(valueItem, 200))
}
header[key] = valueItems
}
}
func cutString(str string, pos int) string {
if len(str) <= pos {
return str
}
return str[:pos-1]
}

View File

@ -0,0 +1,135 @@
package execution
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/Masterminds/squirrel"
"github.com/zitadel/logging"
caos_errors "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/logstore"
"github.com/zitadel/zitadel/internal/repository/quota"
)
const (
executionLogsTable = "logstore.execution"
executionTimestampCol = "log_date"
executionTookCol = "took"
executionMessageCol = "message"
executionLogLevelCol = "loglevel"
executionInstanceIdCol = "instance_id"
executionActionIdCol = "action_id"
executionMetadataCol = "metadata"
)
var _ logstore.UsageQuerier = (*databaseLogStorage)(nil)
var _ logstore.LogCleanupper = (*databaseLogStorage)(nil)
type databaseLogStorage struct {
dbClient *sql.DB
}
func NewDatabaseLogStorage(dbClient *sql.DB) *databaseLogStorage {
return &databaseLogStorage{dbClient: dbClient}
}
func (l *databaseLogStorage) QuotaUnit() quota.Unit {
return quota.ActionsAllRunsSeconds
}
func (l *databaseLogStorage) Emit(ctx context.Context, bulk []logstore.LogRecord) error {
builder := squirrel.Insert(executionLogsTable).
Columns(
executionTimestampCol,
executionTookCol,
executionMessageCol,
executionLogLevelCol,
executionInstanceIdCol,
executionActionIdCol,
executionMetadataCol,
).
PlaceholderFormat(squirrel.Dollar)
for idx := range bulk {
item := bulk[idx].(*Record)
var took interface{}
if item.Took > 0 {
took = item.Took
}
builder = builder.Values(
item.LogDate,
took,
item.Message,
item.LogLevel,
item.InstanceID,
item.ActionID,
item.Metadata,
)
}
stmt, args, err := builder.ToSql()
if err != nil {
return caos_errors.ThrowInternal(err, "EXEC-KOS7I", "Errors.Internal")
}
result, err := l.dbClient.ExecContext(ctx, stmt, args...)
if err != nil {
return caos_errors.ThrowInternal(err, "EXEC-0j6i5", "Errors.Access.StorageFailed")
}
rows, err := result.RowsAffected()
if err != nil {
return caos_errors.ThrowInternal(err, "EXEC-MGchJ", "Errors.Internal")
}
logging.WithFields("rows", rows).Debug("successfully stored execution logs")
return nil
}
// TODO: AS OF SYSTEM TIME
func (l *databaseLogStorage) QueryUsage(ctx context.Context, instanceId string, start time.Time) (uint64, error) {
stmt, args, err := squirrel.Select(
fmt.Sprintf("COALESCE(SUM(%s)::INT,0)", executionTookCol),
).
From(executionLogsTable).
Where(squirrel.And{
squirrel.Eq{executionInstanceIdCol: instanceId},
squirrel.GtOrEq{executionTimestampCol: start},
squirrel.NotEq{executionTookCol: nil},
}).
PlaceholderFormat(squirrel.Dollar).
ToSql()
if err != nil {
return 0, caos_errors.ThrowInternal(err, "EXEC-DXtzg", "Errors.Internal")
}
var durationSeconds uint64
if err = l.dbClient.
QueryRowContext(ctx, stmt, args...).
Scan(&durationSeconds); err != nil {
return 0, caos_errors.ThrowInternal(err, "EXEC-Ad8nP", "Errors.Logstore.Execution.ScanFailed")
}
return durationSeconds, nil
}
func (l *databaseLogStorage) Cleanup(ctx context.Context, keep time.Duration) error {
stmt, args, err := squirrel.Delete(executionLogsTable).
Where(squirrel.LtOrEq{executionTimestampCol: time.Now().Add(-keep)}).
PlaceholderFormat(squirrel.Dollar).
ToSql()
if err != nil {
return caos_errors.ThrowInternal(err, "EXEC-Bja8V", "Errors.Internal")
}
execCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
_, err = l.dbClient.ExecContext(execCtx, stmt, args...)
return err
}

View File

@ -0,0 +1,33 @@
package execution
import (
"time"
"github.com/sirupsen/logrus"
"github.com/zitadel/zitadel/internal/logstore"
)
var _ logstore.LogRecord = (*Record)(nil)
type Record struct {
LogDate time.Time `json:"logDate"`
Took time.Duration `json:"took"`
Message string `json:"message"`
LogLevel logrus.Level `json:"logLevel"`
InstanceID string `json:"instanceId"`
ActionID string `json:"actionId,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
func (e Record) Normalize() logstore.LogRecord {
e.Message = cutString(e.Message, 2000)
return &e
}
func cutString(str string, pos int) string {
if len(str) <= pos {
return str
}
return str[:pos]
}

View File

@ -0,0 +1,89 @@
package mock
import (
"context"
"sync"
"time"
"github.com/benbjohnson/clock"
"github.com/zitadel/zitadel/internal/logstore"
"github.com/zitadel/zitadel/internal/repository/quota"
)
var _ logstore.UsageQuerier = (*InmemLogStorage)(nil)
var _ logstore.LogCleanupper = (*InmemLogStorage)(nil)
type InmemLogStorage struct {
mux sync.Mutex
clock clock.Clock
emitted []*record
bulks []int
}
func NewInMemoryStorage(clock clock.Clock) *InmemLogStorage {
return &InmemLogStorage{
clock: clock,
emitted: make([]*record, 0),
bulks: make([]int, 0),
}
}
func (l *InmemLogStorage) QuotaUnit() quota.Unit {
return quota.Unimplemented
}
func (l *InmemLogStorage) Emit(_ context.Context, bulk []logstore.LogRecord) error {
if len(bulk) == 0 {
return nil
}
l.mux.Lock()
defer l.mux.Unlock()
for idx := range bulk {
l.emitted = append(l.emitted, bulk[idx].(*record))
}
l.bulks = append(l.bulks, len(bulk))
return nil
}
func (l *InmemLogStorage) QueryUsage(_ context.Context, _ string, start time.Time) (uint64, error) {
l.mux.Lock()
defer l.mux.Unlock()
var count uint64
for _, r := range l.emitted {
if r.ts.After(start) {
count++
}
}
return count, nil
}
func (l *InmemLogStorage) Cleanup(_ context.Context, keep time.Duration) error {
l.mux.Lock()
defer l.mux.Unlock()
clean := make([]*record, 0)
from := l.clock.Now().Add(-(keep + 1))
for _, r := range l.emitted {
if r.ts.After(from) {
clean = append(clean, r)
}
}
l.emitted = clean
return nil
}
func (l *InmemLogStorage) Bulks() []int {
l.mux.Lock()
defer l.mux.Unlock()
return l.bulks
}
func (l *InmemLogStorage) Len() int {
l.mux.Lock()
defer l.mux.Unlock()
return len(l.emitted)
}

View File

@ -0,0 +1,25 @@
package mock
import (
"time"
"github.com/benbjohnson/clock"
"github.com/zitadel/zitadel/internal/logstore"
)
var _ logstore.LogRecord = (*record)(nil)
func NewRecord(clock clock.Clock) *record {
return &record{ts: clock.Now()}
}
type record struct {
ts time.Time
redacted bool
}
func (r record) Normalize() logstore.LogRecord {
r.redacted = true
return &r
}

Some files were not shown because too many files have changed in this diff Show More