mirror of
https://github.com/zitadel/zitadel.git
synced 2024-12-04 23:45:07 +00:00
feat: add quotas (#4779)
adds possibilities to cap authenticated requests and execution seconds of actions on a defined intervall
This commit is contained in:
parent
45f6a4436e
commit
681541f41b
@ -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.
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
|
||||
|
@ -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"
|
||||
|
@ -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
32
cmd/setup/07.go
Normal 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
14
cmd/setup/07/access.sql
Normal 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)
|
||||
);
|
11
cmd/setup/07/execution.sql
Normal file
11
cmd/setup/07/execution.sql
Normal 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)
|
||||
);
|
3
cmd/setup/07/logstore.sql
Normal file
3
cmd/setup/07/logstore.sql
Normal file
@ -0,0 +1,3 @@
|
||||
CREATE SCHEMA IF NOT EXISTS logstore;
|
||||
|
||||
GRANT ALL ON ALL TABLES IN SCHEMA logstore TO %[1]s;
|
@ -62,6 +62,7 @@ type Steps struct {
|
||||
s4EventstoreIndexes *EventstoreIndexes
|
||||
s5LastFailed *LastFailed
|
||||
s6OwnerRemoveColumns *OwnerRemoveColumns
|
||||
s7LogstoreTables *LogstoreTables
|
||||
}
|
||||
|
||||
type encryptionKeyConfig struct {
|
||||
|
@ -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)
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -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).
|
60
docs/docs/self-hosting/manage/quotas.md
Normal file
60
docs/docs/self-hosting/manage/quotas.md
Normal 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.
|
||||
|
@ -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: [
|
||||
|
@ -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}`);
|
||||
});
|
||||
}
|
||||
|
@ -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', () => {
|
||||
|
@ -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({
|
||||
|
23
e2e/cypress/e2e/i18n/api.cy.ts
Normal file
23
e2e/cypress/e2e/i18n/api.cy.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
@ -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 ...');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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', () => {
|
||||
|
248
e2e/cypress/e2e/quotas/quotas.cy.ts
Normal file
248
e2e/cypress/e2e/quotas/quotas.cy.ts
Normal 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;
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -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');
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -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',
|
||||
|
31
e2e/cypress/support/api/instances.ts
Normal file
31
e2e/cypress/support/api/instances.ts
Normal 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,
|
||||
});
|
||||
}
|
@ -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`,
|
||||
|
@ -7,7 +7,7 @@ export function ensureOIDCSettingsSet(
|
||||
idTokenLifetime: number,
|
||||
refreshTokenExpiration: number,
|
||||
refreshTokenIdleExpiration: number,
|
||||
): Cypress.Chainable<number> {
|
||||
) {
|
||||
return ensureSetting(
|
||||
api,
|
||||
`${api.adminBaseURL}/settings/oidc`,
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -22,7 +22,7 @@ export function ensureDomainPolicy(
|
||||
userLoginMustBeDomain: boolean,
|
||||
validateOrgDomains: boolean,
|
||||
smtpSenderAddressMatchesInstanceDomain: boolean,
|
||||
): Cypress.Chainable<number> {
|
||||
) {
|
||||
return ensureSetting(
|
||||
api,
|
||||
`${api.adminBaseURL}/policies/domain`,
|
||||
|
@ -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`,
|
||||
|
84
e2e/cypress/support/api/quota.ts
Normal file
84
e2e/cypress/support/api/quota.ts
Normal 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;
|
||||
});
|
||||
}
|
@ -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) => {
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
},
|
||||
};
|
||||
|
@ -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,
|
||||
};
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -15,6 +15,4 @@
|
||||
|
||||
// Import commands.js using ES2015 syntax:
|
||||
import './commands';
|
||||
|
||||
// Alternatively you can use CommonJS syntax:
|
||||
// require('./commands')
|
||||
import './types';
|
||||
|
@ -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'
|
@ -82,8 +82,10 @@ export function login(
|
||||
|
||||
onAuthenticated ? onAuthenticated() : null;
|
||||
|
||||
cy.visit('/');
|
||||
|
||||
cy.get('[data-e2e=authenticated-welcome]', {
|
||||
timeout: 10_000,
|
||||
timeout: 50_000,
|
||||
});
|
||||
},
|
||||
{
|
||||
|
10
e2e/cypress/support/types.ts
Normal file
10
e2e/cypress/support/types.ts
Normal file
@ -0,0 +1,10 @@
|
||||
let webhookEventSchema = {
|
||||
unit: 0,
|
||||
id: '',
|
||||
callURL: '',
|
||||
periodStart: new Date(),
|
||||
threshold: 0,
|
||||
usage: 0,
|
||||
};
|
||||
|
||||
export type ZITADELWebhookEvent = typeof webhookEventSchema;
|
31
e2e/docker-compose-zitadel.yaml
Normal file
31
e2e/docker-compose-zitadel.yaml
Normal 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=="
|
||||
|
@ -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
454
e2e/package-lock.json
generated
@ -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",
|
||||
|
@ -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
3
go.mod
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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"),
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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())
|
||||
|
@ -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))
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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")},
|
||||
|
55
internal/api/grpc/server/middleware/access_interceptor.go
Normal file
55
internal/api/grpc/server/middleware/access_interceptor.go
Normal 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
|
||||
}
|
||||
}
|
@ -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, ¬FoundErr) {
|
||||
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, ¬FoundErr) {
|
||||
notFoundErr.Message = translator.LocalizeFromCtx(ctx, notFoundErr.GetMessage(), nil)
|
||||
}
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
|
@ -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 {
|
||||
|
46
internal/api/grpc/server/middleware/quota_interceptor.go
Normal file
46
internal/api/grpc/server/middleware/quota_interceptor.go
Normal 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)
|
||||
}
|
||||
}
|
@ -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),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
@ -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
|
||||
|
32
internal/api/grpc/system/quota.go
Normal file
32
internal/api/grpc/system/quota.go
Normal 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
|
||||
}
|
43
internal/api/grpc/system/quota_converter.go
Normal file
43
internal/api/grpc/system/quota_converter.go
Normal 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
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
102
internal/api/http/middleware/access_interceptor.go
Normal file
102
internal/api/http/middleware/access_interceptor.go
Normal 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)
|
||||
}
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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,
|
||||
),
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -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...)
|
||||
|
@ -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
207
internal/command/quota.go
Normal 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] = "a.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
|
||||
}
|
||||
}
|
54
internal/command/quota_model.go
Normal file
54
internal/command/quota_model.go
Normal 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 "aWriteModel{
|
||||
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()
|
||||
}
|
59
internal/command/quota_notifications.go
Normal file
59
internal/command/quota_notifications.go
Normal 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)
|
||||
}
|
45
internal/command/quota_notifications_model.go
Normal file
45
internal/command/quota_notifications_model.go
Normal 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 "aNotificationsWriteModel{
|
||||
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()
|
||||
}
|
25
internal/command/quota_period.go
Normal file
25
internal/command/quota_period.go
Normal 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)
|
||||
}
|
58
internal/command/quota_report.go
Normal file
58
internal/command/quota_report.go
Normal 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
|
||||
}
|
48
internal/errors/resource_exhausted.go
Normal file
48
internal/errors/resource_exhausted.go
Normal 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
|
||||
}
|
34
internal/errors/resource_exhausted_test.go
Normal file
34
internal/errors/resource_exhausted_test.go
Normal 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)
|
||||
}
|
@ -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:
|
||||
|
11
internal/logstore/config.go
Normal file
11
internal/logstore/config.go
Normal file
@ -0,0 +1,11 @@
|
||||
package logstore
|
||||
|
||||
type Configs struct {
|
||||
Access *Config
|
||||
Execution *Config
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Database *EmitterConfig
|
||||
Stdout *EmitterConfig
|
||||
}
|
92
internal/logstore/debouncer.go
Normal file
92
internal/logstore/debouncer.go
Normal 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()
|
||||
}
|
||||
}
|
112
internal/logstore/emitter.go
Normal file
112
internal/logstore/emitter.go
Normal 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)
|
||||
}
|
||||
}
|
159
internal/logstore/emitters/access/database.go
Normal file
159
internal/logstore/emitters/access/database.go
Normal 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
|
||||
}
|
91
internal/logstore/emitters/access/record.go
Normal file
91
internal/logstore/emitters/access/record.go
Normal 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]
|
||||
}
|
135
internal/logstore/emitters/execution/database.go
Normal file
135
internal/logstore/emitters/execution/database.go
Normal 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
|
||||
}
|
33
internal/logstore/emitters/execution/record.go
Normal file
33
internal/logstore/emitters/execution/record.go
Normal 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]
|
||||
}
|
89
internal/logstore/emitters/mock/inmem.go
Normal file
89
internal/logstore/emitters/mock/inmem.go
Normal 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)
|
||||
}
|
25
internal/logstore/emitters/mock/record.go
Normal file
25
internal/logstore/emitters/mock/record.go
Normal 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
Loading…
Reference in New Issue
Block a user