From 35a097766392d79ea26ae39415ffe8fbbd707726 Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Thu, 11 May 2023 09:24:44 +0200 Subject: [PATCH 01/16] fix: improve exhausted SetCookie header (#5789) * fix: remove access interceptor for console * feat: template quota cookie value * fix: send exhausted cookie from grpc-gateway * refactor: remove ineffectual err assignments * Update internal/api/grpc/server/gateway.go Co-authored-by: Livio Spring * use dynamic host header to find instance * add instance mgmt url to environment.json * support hosts with default ports * fix linting * docs: update lb example * print access logs to stdout * fix grpc gateway exhausted cookies * cleanup --------- Co-authored-by: Livio Spring --- cmd/defaults.yaml | 1 + cmd/start/start.go | 21 ++++-- .../loadbalancing-example/docker-compose.yaml | 2 +- .../example-traefik.yaml | 7 +- .../example-zitadel-config.yaml | 5 ++ .../loadbalancing-example.mdx | 5 ++ e2e/cypress/e2e/quotas/quotas.cy.ts | 17 +++-- internal/api/api.go | 20 ++++- internal/api/grpc/server/gateway.go | 72 ++++++++++++++++-- .../api/http/middleware/access_interceptor.go | 74 ++++++++++++------- internal/api/ui/console/console.go | 47 +++++++++--- 11 files changed, 208 insertions(+), 63 deletions(-) diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 1acbb55d44..0867a1d5d3 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -267,6 +267,7 @@ Console: LongCache: MaxAge: 12h SharedMaxAge: 168h #7d + InstanceManagementURL: "" Notification: Repository: diff --git a/cmd/start/start.go b/cmd/start/start.go index 6f248c1083..4ae1e15b08 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -5,6 +5,7 @@ import ( "crypto/tls" _ "embed" "fmt" + "math" "net" "net/http" "os" @@ -301,8 +302,14 @@ func startAPIs( if accessSvc.Enabled() { logging.Warn("access logs are currently in beta") } - accessInterceptor := middleware.NewAccessInterceptor(accessSvc, config.Quotas.Access) - apis, err := api.New(ctx, config.Port, router, queries, verifier, config.InternalAuthZ, tlsConfig, config.HTTP2HostHeader, config.HTTP1HostHeader, accessSvc) + exhaustedCookieHandler := http_util.NewCookieHandler( + http_util.WithUnsecure(), + http_util.WithNonHttpOnly(), + http_util.WithMaxAge(int(math.Floor(config.Quotas.Access.ExhaustedCookieMaxAge.Seconds()))), + ) + limitingAccessInterceptor := middleware.NewAccessInterceptor(accessSvc, exhaustedCookieHandler, config.Quotas.Access, false) + nonLimitingAccessInterceptor := middleware.NewAccessInterceptor(accessSvc, nil, config.Quotas.Access, true) + apis, err := api.New(ctx, config.Port, router, queries, verifier, config.InternalAuthZ, tlsConfig, config.HTTP2HostHeader, config.HTTP1HostHeader, accessSvc, exhaustedCookieHandler, config.Quotas.Access) if err != nil { return fmt.Errorf("error creating api %w", err) } @@ -334,7 +341,7 @@ func startAPIs( } instanceInterceptor := middleware.InstanceInterceptor(queries, config.HTTP1HostHeader, login.IgnoreInstanceEndpoints...) assetsCache := middleware.AssetsCacheInterceptor(config.AssetStorage.Cache.MaxAge, config.AssetStorage.Cache.SharedMaxAge) - apis.RegisterHandlerOnPrefix(assets.HandlerPrefix, assets.NewHandler(commands, verifier, config.InternalAuthZ, id.SonyFlakeGenerator(), store, queries, middleware.CallDurationHandler, instanceInterceptor.Handler, assetsCache.Handler, accessInterceptor.Handle)) + apis.RegisterHandlerOnPrefix(assets.HandlerPrefix, assets.NewHandler(commands, verifier, config.InternalAuthZ, id.SonyFlakeGenerator(), store, queries, middleware.CallDurationHandler, instanceInterceptor.Handler, assetsCache.Handler, limitingAccessInterceptor.Handle)) userAgentInterceptor, err := middleware.NewUserAgentHandler(config.UserAgentCookie, keys.UserAgentCookieKey, id.SonyFlakeGenerator(), config.ExternalSecure, login.EndpointResources) if err != nil { @@ -355,25 +362,25 @@ func startAPIs( } apis.RegisterHandlerOnPrefix(openapi.HandlerPrefix, openAPIHandler) - oidcProvider, err := oidc.NewProvider(config.OIDC, login.DefaultLoggedOutPath, config.ExternalSecure, commands, queries, authRepo, keys.OIDC, keys.OIDCKey, eventstore, dbClient, userAgentInterceptor, instanceInterceptor.Handler, accessInterceptor.Handle) + oidcProvider, err := oidc.NewProvider(config.OIDC, login.DefaultLoggedOutPath, config.ExternalSecure, commands, queries, authRepo, keys.OIDC, keys.OIDCKey, eventstore, dbClient, userAgentInterceptor, instanceInterceptor.Handler, limitingAccessInterceptor.Handle) if err != nil { return fmt.Errorf("unable to start oidc provider: %w", err) } apis.RegisterHandlerPrefixes(oidcProvider.HttpHandler(), "/.well-known/openid-configuration", "/oidc/v1", "/oauth/v2") - samlProvider, err := saml.NewProvider(config.SAML, config.ExternalSecure, commands, queries, authRepo, keys.OIDC, keys.SAML, eventstore, dbClient, instanceInterceptor.Handler, userAgentInterceptor, accessInterceptor.Handle) + samlProvider, err := saml.NewProvider(config.SAML, config.ExternalSecure, commands, queries, authRepo, keys.OIDC, keys.SAML, eventstore, dbClient, instanceInterceptor.Handler, userAgentInterceptor, limitingAccessInterceptor.Handle) if err != nil { return fmt.Errorf("unable to start saml provider: %w", err) } apis.RegisterHandlerOnPrefix(saml.HandlerPrefix, samlProvider.HttpHandler()) - c, err := console.Start(config.Console, config.ExternalSecure, oidcProvider.IssuerFromRequest, middleware.CallDurationHandler, instanceInterceptor.Handler, accessInterceptor.Handle, config.CustomerPortal) + c, err := console.Start(config.Console, config.ExternalSecure, oidcProvider.IssuerFromRequest, middleware.CallDurationHandler, instanceInterceptor.Handler, nonLimitingAccessInterceptor.Handle, config.CustomerPortal) if err != nil { return fmt.Errorf("unable to start console: %w", err) } apis.RegisterHandlerOnPrefix(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, accessInterceptor.Handle, 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, limitingAccessInterceptor.Handle, keys.User, keys.IDPConfig, keys.CSRFCookieKey) if err != nil { return fmt.Errorf("unable to start login: %w", err) } diff --git a/docs/docs/self-hosting/deploy/loadbalancing-example/docker-compose.yaml b/docs/docs/self-hosting/deploy/loadbalancing-example/docker-compose.yaml index 45d941f7b2..21a365354a 100644 --- a/docs/docs/self-hosting/deploy/loadbalancing-example/docker-compose.yaml +++ b/docs/docs/self-hosting/deploy/loadbalancing-example/docker-compose.yaml @@ -4,7 +4,7 @@ services: traefik: networks: - 'zitadel' - image: "traefik:v2.7" + image: "traefik:v2.10.1" ports: - "80:80" - "443:443" diff --git a/docs/docs/self-hosting/deploy/loadbalancing-example/example-traefik.yaml b/docs/docs/self-hosting/deploy/loadbalancing-example/example-traefik.yaml index 618d089c8b..4b4f5a35ca 100644 --- a/docs/docs/self-hosting/deploy/loadbalancing-example/example-traefik.yaml +++ b/docs/docs/self-hosting/deploy/loadbalancing-example/example-traefik.yaml @@ -1,3 +1,8 @@ +log: + level: DEBUG + +accessLog: {} + entrypoints: web: address: ":80" @@ -7,7 +12,7 @@ entrypoints: tls: stores: - default: + default: # generates self-signed certificates defaultCertificate: diff --git a/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-config.yaml b/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-config.yaml index 2e1afc71cd..6c45449cf5 100644 --- a/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-config.yaml +++ b/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-config.yaml @@ -23,3 +23,8 @@ Database: RootCert: "/crdb-certs/ca.crt" Cert: "/crdb-certs/client.root.crt" Key: "/crdb-certs/client.root.key" + +LogStore: + Access: + Stdout: + Enabled: true diff --git a/docs/docs/self-hosting/deploy/loadbalancing-example/loadbalancing-example.mdx b/docs/docs/self-hosting/deploy/loadbalancing-example/loadbalancing-example.mdx index 933404c3f2..e4a668b0fb 100644 --- a/docs/docs/self-hosting/deploy/loadbalancing-example/loadbalancing-example.mdx +++ b/docs/docs/self-hosting/deploy/loadbalancing-example/loadbalancing-example.mdx @@ -70,3 +70,8 @@ This is the IAM admin users login according to your configuration in the [exampl - **password**: *RootPassword1!* Read more about [the login process](/guides/integrate/login-users). + +## Troubleshooting + +You can connect to cockroach like this: `docker exec -it loadbalancing-example-my-cockroach-db-1 cockroach sql --host my-cockroach-db --certs-dir /cockroach/certs/` +For example, to show all login names: `docker exec -it loadbalancing-example-my-cockroach-db-1 cockroach sql --database zitadel --host my-cockroach-db --certs-dir /cockroach/certs/ --execute "select * from projections.login_names2"` diff --git a/e2e/cypress/e2e/quotas/quotas.cy.ts b/e2e/cypress/e2e/quotas/quotas.cy.ts index 14e5c50da9..805779fb7c 100644 --- a/e2e/cypress/e2e/quotas/quotas.cy.ts +++ b/e2e/cypress/e2e/quotas/quotas.cy.ts @@ -107,14 +107,9 @@ describe('quotas', () => { }, }); }); + expectCookieDoesntExist(); 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', @@ -127,12 +122,16 @@ describe('quotas', () => { }); cy.getCookie('zitadel.quota.limiting').then((cookie) => { expect(cookie.value).to.equal('true'); + const cookieExpiry = new Date(); + cookieExpiry.setTime(cookie.expiry * 1000); + expect(cookieExpiry).to.be.within(start, expiresMax); }); createHumanUser(ctx.api, testUserName, false).then((res) => { expect(res.status).to.equal(429); }); ensureQuotaIsRemoved(ctx, Unit.AuthenticatedRequests); createHumanUser(ctx.api, testUserName); + expectCookieDoesntExist(); }); }); }); @@ -301,3 +300,9 @@ describe('quotas', () => { }); }); }); + +function expectCookieDoesntExist() { + cy.getCookie('zitadel.quota.limiting').then((cookie) => { + expect(cookie).to.be.null; + }); +} diff --git a/internal/api/api.go b/internal/api/api.go index 4efdb322a0..768f0f1a2e 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -34,6 +34,9 @@ type API struct { http1HostName string grpcGateway *server.Gateway healthServer *health.Server + cookieHandler *http_util.CookieHandler + cookieConfig *http_mw.AccessConfig + queries *query.Queries } type healthCheck interface { @@ -49,6 +52,8 @@ func New( authZ internal_authz.Config, tlsConfig *tls.Config, http2HostName, http1HostName string, accessSvc *logstore.Service, + cookieHandler *http_util.CookieHandler, + cookieConfig *http_mw.AccessConfig, ) (_ *API, err error) { api := &API{ port: port, @@ -56,10 +61,13 @@ func New( health: queries, router: router, http1HostName: http1HostName, + cookieConfig: cookieConfig, + cookieHandler: cookieHandler, + queries: queries, } api.grpcServer = server.CreateServer(api.verifier, authZ, queries, http2HostName, tlsConfig, accessSvc) - api.grpcGateway, err = server.CreateGateway(ctx, port, http1HostName) + api.grpcGateway, err = server.CreateGateway(ctx, port, http1HostName, cookieHandler, cookieConfig) if err != nil { return nil, err } @@ -77,7 +85,15 @@ func New( // used for v1 api (system, admin, mgmt, auth) func (a *API) RegisterServer(ctx context.Context, grpcServer server.WithGatewayPrefix) error { grpcServer.RegisterServer(a.grpcServer) - handler, prefix, err := server.CreateGatewayWithPrefix(ctx, grpcServer, a.port, a.http1HostName) + handler, prefix, err := server.CreateGatewayWithPrefix( + ctx, + grpcServer, + a.port, + a.http1HostName, + a.cookieHandler, + a.cookieConfig, + a.queries, + ) if err != nil { return err } diff --git a/internal/api/grpc/server/gateway.go b/internal/api/grpc/server/gateway.go index faba435940..9798e5dbd0 100644 --- a/internal/api/grpc/server/gateway.go +++ b/internal/api/grpc/server/gateway.go @@ -16,7 +16,9 @@ import ( client_middleware "github.com/zitadel/zitadel/internal/api/grpc/client/middleware" "github.com/zitadel/zitadel/internal/api/grpc/server/middleware" + http_utils "github.com/zitadel/zitadel/internal/api/http" http_mw "github.com/zitadel/zitadel/internal/api/http/middleware" + "github.com/zitadel/zitadel/internal/query" ) const ( @@ -67,10 +69,13 @@ type Gateway struct { mux *runtime.ServeMux http1HostName string connection *grpc.ClientConn + cookieHandler *http_utils.CookieHandler + cookieConfig *http_mw.AccessConfig + queries *query.Queries } func (g *Gateway) Handler() http.Handler { - return addInterceptors(g.mux, g.http1HostName) + return addInterceptors(g.mux, g.http1HostName, g.cookieHandler, g.cookieConfig, g.queries) } type CustomHTTPResponse interface { @@ -79,7 +84,15 @@ type CustomHTTPResponse interface { type RegisterGatewayFunc func(ctx context.Context, mux *runtime.ServeMux, conn *grpc.ClientConn) error -func CreateGatewayWithPrefix(ctx context.Context, g WithGatewayPrefix, port uint16, http1HostName string) (http.Handler, string, error) { +func CreateGatewayWithPrefix( + ctx context.Context, + g WithGatewayPrefix, + port uint16, + http1HostName string, + cookieHandler *http_utils.CookieHandler, + cookieConfig *http_mw.AccessConfig, + queries *query.Queries, +) (http.Handler, string, error) { runtimeMux := runtime.NewServeMux(serveMuxOptions...) opts := []grpc.DialOption{ grpc.WithTransportCredentials(insecure.NewCredentials()), @@ -93,10 +106,10 @@ func CreateGatewayWithPrefix(ctx context.Context, g WithGatewayPrefix, port uint if err != nil { return nil, "", fmt.Errorf("failed to register grpc gateway: %w", err) } - return addInterceptors(runtimeMux, http1HostName), g.GatewayPathPrefix(), nil + return addInterceptors(runtimeMux, http1HostName, cookieHandler, cookieConfig, queries), g.GatewayPathPrefix(), nil } -func CreateGateway(ctx context.Context, port uint16, http1HostName string) (*Gateway, error) { +func CreateGateway(ctx context.Context, port uint16, http1HostName string, cookieHandler *http_utils.CookieHandler, cookieConfig *http_mw.AccessConfig) (*Gateway, error) { connection, err := dial(ctx, port, []grpc.DialOption{ @@ -111,6 +124,8 @@ func CreateGateway(ctx context.Context, port uint16, http1HostName string) (*Gat mux: runtimeMux, http1HostName: http1HostName, connection: connection, + cookieHandler: cookieHandler, + cookieConfig: cookieConfig, }, nil } @@ -145,13 +160,23 @@ func dial(ctx context.Context, port uint16, opts []grpc.DialOption) (*grpc.Clien return conn, nil } -func addInterceptors(handler http.Handler, http1HostName string) http.Handler { +func addInterceptors( + handler http.Handler, + http1HostName string, + cookieHandler *http_utils.CookieHandler, + cookieConfig *http_mw.AccessConfig, + queries *query.Queries, +) http.Handler { handler = http_mw.CallDurationHandler(handler) handler = http1Host(handler, http1HostName) handler = http_mw.CORSInterceptor(handler) handler = http_mw.RobotsTagHandler(handler) handler = http_mw.DefaultTelemetryHandler(handler) - return http_mw.DefaultMetricsHandler(handler) + // For some non-obvious reason, the exhaustedCookieInterceptor sends the SetCookie header + // only if it follows the http_mw.DefaultTelemetryHandler + handler = exhaustedCookieInterceptor(handler, cookieHandler, cookieConfig, queries) + handler = http_mw.DefaultMetricsHandler(handler) + return handler } func http1Host(next http.Handler, http1HostName string) http.Handler { @@ -165,3 +190,38 @@ func http1Host(next http.Handler, http1HostName string) http.Handler { next.ServeHTTP(w, r) }) } + +func exhaustedCookieInterceptor( + next http.Handler, + cookieHandler *http_utils.CookieHandler, + cookieConfig *http_mw.AccessConfig, + queries *query.Queries, +) http.Handler { + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + next.ServeHTTP(&cookieResponseWriter{ + ResponseWriter: writer, + cookieHandler: cookieHandler, + cookieConfig: cookieConfig, + request: request, + queries: queries, + }, request) + }) +} + +type cookieResponseWriter struct { + http.ResponseWriter + cookieHandler *http_utils.CookieHandler + cookieConfig *http_mw.AccessConfig + request *http.Request + queries *query.Queries +} + +func (r *cookieResponseWriter) WriteHeader(status int) { + if status >= 200 && status < 300 { + http_mw.DeleteExhaustedCookie(r.cookieHandler, r.ResponseWriter, r.request, r.cookieConfig) + } + if status == http.StatusTooManyRequests { + http_mw.SetExhaustedCookie(r.cookieHandler, r.ResponseWriter, r.cookieConfig, r.request) + } + r.ResponseWriter.WriteHeader(status) +} diff --git a/internal/api/http/middleware/access_interceptor.go b/internal/api/http/middleware/access_interceptor.go index 4e95a83f6f..cf52a597d6 100644 --- a/internal/api/http/middleware/access_interceptor.go +++ b/internal/api/http/middleware/access_interceptor.go @@ -1,15 +1,16 @@ package middleware import ( - "math" + "net" "net/http" "net/url" - "strconv" + "strings" "time" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/grpc/server/middleware" http_utils "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/logstore" "github.com/zitadel/zitadel/internal/logstore/emitters/access" @@ -20,6 +21,7 @@ type AccessInterceptor struct { svc *logstore.Service cookieHandler *http_utils.CookieHandler limitConfig *AccessConfig + storeOnly bool } type AccessConfig struct { @@ -27,14 +29,15 @@ type AccessConfig struct { ExhaustedCookieMaxAge time.Duration } -func NewAccessInterceptor(svc *logstore.Service, cookieConfig *AccessConfig) *AccessInterceptor { +// NewAccessInterceptor intercepts all requests and stores them to the logstore. +// If storeOnly is false, it also checks if requests are exhausted. +// If requests are exhausted, it also returns http.StatusTooManyRequests and sets a cookie +func NewAccessInterceptor(svc *logstore.Service, cookieHandler *http_utils.CookieHandler, cookieConfig *AccessConfig, storeOnly bool) *AccessInterceptor { return &AccessInterceptor{ - svc: svc, - cookieHandler: http_utils.NewCookieHandler( - http_utils.WithUnsecure(), - http_utils.WithMaxAge(int(math.Floor(cookieConfig.ExhaustedCookieMaxAge.Seconds()))), - ), - limitConfig: cookieConfig, + svc: svc, + cookieHandler: cookieHandler, + limitConfig: cookieConfig, + storeOnly: storeOnly, } } @@ -44,36 +47,33 @@ func (a *AccessInterceptor) Handle(next http.Handler) http.Handler { } return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { ctx := request.Context() - var err error - tracingCtx, checkSpan := tracing.NewNamedSpan(ctx, "checkAccess") - 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 + limit := false + if !a.storeOnly { + remaining := a.svc.Limit(tracingCtx, instance.InstanceID()) + limit = remaining != nil && *remaining == 0 } - checkSpan.End() - - next.ServeHTTP(wrappedWriter, request) - + if limit { + // Limit can only be true when storeOnly is false, so set the cookie and the response code + SetExhaustedCookie(a.cookieHandler, wrappedWriter, a.limitConfig, request) + http.Error(wrappedWriter, "quota for authenticated requests is exhausted", http.StatusTooManyRequests) + } else { + if !a.storeOnly { + // If not limited and not storeOnly, ensure the cookie is deleted + DeleteExhaustedCookie(a.cookieHandler, wrappedWriter, request, a.limitConfig) + } + // Always serve if not limited + next.ServeHTTP(wrappedWriter, request) + } tracingCtx, writeSpan := tracing.NewNamedSpan(tracingCtx, "writeAccess") defer writeSpan.End() - 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(), @@ -90,6 +90,24 @@ func (a *AccessInterceptor) Handle(next http.Handler) http.Handler { }) } +func SetExhaustedCookie(cookieHandler *http_utils.CookieHandler, writer http.ResponseWriter, cookieConfig *AccessConfig, request *http.Request) { + cookieValue := "true" + host := request.Header.Get(middleware.HTTP1Host) + domain := host + if strings.ContainsAny(host, ":") { + var err error + domain, _, err = net.SplitHostPort(host) + if err != nil { + logging.WithError(err).WithField("host", host).Warning("failed to extract cookie domain from request host") + } + } + cookieHandler.SetCookie(writer, cookieConfig.ExhaustedCookieKey, domain, cookieValue) +} + +func DeleteExhaustedCookie(cookieHandler *http_utils.CookieHandler, writer http.ResponseWriter, request *http.Request, cookieConfig *AccessConfig) { + cookieHandler.DeleteCookie(writer, request, cookieConfig.ExhaustedCookieKey) +} + type statusRecorder struct { http.ResponseWriter status int diff --git a/internal/api/ui/console/console.go b/internal/api/ui/console/console.go index ac11fcf706..45503ca0c1 100644 --- a/internal/api/ui/console/console.go +++ b/internal/api/ui/console/console.go @@ -1,9 +1,11 @@ package console import ( + "bytes" "embed" "encoding/json" "fmt" + "html/template" "io/fs" "net/http" "os" @@ -22,8 +24,9 @@ import ( ) type Config struct { - ShortCache middleware.CacheConfig - LongCache middleware.CacheConfig + ShortCache middleware.CacheConfig + LongCache middleware.CacheConfig + InstanceManagementURL string } type spaHandler struct { @@ -106,7 +109,13 @@ func Start(config Config, externalSecure bool, issuer op.IssuerFromRequest, call handler.Use(callDurationInterceptor, 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) + instance := authz.GetInstance(r.Context()) + instanceMgmtURL, err := templateInstanceManagementURL(config.InstanceManagementURL, instance) + if err != nil { + http.Error(w, fmt.Sprintf("unable to template instance management url for console: %v", err), http.StatusInternalServerError) + return + } + environmentJSON, err := createEnvironmentJSON(url, issuer(r), instance.ConsoleClientID(), customerPortal, instanceMgmtURL) if err != nil { http.Error(w, fmt.Sprintf("unable to marshal env for console: %v", err), http.StatusInternalServerError) return @@ -118,6 +127,18 @@ func Start(config Config, externalSecure bool, issuer op.IssuerFromRequest, call return handler, nil } +func templateInstanceManagementURL(templateableCookieValue string, instance authz.Instance) (string, error) { + cookieValueTemplate, err := template.New("cookievalue").Parse(templateableCookieValue) + if err != nil { + return templateableCookieValue, err + } + cookieValue := new(bytes.Buffer) + if err = cookieValueTemplate.Execute(cookieValue, instance); err != nil { + return templateableCookieValue, err + } + return cookieValue.String(), nil +} + func csp() *middleware.CSP { csp := middleware.DefaultSCP csp.StyleSrc = csp.StyleSrc.AddInline() @@ -127,17 +148,19 @@ func csp() *middleware.CSP { return &csp } -func createEnvironmentJSON(api, issuer, clientID, customerPortal string) ([]byte, error) { +func createEnvironmentJSON(api, issuer, clientID, customerPortal, instanceMgmtUrl string) ([]byte, error) { environment := struct { - API string `json:"api,omitempty"` - Issuer string `json:"issuer,omitempty"` - ClientID string `json:"clientid,omitempty"` - CustomerPortal string `json:"customer_portal,omitempty"` + API string `json:"api,omitempty"` + Issuer string `json:"issuer,omitempty"` + ClientID string `json:"clientid,omitempty"` + CustomerPortal string `json:"customer_portal,omitempty"` + InstanceManagementURL string `json:"instance_management_url,omitempty"` }{ - API: api, - Issuer: issuer, - ClientID: clientID, - CustomerPortal: customerPortal, + API: api, + Issuer: issuer, + ClientID: clientID, + CustomerPortal: customerPortal, + InstanceManagementURL: instanceMgmtUrl, } return json.Marshal(environment) } From c6d29fc20122813d545f37f9117522711bc2bc87 Mon Sep 17 00:00:00 2001 From: adlerhurst Date: Thu, 11 May 2023 10:04:35 +0200 Subject: [PATCH 02/16] fix(eventstore): new column to test clock_timestamp() --- cmd/setup/10.go | 6 ++--- cmd/setup/{ => 10}/10_create_temp_table.sql | 0 cmd/setup/{ => 10}/10_fill_table.sql | 0 cmd/setup/{ => 10}/10_update.sql | 0 cmd/setup/11.go | 26 +++++++++++++++++++++ cmd/setup/11.sql | 1 + cmd/setup/config.go | 1 + cmd/setup/setup.go | 3 +++ 8 files changed, 34 insertions(+), 3 deletions(-) rename cmd/setup/{ => 10}/10_create_temp_table.sql (100%) rename cmd/setup/{ => 10}/10_fill_table.sql (100%) rename cmd/setup/{ => 10}/10_update.sql (100%) create mode 100644 cmd/setup/11.go create mode 100644 cmd/setup/11.sql diff --git a/cmd/setup/10.go b/cmd/setup/10.go index fd8683f49d..1661ccf7dd 100644 --- a/cmd/setup/10.go +++ b/cmd/setup/10.go @@ -13,11 +13,11 @@ import ( ) var ( - //go:embed 10_create_temp_table.sql + //go:embed 10/10_create_temp_table.sql correctCreationDate10CreateTable string - //go:embed 10_fill_table.sql + //go:embed 10/10_fill_table.sql correctCreationDate10FillTable string - //go:embed 10_update.sql + //go:embed 10/10_update.sql correctCreationDate10Update string ) diff --git a/cmd/setup/10_create_temp_table.sql b/cmd/setup/10/10_create_temp_table.sql similarity index 100% rename from cmd/setup/10_create_temp_table.sql rename to cmd/setup/10/10_create_temp_table.sql diff --git a/cmd/setup/10_fill_table.sql b/cmd/setup/10/10_fill_table.sql similarity index 100% rename from cmd/setup/10_fill_table.sql rename to cmd/setup/10/10_fill_table.sql diff --git a/cmd/setup/10_update.sql b/cmd/setup/10/10_update.sql similarity index 100% rename from cmd/setup/10_update.sql rename to cmd/setup/10/10_update.sql diff --git a/cmd/setup/11.go b/cmd/setup/11.go new file mode 100644 index 0000000000..97f6bf6687 --- /dev/null +++ b/cmd/setup/11.go @@ -0,0 +1,26 @@ +package setup + +import ( + "context" + _ "embed" + + "github.com/zitadel/zitadel/internal/database" +) + +var ( + //go:embed 11.sql + addEventCreatedAt string +) + +type AddEventCreatedAt struct { + dbClient *database.DB +} + +func (mig *AddEventCreatedAt) Execute(ctx context.Context) error { + _, err := mig.dbClient.ExecContext(ctx, addEventCreatedAt) + return err +} + +func (mig *AddEventCreatedAt) String() string { + return "11_event_created_at" +} diff --git a/cmd/setup/11.sql b/cmd/setup/11.sql new file mode 100644 index 0000000000..28b49e4747 --- /dev/null +++ b/cmd/setup/11.sql @@ -0,0 +1 @@ +ALTER TABLE eventstore.events ADD COLUMN created_at TIMESTAMPTZ /*NOT NULL*/ DEFAULT clock_timestamp(); \ No newline at end of file diff --git a/cmd/setup/config.go b/cmd/setup/config.go index dc75d54048..5e9819e4c7 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -66,6 +66,7 @@ type Steps struct { s8AuthTokens *AuthTokenIndexes s9EventstoreIndexes2 *EventstoreIndexesNew CorrectCreationDate *CorrectCreationDate + s11AddEventCreatedAt *AddEventCreatedAt } type encryptionKeyConfig struct { diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 918f08dc2d..31df9f2657 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -91,6 +91,7 @@ func Setup(config *Config, steps *Steps, masterKey string) { steps.s8AuthTokens = &AuthTokenIndexes{dbClient: dbClient} steps.s9EventstoreIndexes2 = New09(dbClient) steps.CorrectCreationDate.dbClient = dbClient + steps.s11AddEventCreatedAt = &AddEventCreatedAt{dbClient: dbClient} err = projection.Create(ctx, dbClient, eventstoreClient, config.Projections, nil, nil) logging.OnError(err).Fatal("unable to start projections") @@ -128,6 +129,8 @@ func Setup(config *Config, steps *Steps, masterKey string) { logging.OnError(err).Fatal("unable to migrate step 9") err = migration.Migrate(ctx, eventstoreClient, steps.CorrectCreationDate) logging.OnError(err).Fatal("unable to migrate step 10") + err = migration.Migrate(ctx, eventstoreClient, steps.s11AddEventCreatedAt) + logging.OnError(err).Fatal("unable to migrate step 11") for _, repeatableStep := range repeatableSteps { err = migration.Migrate(ctx, eventstoreClient, repeatableStep) From 2dc016ea3b533e95fbd07323451dc237cafc16d1 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Thu, 11 May 2023 10:18:14 +0200 Subject: [PATCH 03/16] feat(console): device code (#5771) * feat: device code * device code, create stepper * rm logs * app setup with device code * remove redirects if grant type is device code only * add device code app e2e --------- Co-authored-by: Fabi Co-authored-by: Elio Bischof --- .../app-auth-method-radio.component.html | 14 ++-- .../app-auth-method-radio.component.scss | 6 +- .../app-auth-method-radio.component.ts | 2 +- .../apps/app-create/app-create.component.html | 22 +++--- .../apps/app-create/app-create.component.ts | 14 +++- .../apps/app-detail/app-detail.component.ts | 29 +++++-- .../app/pages/projects/apps/authmethods.ts | 79 +++++++++++++++++-- console/src/assets/i18n/de.json | 7 +- console/src/assets/i18n/en.json | 7 +- console/src/assets/i18n/es.json | 7 +- console/src/assets/i18n/fr.json | 7 +- console/src/assets/i18n/it.json | 7 +- console/src/assets/i18n/ja.json | 7 +- console/src/assets/i18n/pl.json | 7 +- console/src/assets/i18n/zh.json | 7 +- .../e2e/applications/applications.cy.ts | 36 ++++++++- 16 files changed, 212 insertions(+), 46 deletions(-) diff --git a/console/src/app/modules/app-radio/app-auth-method-radio/app-auth-method-radio.component.html b/console/src/app/modules/app-radio/app-auth-method-radio/app-auth-method-radio.component.html index 5ab289448d..08b278854d 100644 --- a/console/src/app/modules/app-radio/app-auth-method-radio/app-auth-method-radio.component.html +++ b/console/src/app/modules/app-radio/app-auth-method-radio/app-auth-method-radio.component.html @@ -30,19 +30,23 @@
- {{ 'APP.OIDC.RESPONSETYPE' | translate }} + {{ 'APP.OIDC.RESPONSETYPE' | translate }} {{ 'APP.OIDC.RESPONSE.' + method.responseType.toString() | translate }}
- {{ 'APP.GRANT' | translate }} - {{ 'APP.OIDC.GRANT.' + method.grantType.toString() | translate }} + {{ 'APP.GRANT' | translate }} + {{ + 'APP.OIDC.GRANT.' + grant.toString() | translate + }}
- {{ 'APP.AUTHMETHOD' | translate }} + {{ 'APP.AUTHMETHOD' | translate }} {{ 'APP.OIDC.AUTHMETHOD.' + method.authMethod.toString() | translate }}
- {{ 'APP.AUTHMETHOD' | translate }} + {{ 'APP.AUTHMETHOD' | translate }} {{ 'APP.API.AUTHMETHOD.' + method.apiAuthMethod.toString() | translate }}
diff --git a/console/src/app/modules/app-radio/app-auth-method-radio/app-auth-method-radio.component.scss b/console/src/app/modules/app-radio/app-auth-method-radio/app-auth-method-radio.component.scss index f7ddc2d147..0abe726237 100644 --- a/console/src/app/modules/app-radio/app-auth-method-radio/app-auth-method-radio.component.scss +++ b/console/src/app/modules/app-radio/app-auth-method-radio/app-auth-method-radio.component.scss @@ -155,7 +155,11 @@ white-space: nowrap; } - :first-child { + .space { + margin-left: 0.5rem; + } + + .row-entry { margin-right: 1rem; overflow: hidden; text-overflow: ellipsis; diff --git a/console/src/app/modules/app-radio/app-auth-method-radio/app-auth-method-radio.component.ts b/console/src/app/modules/app-radio/app-auth-method-radio/app-auth-method-radio.component.ts index 48d232850c..4e9f64ab10 100644 --- a/console/src/app/modules/app-radio/app-auth-method-radio/app-auth-method-radio.component.ts +++ b/console/src/app/modules/app-radio/app-auth-method-radio/app-auth-method-radio.component.ts @@ -14,7 +14,7 @@ export interface RadioItemAuthType { prefix: string; background: string; responseType?: OIDCResponseType; - grantType?: OIDCGrantType; + grantType?: OIDCGrantType[]; authMethod?: OIDCAuthMethodType; apiAuthMethod?: APIAuthMethodType; recommended?: boolean; diff --git a/console/src/app/pages/projects/apps/app-create/app-create.component.html b/console/src/app/pages/projects/apps/app-create/app-create.component.html index 5e50ecb383..ab75a2c8f6 100644 --- a/console/src/app/pages/projects/apps/app-create/app-create.component.html +++ b/console/src/app/pages/projects/apps/app-create/app-create.component.html @@ -58,13 +58,9 @@ - + @@ -93,9 +89,11 @@ - - + {{ 'APP.OIDC.REDIRECTSECTION' | translate }}

{{ 'APP.OIDC.REDIRECTTITLE' | translate }}

@@ -431,7 +429,13 @@ -
+
{ beforeEach(() => { @@ -17,15 +18,15 @@ describe('applications', () => { beforeEach(`ensure it doesn't exist already`, () => { cy.get('@ctx').then((ctx) => { cy.get('@projectId').then((projectId) => { - ensureProjectResourceDoesntExist(ctx.api, projectId, Apps, testAppName); + ensureProjectResourceDoesntExist(ctx.api, projectId, Apps, testPKCEAppName); cy.visit(`/projects/${projectId}`); }); }); }); - it('add app', () => { + it('add web pkce app', () => { cy.get('[data-e2e="app-card-add"]').should('be.visible').click(); - cy.get('[formcontrolname="name"]').focus().type(testAppName); + cy.get('[formcontrolname="name"]').focus().type(testPKCEAppName); cy.get('[for="WEB"]').click(); cy.get('[data-e2e="continue-button-nameandtype"]').click(); cy.get('[for="PKCE"]').should('be.visible').click(); @@ -43,6 +44,33 @@ describe('applications', () => { }); }); + describe('add native device code app', () => { + beforeEach(`ensure it doesn't exist already`, () => { + cy.get('@ctx').then((ctx) => { + cy.get('@projectId').then((projectId) => { + ensureProjectResourceDoesntExist(ctx.api, projectId, Apps, testDEVICECODEAppName); + cy.visit(`/projects/${projectId}`); + }); + }); + }); + + it('add device code app', () => { + cy.get('[data-e2e="app-card-add"]').should('be.visible').click(); + cy.get('[formcontrolname="name"]').focus().type(testDEVICECODEAppName); + cy.get('[for="N"]').click(); + cy.get('[data-e2e="continue-button-nameandtype"]').click(); + cy.get('[for="DEVICECODE"]').should('be.visible').click(); + cy.get('[data-e2e="continue-button-authmethod"]').click(); + cy.get('[data-e2e="create-button"]').click(); + cy.get('[id*=overlay]').should('exist'); + cy.shouldConfirmSuccess(); + const expectClientId = new RegExp(`^.*[0-9]+\\@${testProjectName}.*$`); + cy.get('[data-e2e="client-id-copy"]').click(); + cy.contains('[data-e2e="client-id"]', expectClientId); + cy.clipboardMatches(expectClientId); + }); + }); + describe('edit app', () => { it('should configure an application to enable dev mode'); it('should configure an application to put user roles and info inside id token'); From 5a3d09d3a30000ddfbacd14340ec9768a7dd3e5a Mon Sep 17 00:00:00 2001 From: adlerhurst Date: Thu, 11 May 2023 10:31:33 +0200 Subject: [PATCH 04/16] fix(eventstore): use creation_date for existing columns --- cmd/setup/11.sql | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cmd/setup/11.sql b/cmd/setup/11.sql index 28b49e4747..39f3e8cb02 100644 --- a/cmd/setup/11.sql +++ b/cmd/setup/11.sql @@ -1 +1,5 @@ -ALTER TABLE eventstore.events ADD COLUMN created_at TIMESTAMPTZ /*NOT NULL*/ DEFAULT clock_timestamp(); \ No newline at end of file +ALTER TABLE eventstore.events ADD COLUMN created_at TIMESTAMPTZ; + +UPDATE eventstore.events SET created_at = creation_date WHERE created_at IS NULL; + +ALTER TABLE eventstore.events ALTER COLUMN created_at SET NOT NULL DEFAULT clock_timestamp(); \ No newline at end of file From c07411e3142a0a88866d31536208336cee0d5864 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Thu, 11 May 2023 10:58:35 +0200 Subject: [PATCH 05/16] fix: only reuse port for integration tests (#5817) * fix: only reuse port for integration tests * exclude default listenConfig from integration build --- cmd/start/start.go | 13 +------------ cmd/start/start_port.go | 11 +++++++++++ cmd/start/start_port_integration.go | 25 +++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 12 deletions(-) create mode 100644 cmd/start/start_port.go create mode 100644 cmd/start/start_port_integration.go diff --git a/cmd/start/start.go b/cmd/start/start.go index 4ae1e15b08..a191998e1e 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -6,7 +6,6 @@ import ( _ "embed" "fmt" "math" - "net" "net/http" "os" "os/signal" @@ -22,7 +21,6 @@ import ( "github.com/zitadel/saml/pkg/provider" "golang.org/x/net/http2" "golang.org/x/net/http2/h2c" - "golang.org/x/sys/unix" "github.com/zitadel/zitadel/cmd/key" cmd_tls "github.com/zitadel/zitadel/cmd/tls" @@ -392,20 +390,11 @@ func startAPIs( return nil } -func reusePort(network, address string, conn syscall.RawConn) error { - return conn.Control(func(descriptor uintptr) { - err := syscall.SetsockoptInt(int(descriptor), syscall.SOL_SOCKET, unix.SO_REUSEPORT, 1) - if err != nil { - panic(err) - } - }) -} - func listen(ctx context.Context, router *mux.Router, port uint16, tlsConfig *tls.Config, shutdown <-chan os.Signal) error { http2Server := &http2.Server{} http1Server := &http.Server{Handler: h2c.NewHandler(router, http2Server), TLSConfig: tlsConfig} - lc := &net.ListenConfig{Control: reusePort} + lc := listenConfig() lis, err := lc.Listen(ctx, "tcp", fmt.Sprintf(":%d", port)) if err != nil { return fmt.Errorf("tcp listener on %d failed: %w", port, err) diff --git a/cmd/start/start_port.go b/cmd/start/start_port.go new file mode 100644 index 0000000000..bb60fea250 --- /dev/null +++ b/cmd/start/start_port.go @@ -0,0 +1,11 @@ +//go:build !integration + +package start + +import ( + "net" +) + +func listenConfig() *net.ListenConfig { + return &net.ListenConfig{} +} diff --git a/cmd/start/start_port_integration.go b/cmd/start/start_port_integration.go new file mode 100644 index 0000000000..1c5763d6e9 --- /dev/null +++ b/cmd/start/start_port_integration.go @@ -0,0 +1,25 @@ +//go:build integration + +package start + +import ( + "net" + "syscall" + + "golang.org/x/sys/unix" +) + +func listenConfig() *net.ListenConfig { + return &net.ListenConfig{ + Control: reusePort, + } +} + +func reusePort(network, address string, conn syscall.RawConn) error { + return conn.Control(func(descriptor uintptr) { + err := syscall.SetsockoptInt(int(descriptor), syscall.SOL_SOCKET, unix.SO_REUSEPORT, 1) + if err != nil { + panic(err) + } + }) +} From b9a3fac3d2f48a6e924ebddc6681e25821209e18 Mon Sep 17 00:00:00 2001 From: adlerhurst Date: Thu, 11 May 2023 11:14:42 +0200 Subject: [PATCH 06/16] fix(eventstore): backfill column --- cmd/setup/11.sql | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/cmd/setup/11.sql b/cmd/setup/11.sql index 39f3e8cb02..bb62b5f9b2 100644 --- a/cmd/setup/11.sql +++ b/cmd/setup/11.sql @@ -1,5 +1,15 @@ -ALTER TABLE eventstore.events ADD COLUMN created_at TIMESTAMPTZ; +BEGIN; +-- create table with empty created_at +ALTER TABLE eventstore.events ADD COLUMN created_at TIMESTAMPTZ DEFAULT NULL; +COMMIT; +BEGIN; +-- backfill created_at UPDATE eventstore.events SET created_at = creation_date WHERE created_at IS NULL; +COMMIT; -ALTER TABLE eventstore.events ALTER COLUMN created_at SET NOT NULL DEFAULT clock_timestamp(); \ No newline at end of file +BEGIN; +-- set column rules +ALTER TABLE eventstore.events ALTER COLUMN created_at SET DEFAULT clock_timestamp(); +ALTER TABLE eventstore.events ALTER COLUMN created_at SET NOT NULL; +COMMIT; \ No newline at end of file From 8d13f170e84ea7b25c001bf014f92617b6d549f8 Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Thu, 11 May 2023 11:23:40 +0200 Subject: [PATCH 07/16] feat(api): new settings service (#5775) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add v2alpha policies service * feat: add v2alpha policies service * fix: rename of attributes and messages in v2alpha api * fix: rename of attributes and messages in v2alpha api * fix: linter corrections * fix: review corrections * fix: review corrections * fix: review corrections * fix: review corrections * fix grpc * refactor: rename to settings and more * Apply suggestions from code review Co-authored-by: Fabi * add service to docs and rename legal settings * unit tests for converters * go mod tidy * ensure idp name and return list details * fix: use correct resource owner for active idps * change query to join --------- Co-authored-by: Livio Spring Co-authored-by: Fabi Co-authored-by: Tim Möhlmann --- build/zitadel/generate-grpc.sh | 12 + cmd/start/start.go | 4 + docs/docusaurus.config.js | 7 + docs/sidebars.js | 14 + internal/api/grpc/object/v2/converter.go | 13 + internal/api/grpc/settings/v2/server.go | 57 +++ internal/api/grpc/settings/v2/settings.go | 129 +++++ .../grpc/settings/v2/settings_converter.go | 189 +++++++ .../settings/v2/settings_converter_test.go | 461 ++++++++++++++++++ internal/domain/idp.go | 35 ++ internal/domain/policy_login.go | 27 +- internal/query/idp_login_policy_link.go | 48 +- internal/query/idp_login_policy_link_test.go | 24 +- proto/zitadel/object/v2alpha/object.proto | 7 + .../settings/v2alpha/branding_settings.proto | 81 +++ .../settings/v2alpha/domain_settings.proto | 33 ++ .../settings/v2alpha/legal_settings.proto | 40 ++ .../settings/v2alpha/lockout_settings.proto | 23 + .../settings/v2alpha/login_settings.proto | 143 ++++++ .../settings/v2alpha/password_settings.proto | 43 ++ proto/zitadel/settings/v2alpha/settings.proto | 13 + .../settings/v2alpha/settings_service.proto | 356 ++++++++++++++ 22 files changed, 1720 insertions(+), 39 deletions(-) create mode 100644 internal/api/grpc/settings/v2/server.go create mode 100644 internal/api/grpc/settings/v2/settings.go create mode 100644 internal/api/grpc/settings/v2/settings_converter.go create mode 100644 internal/api/grpc/settings/v2/settings_converter_test.go create mode 100644 proto/zitadel/settings/v2alpha/branding_settings.proto create mode 100644 proto/zitadel/settings/v2alpha/domain_settings.proto create mode 100644 proto/zitadel/settings/v2alpha/legal_settings.proto create mode 100644 proto/zitadel/settings/v2alpha/lockout_settings.proto create mode 100644 proto/zitadel/settings/v2alpha/login_settings.proto create mode 100644 proto/zitadel/settings/v2alpha/password_settings.proto create mode 100644 proto/zitadel/settings/v2alpha/settings.proto create mode 100644 proto/zitadel/settings/v2alpha/settings_service.proto diff --git a/build/zitadel/generate-grpc.sh b/build/zitadel/generate-grpc.sh index 891125cb6a..865952fb66 100755 --- a/build/zitadel/generate-grpc.sh +++ b/build/zitadel/generate-grpc.sh @@ -96,4 +96,16 @@ protoc \ --validate_out=lang=go:${GOPATH}/src \ ${PROTO_PATH}/session/v2alpha/session_service.proto +protoc \ + -I=/proto/include \ + --grpc-gateway_out ${GOPATH}/src \ + --grpc-gateway_opt logtostderr=true \ + --grpc-gateway_opt allow_delete_body=true \ + --openapiv2_out ${OPENAPI_PATH} \ + --openapiv2_opt logtostderr=true \ + --openapiv2_opt allow_delete_body=true \ + --zitadel_out=${GOPATH}/src \ + --validate_out=lang=go:${GOPATH}/src \ + ${PROTO_PATH}/settings/v2alpha/settings_service.proto + echo "done generating grpc" diff --git a/cmd/start/start.go b/cmd/start/start.go index a191998e1e..0d2d973690 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -33,6 +33,7 @@ import ( "github.com/zitadel/zitadel/internal/api/grpc/auth" "github.com/zitadel/zitadel/internal/api/grpc/management" "github.com/zitadel/zitadel/internal/api/grpc/session/v2" + "github.com/zitadel/zitadel/internal/api/grpc/settings/v2" "github.com/zitadel/zitadel/internal/api/grpc/system" "github.com/zitadel/zitadel/internal/api/grpc/user/v2" http_util "github.com/zitadel/zitadel/internal/api/http" @@ -337,6 +338,9 @@ func startAPIs( if err := apis.RegisterService(ctx, session.CreateServer(commands, queries, permissionCheck)); err != nil { return err } + if err := apis.RegisterService(ctx, settings.CreateServer(commands, queries, config.ExternalSecure)); err != nil { + return err + } instanceInterceptor := middleware.InstanceInterceptor(queries, config.HTTP1HostHeader, login.IgnoreInstanceEndpoints...) assetsCache := middleware.AssetsCacheInterceptor(config.AssetStorage.Cache.MaxAge, config.AssetStorage.Cache.SharedMaxAge) apis.RegisterHandlerOnPrefix(assets.HandlerPrefix, assets.NewHandler(commands, verifier, config.InternalAuthZ, id.SonyFlakeGenerator(), store, queries, middleware.CallDurationHandler, instanceInterceptor.Handler, assetsCache.Handler, limitingAccessInterceptor.Handle)) diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 5ed0a099d8..409f3905e3 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -273,6 +273,13 @@ module.exports = { sidebarOptions: { groupPathsBy: "tag", }, + }, + settings: { + specPath: ".artifacts/openapi/zitadel/settings/v2alpha/settings_service.swagger.json", + outputDir: "docs/apis/settings_service", + sidebarOptions: { + groupPathsBy: "tag", + }, } } }, diff --git a/docs/sidebars.js b/docs/sidebars.js index 25e84b4b91..54ca65bb18 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -426,6 +426,20 @@ module.exports = { }, items: require("./docs/apis/session_service/sidebar.js"), }, + { + type: "category", + label: "Settings Lifecycle (Alpha)", + link: { + type: "generated-index", + title: "Settings Service API (Alpha)", + slug: "/apis/settings_service", + description: + "This API is intended to manage settings in a ZITADEL instance.\n"+ + "\n"+ + "This project is in alpha state. It can AND will continue breaking until the services provide the same functionality as the current login.", + }, + items: require("./docs/apis/settings_service/sidebar.js"), + }, { type: "category", label: "Assets", diff --git a/internal/api/grpc/object/v2/converter.go b/internal/api/grpc/object/v2/converter.go index feb8dbd62a..6c03e79a8c 100644 --- a/internal/api/grpc/object/v2/converter.go +++ b/internal/api/grpc/object/v2/converter.go @@ -1,8 +1,11 @@ package object import ( + "context" + "google.golang.org/protobuf/types/known/timestamppb" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha" @@ -36,3 +39,13 @@ func ListQueryToQuery(query *object.ListQuery) (offset, limit uint64, asc bool) } return query.Offset, uint64(query.Limit), query.Asc } + +func ResourceOwnerFromReq(ctx context.Context, req *object.RequestContext) string { + if req.GetInstance() { + return authz.GetInstance(ctx).InstanceID() + } + if req.GetOrgId() != "" { + return req.GetOrgId() + } + return authz.GetCtxData(ctx).OrgID +} diff --git a/internal/api/grpc/settings/v2/server.go b/internal/api/grpc/settings/v2/server.go new file mode 100644 index 0000000000..ea6ebd4a25 --- /dev/null +++ b/internal/api/grpc/settings/v2/server.go @@ -0,0 +1,57 @@ +package settings + +import ( + "context" + + "google.golang.org/grpc" + + "github.com/zitadel/zitadel/internal/api/assets" + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/grpc/server" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/query" + settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2alpha" +) + +var _ settings.SettingsServiceServer = (*Server)(nil) + +type Server struct { + settings.UnimplementedSettingsServiceServer + command *command.Commands + query *query.Queries + assetsAPIDomain func(context.Context) string +} + +type Config struct{} + +func CreateServer( + command *command.Commands, + query *query.Queries, + externalSecure bool, +) *Server { + return &Server{ + command: command, + query: query, + assetsAPIDomain: assets.AssetAPI(externalSecure), + } +} + +func (s *Server) RegisterServer(grpcServer *grpc.Server) { + settings.RegisterSettingsServiceServer(grpcServer, s) +} + +func (s *Server) AppName() string { + return settings.SettingsService_ServiceDesc.ServiceName +} + +func (s *Server) MethodPrefix() string { + return settings.SettingsService_ServiceDesc.ServiceName +} + +func (s *Server) AuthMethods() authz.MethodMapping { + return settings.SettingsService_AuthMethods +} + +func (s *Server) RegisterGateway() server.RegisterGatewayFunc { + return settings.RegisterSettingsServiceHandler +} diff --git a/internal/api/grpc/settings/v2/settings.go b/internal/api/grpc/settings/v2/settings.go new file mode 100644 index 0000000000..bf00d8084c --- /dev/null +++ b/internal/api/grpc/settings/v2/settings.go @@ -0,0 +1,129 @@ +package settings + +import ( + "context" + + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" + "github.com/zitadel/zitadel/internal/api/grpc/text" + "github.com/zitadel/zitadel/internal/query" + object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha" + "github.com/zitadel/zitadel/pkg/grpc/settings/v2alpha" +) + +func (s *Server) GetLoginSettings(ctx context.Context, req *settings.GetLoginSettingsRequest) (*settings.GetLoginSettingsResponse, error) { + current, err := s.query.LoginPolicyByID(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) + if err != nil { + return nil, err + } + return &settings.GetLoginSettingsResponse{ + Settings: loginSettingsToPb(current), + Details: &object_pb.Details{ + Sequence: current.Sequence, + ChangeDate: timestamppb.New(current.ChangeDate), + ResourceOwner: current.OrgID, + }, + }, nil +} + +func (s *Server) GetPasswordComplexitySettings(ctx context.Context, req *settings.GetPasswordComplexitySettingsRequest) (*settings.GetPasswordComplexitySettingsResponse, error) { + current, err := s.query.PasswordComplexityPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) + if err != nil { + return nil, err + } + return &settings.GetPasswordComplexitySettingsResponse{ + Settings: passwordSettingsToPb(current), + Details: &object_pb.Details{ + Sequence: current.Sequence, + ChangeDate: timestamppb.New(current.ChangeDate), + ResourceOwner: current.ResourceOwner, + }, + }, nil +} + +func (s *Server) GetBrandingSettings(ctx context.Context, req *settings.GetBrandingSettingsRequest) (*settings.GetBrandingSettingsResponse, error) { + current, err := s.query.ActiveLabelPolicyByOrg(ctx, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) + if err != nil { + return nil, err + } + return &settings.GetBrandingSettingsResponse{ + Settings: brandingSettingsToPb(current, s.assetsAPIDomain(ctx)), + Details: &object_pb.Details{ + Sequence: current.Sequence, + ChangeDate: timestamppb.New(current.ChangeDate), + ResourceOwner: current.ResourceOwner, + }, + }, nil +} + +func (s *Server) GetDomainSettings(ctx context.Context, req *settings.GetDomainSettingsRequest) (*settings.GetDomainSettingsResponse, error) { + current, err := s.query.DomainPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) + if err != nil { + return nil, err + } + return &settings.GetDomainSettingsResponse{ + Settings: domainSettingsToPb(current), + Details: &object_pb.Details{ + Sequence: current.Sequence, + ChangeDate: timestamppb.New(current.ChangeDate), + ResourceOwner: current.ResourceOwner, + }, + }, nil +} + +func (s *Server) GetLegalAndSupportSettings(ctx context.Context, req *settings.GetLegalAndSupportSettingsRequest) (*settings.GetLegalAndSupportSettingsResponse, error) { + current, err := s.query.PrivacyPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) + if err != nil { + return nil, err + } + return &settings.GetLegalAndSupportSettingsResponse{ + Settings: legalAndSupportSettingsToPb(current), + Details: &object_pb.Details{ + Sequence: current.Sequence, + ChangeDate: timestamppb.New(current.ChangeDate), + ResourceOwner: current.ResourceOwner, + }, + }, nil +} + +func (s *Server) GetLockoutSettings(ctx context.Context, req *settings.GetLockoutSettingsRequest) (*settings.GetLockoutSettingsResponse, error) { + current, err := s.query.LockoutPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) + if err != nil { + return nil, err + } + return &settings.GetLockoutSettingsResponse{ + Settings: lockoutSettingsToPb(current), + Details: &object_pb.Details{ + Sequence: current.Sequence, + ChangeDate: timestamppb.New(current.ChangeDate), + ResourceOwner: current.ResourceOwner, + }, + }, nil +} + +func (s *Server) GetActiveIdentityProviders(ctx context.Context, req *settings.GetActiveIdentityProvidersRequest) (*settings.GetActiveIdentityProvidersResponse, error) { + links, err := s.query.IDPLoginPolicyLinks(ctx, object.ResourceOwnerFromReq(ctx, req.GetCtx()), &query.IDPLoginPolicyLinksSearchQuery{}, false) + if err != nil { + return nil, err + } + + return &settings.GetActiveIdentityProvidersResponse{ + Details: object.ToListDetails(links.SearchResponse), + IdentityProviders: identityProvidersToPb(links.Links), + }, nil +} + +func (s *Server) GetGeneralSettings(ctx context.Context, _ *settings.GetGeneralSettingsRequest) (*settings.GetGeneralSettingsResponse, error) { + langs, err := s.query.Languages(ctx) + if err != nil { + return nil, err + } + instance := authz.GetInstance(ctx) + return &settings.GetGeneralSettingsResponse{ + SupportedLanguages: text.LanguageTagsToStrings(langs), + DefaultOrgId: instance.DefaultOrganisationID(), + DefaultLanguage: instance.DefaultLanguage().String(), + }, nil +} diff --git a/internal/api/grpc/settings/v2/settings_converter.go b/internal/api/grpc/settings/v2/settings_converter.go new file mode 100644 index 0000000000..42ba4767c4 --- /dev/null +++ b/internal/api/grpc/settings/v2/settings_converter.go @@ -0,0 +1,189 @@ +package settings + +import ( + "google.golang.org/protobuf/types/known/durationpb" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2alpha" +) + +// TODO: ? +func loginSettingsToPb(current *query.LoginPolicy) *settings.LoginSettings { + multi := make([]settings.MultiFactorType, len(current.MultiFactors)) + for i, typ := range current.MultiFactors { + multi[i] = multiFactorTypeToPb(typ) + } + second := make([]settings.SecondFactorType, len(current.SecondFactors)) + for i, typ := range current.SecondFactors { + second[i] = secondFactorTypeToPb(typ) + } + + return &settings.LoginSettings{ + AllowUsernamePassword: current.AllowUsernamePassword, + AllowRegister: current.AllowRegister, + AllowExternalIdp: current.AllowExternalIDPs, + ForceMfa: current.ForceMFA, + PasskeysType: passkeysTypeToPb(current.PasswordlessType), + HidePasswordReset: current.HidePasswordReset, + IgnoreUnknownUsernames: current.IgnoreUnknownUsernames, + AllowDomainDiscovery: current.AllowDomainDiscovery, + DisableLoginWithEmail: current.DisableLoginWithEmail, + DisableLoginWithPhone: current.DisableLoginWithPhone, + DefaultRedirectUri: current.DefaultRedirectURI, + PasswordCheckLifetime: durationpb.New(current.PasswordCheckLifetime), + ExternalLoginCheckLifetime: durationpb.New(current.ExternalLoginCheckLifetime), + MfaInitSkipLifetime: durationpb.New(current.MFAInitSkipLifetime), + SecondFactorCheckLifetime: durationpb.New(current.SecondFactorCheckLifetime), + MultiFactorCheckLifetime: durationpb.New(current.MultiFactorCheckLifetime), + SecondFactors: second, + MultiFactors: multi, + ResourceOwnerType: isDefaultToResourceOwnerTypePb(current.IsDefault), + } +} + +func isDefaultToResourceOwnerTypePb(isDefault bool) settings.ResourceOwnerType { + if isDefault { + return settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE + } + return settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_ORG +} + +func passkeysTypeToPb(passwordlessType domain.PasswordlessType) settings.PasskeysType { + switch passwordlessType { + case domain.PasswordlessTypeAllowed: + return settings.PasskeysType_PASSKEYS_TYPE_ALLOWED + case domain.PasswordlessTypeNotAllowed: + return settings.PasskeysType_PASSKEYS_TYPE_NOT_ALLOWED + default: + return settings.PasskeysType_PASSKEYS_TYPE_NOT_ALLOWED + } +} + +func secondFactorTypeToPb(secondFactorType domain.SecondFactorType) settings.SecondFactorType { + switch secondFactorType { + case domain.SecondFactorTypeOTP: + return settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP + case domain.SecondFactorTypeU2F: + return settings.SecondFactorType_SECOND_FACTOR_TYPE_U2F + case domain.SecondFactorTypeUnspecified: + return settings.SecondFactorType_SECOND_FACTOR_TYPE_UNSPECIFIED + default: + return settings.SecondFactorType_SECOND_FACTOR_TYPE_UNSPECIFIED + } +} + +func multiFactorTypeToPb(typ domain.MultiFactorType) settings.MultiFactorType { + switch typ { + case domain.MultiFactorTypeU2FWithPIN: + return settings.MultiFactorType_MULTI_FACTOR_TYPE_U2F_WITH_VERIFICATION + case domain.MultiFactorTypeUnspecified: + return settings.MultiFactorType_MULTI_FACTOR_TYPE_UNSPECIFIED + default: + return settings.MultiFactorType_MULTI_FACTOR_TYPE_UNSPECIFIED + } +} + +func passwordSettingsToPb(current *query.PasswordComplexityPolicy) *settings.PasswordComplexitySettings { + return &settings.PasswordComplexitySettings{ + MinLength: current.MinLength, + RequiresUppercase: current.HasUppercase, + RequiresLowercase: current.HasLowercase, + RequiresNumber: current.HasNumber, + RequiresSymbol: current.HasSymbol, + ResourceOwnerType: isDefaultToResourceOwnerTypePb(current.IsDefault), + } +} + +func brandingSettingsToPb(current *query.LabelPolicy, assetPrefix string) *settings.BrandingSettings { + return &settings.BrandingSettings{ + LightTheme: themeToPb(current.Light, assetPrefix, current.ResourceOwner), + DarkTheme: themeToPb(current.Dark, assetPrefix, current.ResourceOwner), + FontUrl: domain.AssetURL(assetPrefix, current.ResourceOwner, current.FontURL), + DisableWatermark: current.WatermarkDisabled, + HideLoginNameSuffix: current.HideLoginNameSuffix, + ResourceOwnerType: isDefaultToResourceOwnerTypePb(current.IsDefault), + } +} + +func themeToPb(theme query.Theme, assetPrefix, resourceOwner string) *settings.Theme { + return &settings.Theme{ + PrimaryColor: theme.PrimaryColor, + BackgroundColor: theme.BackgroundColor, + FontColor: theme.FontColor, + WarnColor: theme.WarnColor, + LogoUrl: domain.AssetURL(assetPrefix, resourceOwner, theme.LogoURL), + IconUrl: domain.AssetURL(assetPrefix, resourceOwner, theme.IconURL), + } +} + +func domainSettingsToPb(current *query.DomainPolicy) *settings.DomainSettings { + return &settings.DomainSettings{ + LoginNameIncludesDomain: current.UserLoginMustBeDomain, + RequireOrgDomainVerification: current.ValidateOrgDomains, + SmtpSenderAddressMatchesInstanceDomain: current.SMTPSenderAddressMatchesInstanceDomain, + ResourceOwnerType: isDefaultToResourceOwnerTypePb(current.IsDefault), + } +} + +func legalAndSupportSettingsToPb(current *query.PrivacyPolicy) *settings.LegalAndSupportSettings { + return &settings.LegalAndSupportSettings{ + TosLink: current.TOSLink, + PrivacyPolicyLink: current.PrivacyLink, + HelpLink: current.HelpLink, + SupportEmail: string(current.SupportEmail), + ResourceOwnerType: isDefaultToResourceOwnerTypePb(current.IsDefault), + } +} + +func lockoutSettingsToPb(current *query.LockoutPolicy) *settings.LockoutSettings { + return &settings.LockoutSettings{ + MaxPasswordAttempts: current.MaxPasswordAttempts, + ResourceOwnerType: isDefaultToResourceOwnerTypePb(current.IsDefault), + } +} + +func identityProvidersToPb(idps []*query.IDPLoginPolicyLink) []*settings.IdentityProvider { + providers := make([]*settings.IdentityProvider, len(idps)) + for i, idp := range idps { + providers[i] = identityProviderToPb(idp) + } + return providers +} + +func identityProviderToPb(idp *query.IDPLoginPolicyLink) *settings.IdentityProvider { + return &settings.IdentityProvider{ + Id: idp.IDPID, + Name: domain.IDPName(idp.IDPName, idp.IDPType), + Type: idpTypeToPb(idp.IDPType), + } +} + +func idpTypeToPb(idpType domain.IDPType) settings.IdentityProviderType { + switch idpType { + case domain.IDPTypeUnspecified: + return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_UNSPECIFIED + case domain.IDPTypeOIDC: + return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_OIDC + case domain.IDPTypeJWT: + return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_JWT + case domain.IDPTypeOAuth: + return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_OAUTH + case domain.IDPTypeLDAP: + return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_LDAP + case domain.IDPTypeAzureAD: + return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_AZURE_AD + case domain.IDPTypeGitHub: + return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GITHUB + case domain.IDPTypeGitHubEnterprise: + return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GITHUB_ES + case domain.IDPTypeGitLab: + return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GITLAB + case domain.IDPTypeGitLabSelfHosted: + return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GITLAB_SELF_HOSTED + case domain.IDPTypeGoogle: + return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GOOGLE + default: + return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_UNSPECIFIED + } +} diff --git a/internal/api/grpc/settings/v2/settings_converter_test.go b/internal/api/grpc/settings/v2/settings_converter_test.go new file mode 100644 index 0000000000..818bf64ffe --- /dev/null +++ b/internal/api/grpc/settings/v2/settings_converter_test.go @@ -0,0 +1,461 @@ +package settings + +import ( + "reflect" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/known/durationpb" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2alpha" +) + +var ignoreMessageTypes = map[protoreflect.FullName]bool{ + "google.protobuf.Duration": true, +} + +// allFieldsSet recusively checks if all values in a message +// have a non-zero value. +func allFieldsSet(t testing.TB, msg protoreflect.Message) { + md := msg.Descriptor() + name := md.FullName() + if ignoreMessageTypes[name] { + return + } + + fields := md.Fields() + + for i := 0; i < fields.Len(); i++ { + fd := fields.Get(i) + if !msg.Has(fd) { + t.Errorf("not all fields set in %q, missing %q", name, fd.Name()) + continue + } + + if fd.Kind() == protoreflect.MessageKind { + allFieldsSet(t, msg.Get(fd).Message()) + } + } +} + +func Test_loginSettingsToPb(t *testing.T) { + arg := &query.LoginPolicy{ + AllowUsernamePassword: true, + AllowRegister: true, + AllowExternalIDPs: true, + ForceMFA: true, + PasswordlessType: domain.PasswordlessTypeAllowed, + HidePasswordReset: true, + IgnoreUnknownUsernames: true, + AllowDomainDiscovery: true, + DisableLoginWithEmail: true, + DisableLoginWithPhone: true, + DefaultRedirectURI: "example.com", + PasswordCheckLifetime: time.Hour, + ExternalLoginCheckLifetime: time.Minute, + MFAInitSkipLifetime: time.Millisecond, + SecondFactorCheckLifetime: time.Microsecond, + MultiFactorCheckLifetime: time.Nanosecond, + SecondFactors: []domain.SecondFactorType{ + domain.SecondFactorTypeOTP, + domain.SecondFactorTypeU2F, + }, + MultiFactors: []domain.MultiFactorType{ + domain.MultiFactorTypeU2FWithPIN, + }, + IsDefault: true, + } + + want := &settings.LoginSettings{ + AllowUsernamePassword: true, + AllowRegister: true, + AllowExternalIdp: true, + ForceMfa: true, + PasskeysType: settings.PasskeysType_PASSKEYS_TYPE_ALLOWED, + HidePasswordReset: true, + IgnoreUnknownUsernames: true, + AllowDomainDiscovery: true, + DisableLoginWithEmail: true, + DisableLoginWithPhone: true, + DefaultRedirectUri: "example.com", + PasswordCheckLifetime: durationpb.New(time.Hour), + ExternalLoginCheckLifetime: durationpb.New(time.Minute), + MfaInitSkipLifetime: durationpb.New(time.Millisecond), + SecondFactorCheckLifetime: durationpb.New(time.Microsecond), + MultiFactorCheckLifetime: durationpb.New(time.Nanosecond), + SecondFactors: []settings.SecondFactorType{ + settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP, + settings.SecondFactorType_SECOND_FACTOR_TYPE_U2F, + }, + MultiFactors: []settings.MultiFactorType{ + settings.MultiFactorType_MULTI_FACTOR_TYPE_U2F_WITH_VERIFICATION, + }, + ResourceOwnerType: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE, + } + + got := loginSettingsToPb(arg) + allFieldsSet(t, got.ProtoReflect()) + if !proto.Equal(got, want) { + t.Errorf("loginSettingsToPb() =\n%v\nwant\n%v", got, want) + } +} + +func Test_isDefaultToResourceOwnerTypePb(t *testing.T) { + type args struct { + isDefault bool + } + tests := []struct { + args args + want settings.ResourceOwnerType + }{ + { + args: args{false}, + want: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_ORG, + }, + { + args: args{true}, + want: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE, + }, + } + for _, tt := range tests { + t.Run(tt.want.String(), func(t *testing.T) { + got := isDefaultToResourceOwnerTypePb(tt.args.isDefault) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_passkeysTypeToPb(t *testing.T) { + type args struct { + passwordlessType domain.PasswordlessType + } + tests := []struct { + args args + want settings.PasskeysType + }{ + { + args: args{domain.PasswordlessTypeNotAllowed}, + want: settings.PasskeysType_PASSKEYS_TYPE_NOT_ALLOWED, + }, + { + args: args{domain.PasswordlessTypeAllowed}, + want: settings.PasskeysType_PASSKEYS_TYPE_ALLOWED, + }, + { + args: args{99}, + want: settings.PasskeysType_PASSKEYS_TYPE_NOT_ALLOWED, + }, + } + for _, tt := range tests { + t.Run(tt.want.String(), func(t *testing.T) { + got := passkeysTypeToPb(tt.args.passwordlessType) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_secondFactorTypeToPb(t *testing.T) { + type args struct { + secondFactorType domain.SecondFactorType + } + tests := []struct { + args args + want settings.SecondFactorType + }{ + { + args: args{domain.SecondFactorTypeOTP}, + want: settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP, + }, + { + args: args{domain.SecondFactorTypeU2F}, + want: settings.SecondFactorType_SECOND_FACTOR_TYPE_U2F, + }, + { + args: args{domain.SecondFactorTypeUnspecified}, + want: settings.SecondFactorType_SECOND_FACTOR_TYPE_UNSPECIFIED, + }, + { + args: args{99}, + want: settings.SecondFactorType_SECOND_FACTOR_TYPE_UNSPECIFIED, + }, + } + for _, tt := range tests { + t.Run(tt.want.String(), func(t *testing.T) { + got := secondFactorTypeToPb(tt.args.secondFactorType) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_multiFactorTypeToPb(t *testing.T) { + type args struct { + typ domain.MultiFactorType + } + tests := []struct { + args args + want settings.MultiFactorType + }{ + { + args: args{domain.MultiFactorTypeU2FWithPIN}, + want: settings.MultiFactorType_MULTI_FACTOR_TYPE_U2F_WITH_VERIFICATION, + }, + { + args: args{domain.MultiFactorTypeUnspecified}, + want: settings.MultiFactorType_MULTI_FACTOR_TYPE_UNSPECIFIED, + }, + { + args: args{99}, + want: settings.MultiFactorType_MULTI_FACTOR_TYPE_UNSPECIFIED, + }, + } + for _, tt := range tests { + t.Run(tt.want.String(), func(t *testing.T) { + got := multiFactorTypeToPb(tt.args.typ) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_passwordSettingsToPb(t *testing.T) { + arg := &query.PasswordComplexityPolicy{ + MinLength: 12, + HasUppercase: true, + HasLowercase: true, + HasNumber: true, + HasSymbol: true, + IsDefault: true, + } + want := &settings.PasswordComplexitySettings{ + MinLength: 12, + RequiresUppercase: true, + RequiresLowercase: true, + RequiresNumber: true, + RequiresSymbol: true, + ResourceOwnerType: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE, + } + + got := passwordSettingsToPb(arg) + allFieldsSet(t, got.ProtoReflect()) + if !proto.Equal(got, want) { + t.Errorf("passwordSettingsToPb() =\n%v\nwant\n%v", got, want) + } +} + +func Test_brandingSettingsToPb(t *testing.T) { + arg := &query.LabelPolicy{ + Light: query.Theme{ + PrimaryColor: "red", + WarnColor: "white", + BackgroundColor: "blue", + FontColor: "orange", + LogoURL: "light-logo", + IconURL: "light-icon", + }, + Dark: query.Theme{ + PrimaryColor: "magenta", + WarnColor: "pink", + BackgroundColor: "black", + FontColor: "white", + LogoURL: "dark-logo", + IconURL: "dark-icon", + }, + ResourceOwner: "me", + FontURL: "fonts", + WatermarkDisabled: true, + HideLoginNameSuffix: true, + IsDefault: true, + } + want := &settings.BrandingSettings{ + LightTheme: &settings.Theme{ + PrimaryColor: "red", + WarnColor: "white", + BackgroundColor: "blue", + FontColor: "orange", + LogoUrl: "http://example.com/me/light-logo", + IconUrl: "http://example.com/me/light-icon", + }, + DarkTheme: &settings.Theme{ + PrimaryColor: "magenta", + WarnColor: "pink", + BackgroundColor: "black", + FontColor: "white", + LogoUrl: "http://example.com/me/dark-logo", + IconUrl: "http://example.com/me/dark-icon", + }, + FontUrl: "http://example.com/me/fonts", + DisableWatermark: true, + HideLoginNameSuffix: true, + ResourceOwnerType: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE, + } + + got := brandingSettingsToPb(arg, "http://example.com") + allFieldsSet(t, got.ProtoReflect()) + if !proto.Equal(got, want) { + t.Errorf("brandingSettingsToPb() =\n%v\nwant\n%v", got, want) + } +} + +func Test_domainSettingsToPb(t *testing.T) { + arg := &query.DomainPolicy{ + UserLoginMustBeDomain: true, + ValidateOrgDomains: true, + SMTPSenderAddressMatchesInstanceDomain: true, + IsDefault: true, + } + want := &settings.DomainSettings{ + LoginNameIncludesDomain: true, + RequireOrgDomainVerification: true, + SmtpSenderAddressMatchesInstanceDomain: true, + ResourceOwnerType: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE, + } + got := domainSettingsToPb(arg) + allFieldsSet(t, got.ProtoReflect()) + if !proto.Equal(got, want) { + t.Errorf("domainSettingsToPb() =\n%v\nwant\n%v", got, want) + } +} + +func Test_legalSettingsToPb(t *testing.T) { + arg := &query.PrivacyPolicy{ + TOSLink: "http://example.com/tos", + PrivacyLink: "http://example.com/pricacy", + HelpLink: "http://example.com/help", + SupportEmail: "support@zitadel.com", + IsDefault: true, + } + want := &settings.LegalAndSupportSettings{ + TosLink: "http://example.com/tos", + PrivacyPolicyLink: "http://example.com/pricacy", + HelpLink: "http://example.com/help", + SupportEmail: "support@zitadel.com", + ResourceOwnerType: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE, + } + got := legalAndSupportSettingsToPb(arg) + allFieldsSet(t, got.ProtoReflect()) + if !proto.Equal(got, want) { + t.Errorf("legalSettingsToPb() =\n%v\nwant\n%v", got, want) + } +} + +func Test_lockoutSettingsToPb(t *testing.T) { + arg := &query.LockoutPolicy{ + MaxPasswordAttempts: 22, + IsDefault: true, + } + want := &settings.LockoutSettings{ + MaxPasswordAttempts: 22, + ResourceOwnerType: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE, + } + got := lockoutSettingsToPb(arg) + allFieldsSet(t, got.ProtoReflect()) + if !proto.Equal(got, want) { + t.Errorf("lockoutSettingsToPb() =\n%v\nwant\n%v", got, want) + } +} + +func Test_identityProvidersToPb(t *testing.T) { + arg := []*query.IDPLoginPolicyLink{ + { + IDPID: "1", + IDPName: "foo", + IDPType: domain.IDPTypeOIDC, + }, + { + IDPID: "2", + IDPName: "bar", + IDPType: domain.IDPTypeGitHub, + }, + } + want := []*settings.IdentityProvider{ + { + Id: "1", + Name: "foo", + Type: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_OIDC, + }, + { + Id: "2", + Name: "bar", + Type: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GITHUB, + }, + } + got := identityProvidersToPb(arg) + require.Len(t, got, len(got)) + for i, v := range got { + allFieldsSet(t, v.ProtoReflect()) + if !proto.Equal(v, want[i]) { + t.Errorf("identityProvidersToPb() =\n%v\nwant\n%v", got, want) + } + } +} + +func Test_idpTypeToPb(t *testing.T) { + type args struct { + idpType domain.IDPType + } + tests := []struct { + args args + want settings.IdentityProviderType + }{ + { + args: args{domain.IDPTypeUnspecified}, + want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_UNSPECIFIED, + }, + { + args: args{domain.IDPTypeOIDC}, + want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_OIDC, + }, + { + args: args{domain.IDPTypeJWT}, + want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_JWT, + }, + { + args: args{domain.IDPTypeOAuth}, + want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_OAUTH, + }, + { + args: args{domain.IDPTypeLDAP}, + want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_LDAP, + }, + { + args: args{domain.IDPTypeAzureAD}, + want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_AZURE_AD, + }, + { + args: args{domain.IDPTypeGitHub}, + want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GITHUB, + }, + { + args: args{domain.IDPTypeGitHubEnterprise}, + want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GITHUB_ES, + }, + { + args: args{domain.IDPTypeGitLab}, + want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GITLAB, + }, + { + args: args{domain.IDPTypeGitLabSelfHosted}, + want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GITLAB_SELF_HOSTED, + }, + { + args: args{domain.IDPTypeGoogle}, + want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GOOGLE, + }, + { + args: args{99}, + want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_UNSPECIFIED, + }, + } + for _, tt := range tests { + t.Run(tt.want.String(), func(t *testing.T) { + if got := idpTypeToPb(tt.args.idpType); !reflect.DeepEqual(got, tt.want) { + t.Errorf("idpTypeToPb() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/domain/idp.go b/internal/domain/idp.go index 5e4959d182..fc0e8b91fe 100644 --- a/internal/domain/idp.go +++ b/internal/domain/idp.go @@ -1,5 +1,7 @@ package domain +import "github.com/zitadel/logging" + type IDPState int32 const ( @@ -56,3 +58,36 @@ func (t IDPType) GetCSSClass() string { return "" } } + +func IDPName(name string, idpType IDPType) string { + if name != "" { + return name + } + return idpType.DisplayName() +} + +// DisplayName returns the name or a default +// to be used when always a name must be displayed (e.g. login) +func (t IDPType) DisplayName() string { + switch t { + case IDPTypeGitHub: + return "GitHub" + case IDPTypeGitLab: + return "GitLab" + case IDPTypeGoogle: + return "Google" + case IDPTypeUnspecified, + IDPTypeOIDC, + IDPTypeJWT, + IDPTypeOAuth, + IDPTypeLDAP, + IDPTypeAzureAD, + IDPTypeGitHubEnterprise, + IDPTypeGitLabSelfHosted: + fallthrough + default: + // we should never get here, so log it + logging.Errorf("name of provider (type %d) is empty", t) + return "" + } +} diff --git a/internal/domain/policy_login.go b/internal/domain/policy_login.go index b8019974e1..b9c169193c 100644 --- a/internal/domain/policy_login.go +++ b/internal/domain/policy_login.go @@ -4,8 +4,6 @@ import ( "net/url" "time" - "github.com/zitadel/logging" - "github.com/zitadel/zitadel/internal/eventstore/v1/models" ) @@ -70,30 +68,7 @@ func (p IDPProvider) IsValid() bool { // DisplayName returns the name or a default // to be used when always a name must be displayed (e.g. login) func (p IDPProvider) DisplayName() string { - if p.Name != "" { - return p.Name - } - switch p.IDPType { - case IDPTypeGitHub: - return "GitHub" - case IDPTypeGitLab: - return "GitLab" - case IDPTypeGoogle: - return "Google" - case IDPTypeUnspecified, - IDPTypeOIDC, - IDPTypeJWT, - IDPTypeOAuth, - IDPTypeLDAP, - IDPTypeAzureAD, - IDPTypeGitHubEnterprise, - IDPTypeGitLabSelfHosted: - fallthrough - default: - // we should never get here, so log it - logging.Errorf("name of provider (type %d) is empty - id: %s", p.IDPType, p.IDPConfigID) - return "" - } + return IDPName(p.Name, p.IDPType) } type PasswordlessType int32 diff --git a/internal/query/idp_login_policy_link.go b/internal/query/idp_login_policy_link.go index afa5a8f6e8..750f2f1141 100644 --- a/internal/query/idp_login_policy_link.go +++ b/internal/query/idp_login_policy_link.go @@ -80,27 +80,33 @@ var ( name: projection.IDPLoginPolicyLinkOwnerRemovedCol, table: idpLoginPolicyLinkTable, } + + idpLoginPolicyOwnerTable = loginPolicyTable.setAlias("login_policy_owner") + idpLoginPolicyOwnerIDCol = LoginPolicyColumnOrgID.setTable(idpLoginPolicyOwnerTable) + idpLoginPolicyOwnerInstanceIDCol = LoginPolicyColumnInstanceID.setTable(idpLoginPolicyOwnerTable) + idpLoginPolicyOwnerIsDefaultCol = LoginPolicyColumnIsDefault.setTable(idpLoginPolicyOwnerTable) + idpLoginPolicyOwnerOwnerRemovedCol = LoginPolicyColumnOwnerRemoved.setTable(idpLoginPolicyOwnerTable) ) func (q *Queries) IDPLoginPolicyLinks(ctx context.Context, resourceOwner string, queries *IDPLoginPolicyLinksSearchQuery, withOwnerRemoved bool) (idps *IDPLoginPolicyLinks, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - query, scan := prepareIDPLoginPolicyLinksQuery(ctx, q.client) + query, scan := prepareIDPLoginPolicyLinksQuery(ctx, q.client, resourceOwner) eq := sq.Eq{ - IDPLoginPolicyLinkResourceOwnerCol.identifier(): resourceOwner, - IDPLoginPolicyLinkInstanceIDCol.identifier(): authz.GetInstance(ctx).InstanceID(), + IDPLoginPolicyLinkInstanceIDCol.identifier(): authz.GetInstance(ctx).InstanceID(), } if !withOwnerRemoved { eq[IDPLoginPolicyLinkOwnerRemovedCol.identifier()] = false + eq[idpLoginPolicyOwnerOwnerRemovedCol.identifier()] = false } + stmt, args, err := queries.toQuery(query).Where(eq).ToSql() if err != nil { return nil, errors.ThrowInvalidArgument(err, "QUERY-FDbKW", "Errors.Query.InvalidRequest") } - rows, err := q.client.QueryContext(ctx, stmt, args...) - if err != nil { + if err != nil || rows.Err() != nil { return nil, errors.ThrowInternal(err, "QUERY-ZkKUc", "Errors.Internal") } idps, err = scan(rows) @@ -111,7 +117,11 @@ func (q *Queries) IDPLoginPolicyLinks(ctx context.Context, resourceOwner string, return idps, err } -func prepareIDPLoginPolicyLinksQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Rows) (*IDPLoginPolicyLinks, error)) { +func prepareIDPLoginPolicyLinksQuery(ctx context.Context, db prepareDatabase, resourceOwner string) (sq.SelectBuilder, func(*sql.Rows) (*IDPLoginPolicyLinks, error)) { + resourceOwnerQuery, resourceOwnerArgs, err := prepareIDPLoginPolicyLinksResourceOwnerQuery(ctx, resourceOwner) + if err != nil { + return sq.SelectBuilder{}, nil + } return sq.Select( IDPLoginPolicyLinkIDPIDCol.identifier(), IDPTemplateNameCol.identifier(), @@ -119,7 +129,12 @@ func prepareIDPLoginPolicyLinksQuery(ctx context.Context, db prepareDatabase) (s IDPTemplateOwnerTypeCol.identifier(), countColumn.identifier()). From(idpLoginPolicyLinkTable.identifier()). - LeftJoin(join(IDPTemplateIDCol, IDPLoginPolicyLinkIDPIDCol) + db.Timetravel(call.Took(ctx))). + LeftJoin(join(IDPTemplateIDCol, IDPLoginPolicyLinkIDPIDCol)). + RightJoin("("+resourceOwnerQuery+") AS "+idpLoginPolicyOwnerTable.alias+" ON "+ + idpLoginPolicyOwnerIDCol.identifier()+" = "+IDPLoginPolicyLinkResourceOwnerCol.identifier()+" AND "+ + idpLoginPolicyOwnerInstanceIDCol.identifier()+" = "+IDPLoginPolicyLinkInstanceIDCol.identifier()+ + " "+db.Timetravel(call.Took(ctx)), + resourceOwnerArgs...). PlaceholderFormat(sq.Dollar), func(rows *sql.Rows) (*IDPLoginPolicyLinks, error) { links := make([]*IDPLoginPolicyLink, 0) @@ -164,3 +179,22 @@ func prepareIDPLoginPolicyLinksQuery(ctx context.Context, db prepareDatabase) (s }, nil } } + +func prepareIDPLoginPolicyLinksResourceOwnerQuery(ctx context.Context, resourceOwner string) (string, []interface{}, error) { + eqPolicy := sq.Eq{idpLoginPolicyOwnerInstanceIDCol.identifier(): authz.GetInstance(ctx).InstanceID()} + return sq.Select( + idpLoginPolicyOwnerIDCol.identifier(), + idpLoginPolicyOwnerInstanceIDCol.identifier(), + idpLoginPolicyOwnerOwnerRemovedCol.identifier(), + ). + From(idpLoginPolicyOwnerTable.identifier()). + Where( + sq.And{ + eqPolicy, + sq.Or{ + sq.Eq{idpLoginPolicyOwnerIDCol.identifier(): resourceOwner}, + sq.Eq{idpLoginPolicyOwnerIDCol.identifier(): authz.GetInstance(ctx).InstanceID()}, + }, + }). + Limit(1).OrderBy(idpLoginPolicyOwnerIsDefaultCol.identifier()).ToSql() +} diff --git a/internal/query/idp_login_policy_link_test.go b/internal/query/idp_login_policy_link_test.go index 4e802d9542..530780160c 100644 --- a/internal/query/idp_login_policy_link_test.go +++ b/internal/query/idp_login_policy_link_test.go @@ -1,6 +1,7 @@ package query import ( + "context" "database/sql" "database/sql/driver" "errors" @@ -8,6 +9,8 @@ import ( "regexp" "testing" + sq "github.com/Masterminds/squirrel" + "github.com/zitadel/zitadel/internal/domain" ) @@ -19,6 +22,9 @@ var ( ` COUNT(*) OVER ()` + ` FROM projections.idp_login_policy_links5` + ` LEFT JOIN projections.idp_templates5 ON projections.idp_login_policy_links5.idp_id = projections.idp_templates5.id AND projections.idp_login_policy_links5.instance_id = projections.idp_templates5.instance_id` + + ` RIGHT JOIN (SELECT login_policy_owner.aggregate_id, login_policy_owner.instance_id, login_policy_owner.owner_removed FROM projections.login_policies4 AS login_policy_owner` + + ` WHERE (login_policy_owner.instance_id = $1 AND (login_policy_owner.aggregate_id = $2 OR login_policy_owner.aggregate_id = $3)) ORDER BY login_policy_owner.is_default LIMIT 1) AS login_policy_owner` + + ` ON login_policy_owner.aggregate_id = projections.idp_login_policy_links5.resource_owner AND login_policy_owner.instance_id = projections.idp_login_policy_links5.instance_id` + ` AS OF SYSTEM TIME '-1 ms'`) loginPolicyIDPLinksCols = []string{ "idp_id", @@ -41,8 +47,10 @@ func Test_IDPLoginPolicyLinkPrepares(t *testing.T) { object interface{} }{ { - name: "prepareIDPsQuery found", - prepare: prepareIDPLoginPolicyLinksQuery, + name: "prepareIDPsQuery found", + prepare: func(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Rows) (*IDPLoginPolicyLinks, error)) { + return prepareIDPLoginPolicyLinksQuery(ctx, db, "resourceOwner") + }, want: want{ sqlExpectations: mockQueries( loginPolicyIDPLinksQuery, @@ -72,8 +80,10 @@ func Test_IDPLoginPolicyLinkPrepares(t *testing.T) { }, }, { - name: "prepareIDPsQuery no idp", - prepare: prepareIDPLoginPolicyLinksQuery, + name: "prepareIDPsQuery no idp", + prepare: func(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Rows) (*IDPLoginPolicyLinks, error)) { + return prepareIDPLoginPolicyLinksQuery(ctx, db, "resourceOwner") + }, want: want{ sqlExpectations: mockQueries( loginPolicyIDPLinksQuery, @@ -102,8 +112,10 @@ func Test_IDPLoginPolicyLinkPrepares(t *testing.T) { }, }, { - name: "prepareIDPsQuery sql err", - prepare: prepareIDPLoginPolicyLinksQuery, + name: "prepareIDPsQuery sql err", + prepare: func(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Rows) (*IDPLoginPolicyLinks, error)) { + return prepareIDPLoginPolicyLinksQuery(ctx, db, "resourceOwner") + }, want: want{ sqlExpectations: mockQueryErr( loginPolicyIDPLinksQuery, diff --git a/proto/zitadel/object/v2alpha/object.proto b/proto/zitadel/object/v2alpha/object.proto index 27c6dbb395..5150ad7929 100644 --- a/proto/zitadel/object/v2alpha/object.proto +++ b/proto/zitadel/object/v2alpha/object.proto @@ -15,6 +15,13 @@ message Organisation { } } +message RequestContext { + oneof resource_owner { + string org_id = 1; + bool instance = 2 [(validate.rules).bool = {const: true}]; + } +} + message ListQuery { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { json_schema: { diff --git a/proto/zitadel/settings/v2alpha/branding_settings.proto b/proto/zitadel/settings/v2alpha/branding_settings.proto new file mode 100644 index 0000000000..a98b67769a --- /dev/null +++ b/proto/zitadel/settings/v2alpha/branding_settings.proto @@ -0,0 +1,81 @@ +syntax = "proto3"; + +package zitadel.settings.v2alpha; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/settings/v2alpha;settings"; + +import "protoc-gen-openapiv2/options/annotations.proto"; +import "zitadel/settings/v2alpha/settings.proto"; + +message BrandingSettings { + Theme light_theme = 1; + Theme dark_theme = 2; + string font_url = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "url to the font used"; + example: "\"https://acme.com/assets/v1/165617850692654601/policy/label/font-180950243237405441\""; + } + ]; + // hides the org suffix on the login form if the scope \"urn:zitadel:iam:org:domain:primary:{domainname}\" is set + bool hide_login_name_suffix = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "hides the org suffix on the login form if the scope \"urn:zitadel:iam:org:domain:primary:{domainname}\" is set"; + } + ]; + bool disable_watermark = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "boolean to disable the watermark"; + } + ]; + // resource_owner_type returns if the setting is managed on the organization or on the instance + ResourceOwnerType resource_owner_type = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "resource_owner_type returns if the setting is managed on the organization or on the instance"; + } + ]; +} + +message Theme { + // hex value for primary color + string primary_color = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "hex value for primary color"; + example: "\"#5469d4\""; + } + ]; + // hex value for background color + string background_color = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "hex value for background color"; + example: "\"#FAFAFA\""; + } + ]; + // hex value for warning color + string warn_color = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "hex value for warn color"; + example: "\"#CD3D56\""; + } + ]; + // hex value for font color + string font_color = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "hex value for font color"; + example: "\"#000000\""; + } + ]; + // url where the logo is served + string logo_url = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "url to the logo"; + example: "\"https://acme.com/assets/v1/165617850692654601/policy/label/logo-180950416321494657\""; + } + ]; + // url where the icon is served + string icon_url = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "url to the icon"; + example: "\"https://acme.com/assets/v1/165617850692654601/policy/label/icon-180950498874178817\""; + } + ]; +} diff --git a/proto/zitadel/settings/v2alpha/domain_settings.proto b/proto/zitadel/settings/v2alpha/domain_settings.proto new file mode 100644 index 0000000000..4eae13dc70 --- /dev/null +++ b/proto/zitadel/settings/v2alpha/domain_settings.proto @@ -0,0 +1,33 @@ +syntax = "proto3"; + +package zitadel.settings.v2alpha; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/settings/v2alpha;settings"; + +import "protoc-gen-openapiv2/options/annotations.proto"; +import "zitadel/settings/v2alpha/settings.proto"; + +message DomainSettings { + bool login_name_includes_domain = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "the username has to end with the domain of its organization" + } + ]; + bool require_org_domain_verification = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines if organization domains should be verified upon creation, otherwise will be created already verified" + } + ]; + bool smtp_sender_address_matches_instance_domain = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines if the SMTP sender address domain should match an existing domain on the instance" + } + ]; + // resource_owner_type returns if the setting is managed on the organization or on the instance + ResourceOwnerType resource_owner_type = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "resource_owner_type returns if the setting is managed on the organization or on the instance"; + } + ]; +} + diff --git a/proto/zitadel/settings/v2alpha/legal_settings.proto b/proto/zitadel/settings/v2alpha/legal_settings.proto new file mode 100644 index 0000000000..3b9d5694e2 --- /dev/null +++ b/proto/zitadel/settings/v2alpha/legal_settings.proto @@ -0,0 +1,40 @@ +syntax = "proto3"; + +package zitadel.settings.v2alpha; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/settings/v2alpha;settings"; + +import "protoc-gen-openapiv2/options/annotations.proto"; +import "zitadel/settings/v2alpha/settings.proto"; +import "validate/validate.proto"; + +message LegalAndSupportSettings { + string tos_link = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"https://zitadel.com/docs/legal/terms-of-service\""; + } + ]; + string privacy_policy_link = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"https://zitadel.com/docs/legal/privacy-policy\""; + } + ]; + string help_link = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"https://zitadel.com/docs/manuals/introduction\""; + } + ]; + string support_email = 4 [ + (validate.rules).string = {ignore_empty: true, max_len: 320, email: true}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"support-email@test.com\""; + description: "help / support email address." + } + ]; + // resource_owner_type returns if the setting is managed on the organization or on the instance + ResourceOwnerType resource_owner_type = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "resource_owner_type returns if the setting is managed on the organization or on the instance"; + } + ]; +} diff --git a/proto/zitadel/settings/v2alpha/lockout_settings.proto b/proto/zitadel/settings/v2alpha/lockout_settings.proto new file mode 100644 index 0000000000..7a5ad6244e --- /dev/null +++ b/proto/zitadel/settings/v2alpha/lockout_settings.proto @@ -0,0 +1,23 @@ +syntax = "proto3"; + +package zitadel.settings.v2alpha; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/settings/v2alpha;settings"; + +import "protoc-gen-openapiv2/options/annotations.proto"; +import "zitadel/settings/v2alpha/settings.proto"; + +message LockoutSettings { + uint64 max_password_attempts = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Maximum password check attempts before the account gets locked. Attempts are reset as soon as the password is entered correctly or the password is reset. If set to 0 the account will never be locked." + example: "\"10\"" + } + ]; + // resource_owner_type returns if the settings is managed on the organization or on the instance + ResourceOwnerType resource_owner_type = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "resource_owner_type returns if the settings is managed on the organization or on the instance"; + } + ]; +} diff --git a/proto/zitadel/settings/v2alpha/login_settings.proto b/proto/zitadel/settings/v2alpha/login_settings.proto new file mode 100644 index 0000000000..d84423c3cf --- /dev/null +++ b/proto/zitadel/settings/v2alpha/login_settings.proto @@ -0,0 +1,143 @@ +syntax = "proto3"; + +package zitadel.settings.v2alpha; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/settings/v2alpha;settings"; + +import "protoc-gen-openapiv2/options/annotations.proto"; +import "zitadel/settings/v2alpha/settings.proto"; +import "google/protobuf/duration.proto"; + +message LoginSettings { + bool allow_username_password = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines if a user is allowed to log in with his username and password"; + } + ]; + bool allow_register = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines if a person is allowed to register a user on this organization"; + } + ]; + bool allow_external_idp = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines if a user is allowed to add a defined identity provider. E.g. Google auth"; + } + ]; + bool force_mfa = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines if a user MUST use a multi-factor to log in"; + } + ]; + PasskeysType passkeys_type = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines if passkeys are allowed for users" + } + ]; + bool hide_password_reset = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines if password reset link should be shown in the login screen" + } + ]; + bool ignore_unknown_usernames = 7 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines if unknown username on login screen directly returns an error or always displays the password screen" + } + ]; + string default_redirect_uri = 8 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines where the user will be redirected to if the login is started without app context (e.g. from mail)"; + example: "\"https://acme.com/ui/console\""; + } + ]; + google.protobuf.Duration password_check_lifetime = 9 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Defines after how much time the user has to re-authenticate with the password."; + example: "\"864000s\""; + } + ]; + google.protobuf.Duration external_login_check_lifetime = 10 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Defines after how much time the user has to re-authenticate with an external provider."; + example: "\"864000s\""; + } + ]; + google.protobuf.Duration mfa_init_skip_lifetime = 11 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Defines after how much time the mfa prompt will be shown again."; + example: "\"2592000s\""; + } + ]; + google.protobuf.Duration second_factor_check_lifetime = 12 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Defines after how long the second-factor check is valid."; + example: "\"64800s\""; + } + ]; + google.protobuf.Duration multi_factor_check_lifetime = 13 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Defines how long the multi-factor check is valid."; + example: "\"43200s\""; + } + ]; + repeated SecondFactorType second_factors = 14; + repeated MultiFactorType multi_factors = 15; + // If set to true, the suffix (@domain.com) of an unknown username input on the login screen will be matched against the org domains and will redirect to the registration of that organization on success. + bool allow_domain_discovery = 16 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "If set to true, the suffix (@domain.com) of an unknown username input on the login screen will be matched against the org domains and will redirect to the registration of that organization on success." + } + ]; + bool disable_login_with_email = 17 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines if the user can additionally (to the login name) be identified by their verified email address" + } + ]; + bool disable_login_with_phone = 18 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines if the user can additionally (to the login name) be identified by their verified phone number" + } + ]; + // resource_owner_type returns if the settings is managed on the organization or on the instance + ResourceOwnerType resource_owner_type = 19 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "resource_owner_type returns if the settings is managed on the organization or on the instance"; + } + ]; +} + +enum SecondFactorType { + SECOND_FACTOR_TYPE_UNSPECIFIED = 0; + SECOND_FACTOR_TYPE_OTP = 1; + SECOND_FACTOR_TYPE_U2F = 2; +} + +enum MultiFactorType { + MULTI_FACTOR_TYPE_UNSPECIFIED = 0; + MULTI_FACTOR_TYPE_U2F_WITH_VERIFICATION = 1; +} + +enum PasskeysType { + PASSKEYS_TYPE_NOT_ALLOWED = 0; + PASSKEYS_TYPE_ALLOWED = 1; +} + +message IdentityProvider { + string id = 1; + string name = 2; + IdentityProviderType type = 3; +} + +enum IdentityProviderType { + IDENTITY_PROVIDER_TYPE_UNSPECIFIED = 0; + IDENTITY_PROVIDER_TYPE_OIDC = 1; + IDENTITY_PROVIDER_TYPE_JWT = 2; + IDENTITY_PROVIDER_TYPE_LDAP = 3; + IDENTITY_PROVIDER_TYPE_OAUTH = 4; + IDENTITY_PROVIDER_TYPE_AZURE_AD = 5; + IDENTITY_PROVIDER_TYPE_GITHUB = 6; + IDENTITY_PROVIDER_TYPE_GITHUB_ES = 7; + IDENTITY_PROVIDER_TYPE_GITLAB = 8; + IDENTITY_PROVIDER_TYPE_GITLAB_SELF_HOSTED = 9; + IDENTITY_PROVIDER_TYPE_GOOGLE = 10; +} diff --git a/proto/zitadel/settings/v2alpha/password_settings.proto b/proto/zitadel/settings/v2alpha/password_settings.proto new file mode 100644 index 0000000000..f5f30be47d --- /dev/null +++ b/proto/zitadel/settings/v2alpha/password_settings.proto @@ -0,0 +1,43 @@ +syntax = "proto3"; + +package zitadel.settings.v2alpha; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/settings/v2alpha;settings"; + +import "protoc-gen-openapiv2/options/annotations.proto"; +import "zitadel/settings/v2alpha/settings.proto"; + +message PasswordComplexitySettings { + uint64 min_length = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Defines the minimum length of a password."; + example: "\"8\"" + } + ]; + bool requires_uppercase = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines if the password MUST contain an upper case letter" + } + ]; + bool requires_lowercase = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines if the password MUST contain a lowercase letter" + } + ]; + bool requires_number = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines if the password MUST contain a number" + } + ]; + bool requires_symbol = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines if the password MUST contain a symbol. E.g. \"$\"" + } + ]; + // resource_owner_type returns if the settings is managed on the organization or on the instance + ResourceOwnerType resource_owner_type = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "resource_owner_type returns if the settings is managed on the organization or on the instance"; + } + ]; +} diff --git a/proto/zitadel/settings/v2alpha/settings.proto b/proto/zitadel/settings/v2alpha/settings.proto new file mode 100644 index 0000000000..7131015514 --- /dev/null +++ b/proto/zitadel/settings/v2alpha/settings.proto @@ -0,0 +1,13 @@ +syntax = "proto3"; + +package zitadel.settings.v2alpha; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/settings/v2alpha;settings"; + +import "protoc-gen-openapiv2/options/annotations.proto"; + +enum ResourceOwnerType { + RESOURCE_OWNER_TYPE_UNSPECIFIED = 0; + RESOURCE_OWNER_TYPE_INSTANCE = 1; + RESOURCE_OWNER_TYPE_ORG = 2; +} diff --git a/proto/zitadel/settings/v2alpha/settings_service.proto b/proto/zitadel/settings/v2alpha/settings_service.proto new file mode 100644 index 0000000000..0c7de35bf8 --- /dev/null +++ b/proto/zitadel/settings/v2alpha/settings_service.proto @@ -0,0 +1,356 @@ +syntax = "proto3"; + +package zitadel.settings.v2alpha; + +import "zitadel/protoc_gen_zitadel/v2/options.proto"; +import "zitadel/object/v2alpha/object.proto"; +import "zitadel/settings/v2alpha/branding_settings.proto"; +import "zitadel/settings/v2alpha/domain_settings.proto"; +import "zitadel/settings/v2alpha/legal_settings.proto"; +import "zitadel/settings/v2alpha/lockout_settings.proto"; +import "zitadel/settings/v2alpha/login_settings.proto"; +import "zitadel/settings/v2alpha/password_settings.proto"; +import "google/api/annotations.proto"; +import "google/api/field_behavior.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/settings/v2alpha;settings"; + +option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { + info: { + title: "Settings Service"; + version: "2.0-alpha"; + description: "This API is intended to manage settings in a ZITADEL instance. This project is in alpha state. It can AND will continue breaking until the services provide the same functionality as the current login."; + contact:{ + name: "ZITADEL" + url: "https://zitadel.com" + email: "hi@zitadel.com" + } + license: { + name: "Apache 2.0", + url: "https://github.com/zitadel/zitadel/blob/main/LICENSE"; + }; + }; + schemes: HTTPS; + schemes: HTTP; + + consumes: "application/json"; + consumes: "application/grpc"; + + produces: "application/json"; + produces: "application/grpc"; + + consumes: "application/grpc-web+proto"; + produces: "application/grpc-web+proto"; + + host: "$ZITADEL_DOMAIN"; + base_path: "/"; + + external_docs: { + description: "Detailed information about ZITADEL", + url: "https://zitadel.com/docs" + } + + responses: { + key: "403"; + value: { + description: "Returned when the user does not have permission to access the resource."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } + responses: { + key: "404"; + value: { + description: "Returned when the resource does not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } +}; + +service SettingsService { + + // Get basic information over the instance + rpc GetGeneralSettings (GetGeneralSettingsRequest) returns (GetGeneralSettingsResponse) { + option (google.api.http) = { + get: "/v2alpha/settings" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "policy.read" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Get basic information over the instance"; + description: "Return the basic information of the instance for the requested context" + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + // Get the login settings + rpc GetLoginSettings (GetLoginSettingsRequest) returns (GetLoginSettingsResponse) { + option (google.api.http) = { + get: "/v2alpha/settings/login" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "policy.read" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Get the login settings"; + description: "Return the settings for the requested context" + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + // Get the current active identity providers + rpc GetActiveIdentityProviders (GetActiveIdentityProvidersRequest) returns (GetActiveIdentityProvidersResponse) { + option (google.api.http) = { + get: "/v2alpha/settings/login/idps" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "policy.read" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Get the current active identity providers"; + description: "Return the current active identity providers for the requested context" + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + // Get the password complexity settings + rpc GetPasswordComplexitySettings (GetPasswordComplexitySettingsRequest) returns (GetPasswordComplexitySettingsResponse) { + option (google.api.http) = { + get: "/v2alpha/settings/password/complexity" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "policy.read" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Get the password complexity settings"; + description: "Return the password complexity settings for the requested context" + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + // Get the current active branding settings + rpc GetBrandingSettings (GetBrandingSettingsRequest) returns (GetBrandingSettingsResponse) { + option (google.api.http) = { + get: "/v2alpha/settings/branding" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "policy.read" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Get the current active branding settings"; + description: "Return the current active branding settings for the requested context" + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + // Get the domain settings + rpc GetDomainSettings (GetDomainSettingsRequest) returns (GetDomainSettingsResponse) { + option (google.api.http) = { + get: "/v2alpha/settings/domain" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "policy.read" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Get the domain settings"; + description: "Return the domain settings for the requested context" + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + // Get the legal and support settings + rpc GetLegalAndSupportSettings (GetLegalAndSupportSettingsRequest) returns (GetLegalAndSupportSettingsResponse) { + option (google.api.http) = { + get: "/v2alpha/settings/legal_support" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "policy.read" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Get the legal and support settings"; + description: "Return the legal settings for the requested context" + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + // Get the lockout settings + rpc GetLockoutSettings (GetLockoutSettingsRequest) returns (GetLockoutSettingsResponse) { + option (google.api.http) = { + get: "/v2alpha/settings/lockout" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "policy.read" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Get the lockout settings"; + description: "Return the lockout settings for the requested context, which define when a user will be locked" + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } +} + +message GetLoginSettingsRequest { + zitadel.object.v2alpha.RequestContext ctx = 1; +} + +message GetLoginSettingsResponse { + zitadel.object.v2alpha.Details details = 1; + zitadel.settings.v2alpha.LoginSettings settings = 2; +} + +message GetPasswordComplexitySettingsRequest { + zitadel.object.v2alpha.RequestContext ctx = 1; +} + +message GetPasswordComplexitySettingsResponse { + zitadel.object.v2alpha.Details details = 1; + zitadel.settings.v2alpha.PasswordComplexitySettings settings = 2; +} + +message GetBrandingSettingsRequest { + zitadel.object.v2alpha.RequestContext ctx = 1; +} + +message GetBrandingSettingsResponse { + zitadel.object.v2alpha.Details details = 1; + zitadel.settings.v2alpha.BrandingSettings settings = 2; +} + +message GetDomainSettingsRequest { + zitadel.object.v2alpha.RequestContext ctx = 1; +} + +message GetDomainSettingsResponse { + zitadel.object.v2alpha.Details details = 1; + zitadel.settings.v2alpha.DomainSettings settings = 2; +} + +message GetLegalAndSupportSettingsRequest { + zitadel.object.v2alpha.RequestContext ctx = 1; +} + +message GetLegalAndSupportSettingsResponse { + zitadel.object.v2alpha.Details details = 1; + zitadel.settings.v2alpha.LegalAndSupportSettings settings = 2; +} + +message GetLockoutSettingsRequest { + zitadel.object.v2alpha.RequestContext ctx = 1; +} + +message GetLockoutSettingsResponse { + zitadel.object.v2alpha.Details details = 1; + zitadel.settings.v2alpha.LockoutSettings settings = 2; +} + +message GetActiveIdentityProvidersRequest { + zitadel.object.v2alpha.RequestContext ctx = 1; +} + +message GetActiveIdentityProvidersResponse { + zitadel.object.v2alpha.ListDetails details = 1; + repeated zitadel.settings.v2alpha.IdentityProvider identity_providers = 2; +} + +message GetGeneralSettingsRequest {} + +message GetGeneralSettingsResponse { + string default_org_id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "default organization for the current context" + } + ]; + string default_language = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "default language for the current context" + example: "\"en\"" + } + ]; + repeated string supported_languages = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"en\", \"de\", \"it\"]" + } + ]; +} From 4d7a733e11a80fbaeb5c36d213a8b9a1b5938287 Mon Sep 17 00:00:00 2001 From: adlerhurst Date: Thu, 11 May 2023 11:53:52 +0200 Subject: [PATCH 08/16] fix(step11): execute step 10 to make sure events are in correct order --- cmd/setup/11.go | 6 ++++++ cmd/setup/setup.go | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/cmd/setup/11.go b/cmd/setup/11.go index 97f6bf6687..4745c71994 100644 --- a/cmd/setup/11.go +++ b/cmd/setup/11.go @@ -13,10 +13,16 @@ var ( ) type AddEventCreatedAt struct { + step10 *CorrectCreationDate dbClient *database.DB } func (mig *AddEventCreatedAt) Execute(ctx context.Context) error { + // execute step 10 again because events created after the first execution of step 10 + // could still have the wrong ordering of sequences and creation date + if err := mig.step10.Execute(ctx); err != nil { + return err + } _, err := mig.dbClient.ExecContext(ctx, addEventCreatedAt) return err } diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 31df9f2657..59b7c5d6ba 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -91,7 +91,7 @@ func Setup(config *Config, steps *Steps, masterKey string) { steps.s8AuthTokens = &AuthTokenIndexes{dbClient: dbClient} steps.s9EventstoreIndexes2 = New09(dbClient) steps.CorrectCreationDate.dbClient = dbClient - steps.s11AddEventCreatedAt = &AddEventCreatedAt{dbClient: dbClient} + steps.s11AddEventCreatedAt = &AddEventCreatedAt{dbClient: dbClient, step10: steps.CorrectCreationDate} err = projection.Create(ctx, dbClient, eventstoreClient, config.Projections, nil, nil) logging.OnError(err).Fatal("unable to start projections") From 098c27d3da3d27e4a836751636f6247938a48be9 Mon Sep 17 00:00:00 2001 From: Silvan Date: Thu, 11 May 2023 18:02:34 +0200 Subject: [PATCH 09/16] fix: render authrequest id only if possible (#5823) --- internal/api/ui/login/renderer.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/api/ui/login/renderer.go b/internal/api/ui/login/renderer.go index a9b12f19dc..5c20168d35 100644 --- a/internal/api/ui/login/renderer.go +++ b/internal/api/ui/login/renderer.go @@ -327,7 +327,12 @@ func (l *Login) chooseNextStep(w http.ResponseWriter, r *http.Request, authReq * func (l *Login) renderInternalError(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, err error) { var msg string if err != nil { - logging.WithError(err).WithField("auth_req_id", authReq.ID).Error() + log := logging.WithError(err) + if authReq != nil { + log = log.WithField("auth_req_id", authReq.ID) + } + log.Error() + _, msg = l.getErrorMessage(r, err) } data := l.getBaseData(r, authReq, "Errors.Internal", "", "Internal", msg) From 240b799fd326b6900987a5cb6bff257389ab82a4 Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Fri, 12 May 2023 07:06:54 +0200 Subject: [PATCH 10/16] chore: bump Helm charts from next (#5815) Co-authored-by: Livio Spring --- .github/workflows/zitadel.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/zitadel.yml b/.github/workflows/zitadel.yml index 2691109f80..c5512d5290 100644 --- a/.github/workflows/zitadel.yml +++ b/.github/workflows/zitadel.yml @@ -80,7 +80,7 @@ jobs: name: go-codecov - name: Bump Chart Version uses: peter-evans/repository-dispatch@v2 - if: steps.semantic.outputs.new_release_published == 'true' && github.ref == 'refs/heads/main' + if: steps.semantic.outputs.new_release_published == 'true' && github.ref == 'refs/heads/next' with: token: ${{ steps.generate-token.outputs.token }} repository: zitadel/zitadel-charts From 99857ff9549a612e36eec09f882834a82e51ead0 Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Fri, 12 May 2023 09:36:44 +0200 Subject: [PATCH 11/16] chore: speed up local console docker build (#5824) Co-authored-by: Livio Spring --- .dockerignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.dockerignore b/.dockerignore index 5d4e0f6ced..0fea514232 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,6 +7,7 @@ /k8s/ /node_modules/ /console/src/app/proto/generated/ +/console/.angular /console/tmp/ .releaserc.js changelog.config.js @@ -18,3 +19,4 @@ pkg/grpc/*/*.pb.* pkg/grpc/*/*.swagger.json .goreleaser.yaml .artifacts/ +.vscode From 91431cb451f673efc1d4feb618b37ebf046fe40a Mon Sep 17 00:00:00 2001 From: mffap Date: Fri, 12 May 2023 10:51:17 +0200 Subject: [PATCH 12/16] docs(legal): editorial changes (#5828) * docs(legal): editorial changes * remove from tos --- docs/docs/legal/introduction.mdx | 27 -------- docs/docs/legal/terms-of-service-dedicated.md | 39 ----------- docs/docs/legal/terms-of-service.md | 6 +- docs/docusaurus.config.js | 2 +- docs/sidebars.js | 68 +++++++++++-------- 5 files changed, 42 insertions(+), 100 deletions(-) delete mode 100644 docs/docs/legal/introduction.mdx delete mode 100644 docs/docs/legal/terms-of-service-dedicated.md diff --git a/docs/docs/legal/introduction.mdx b/docs/docs/legal/introduction.mdx deleted file mode 100644 index 00bebc4a6a..0000000000 --- a/docs/docs/legal/introduction.mdx +++ /dev/null @@ -1,27 +0,0 @@ ---- -title: Overview ---- - -import {ListElement, ListWrapper, ICONTYPE} from '../../src/components/list'; -import Column from '../../src/components/column'; - -This section contains important agreements, policies and appendices relevant for users of our websites and services. - -All documents will be provided in English language. - - - - - - - - - - - - - - - - - diff --git a/docs/docs/legal/terms-of-service-dedicated.md b/docs/docs/legal/terms-of-service-dedicated.md deleted file mode 100644 index d2bae23355..0000000000 --- a/docs/docs/legal/terms-of-service-dedicated.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -title: Dedicated Instance Terms -custom_edit_url: null ---- -## General - -Last revised: June 3, 2022 - -### Background - -Within the scope of the Framework Agreement, the Customer may choose to purchase a subscription that requires a dedicated instance of ZITADEL. These additional terms for dedicated instance ("**Dedicated Instance Terms**") apply in addition to the Framework Agreement. - -### Service - -CAOS operates and manages a **Dedicated Instance** of ZITADEL in a private infrastructure environment dedicated for the Customer and provides support services for the Customer according the Purchase Order, these terms, agreed [**Service Level Description**](service-level-description), and [**Support Service Descriptions**](support-services). - -Each Dedicated Instance consists, except agreed otherwise in writing, of a multi-zonal high-availability configuration that guarantees loads up to the specified [rate limits](rate-limit-policy#what-rate-limits-do-apply). - -### Operations - -CAOS will install and manage the Dedicated Instance on infracstructure provided by preferred cloud providers. Costs for infrastructure or cloud providers are not included in the Subscription, if not agreed otherwise in writing. - -You may choose to provide the required infrastructure yourself. You must comply with the requirements and prerequisites outlined in the purchase order. - -You may not modify, maintain or attempt to modify the Dedicated Instance, except with prior instructions by CAOS. - -CAOS will use the same backup strategy as for ZITADEL Cloud (public cloud) services, except otherwise agreed between you and CAOS in writing. - -### Maintenance and Updates - -We will access, modify, and maintain the Dedicated Instance at times solely determined by CAOS (**"Regular Maintenance"**). - -Under certain subscription plans, the Customer may agree a custom frequency and times for changes and updates. CAOS will coordinate the cadence and the changes with the Customer. To guarantee the quality of service, maintenance will occur on regular basis, typically monthly or sooner for security or performance related patches (**"Emergency Maintenance"**), but no longer than on quarterly basis. - -If you fail to permit CAOS to conduct Regular Maintenance for 3 consecutive months or Emergency Maintenance within 5 days of notification, then CAOS will raise this issue with the Customer via Escalation Process. In case the issue is not resolved 5 days after such an escalation, CAOS may terminate the subscription with 30 days prior written notice to Customer. CAOS is not obligated to provide the service according to the terms and SLA, nor is CAOS liable to any security breach or damages after failure to permit Regular Maintenance for 3 consecutive months, or Emergency Maintenance for 5 days after notification. - -### Incidents - -Incidents are handled as documented in the [**Support Service Descriptions**](support-services). If the Customer choose in Purchase Order to provide the required infrastructure, then any incidents related to the infrastructure of the Dedicated Instance have to be resolved through the Customer directly. diff --git a/docs/docs/legal/terms-of-service.md b/docs/docs/legal/terms-of-service.md index ee2b8be36d..e963ae58d2 100644 --- a/docs/docs/legal/terms-of-service.md +++ b/docs/docs/legal/terms-of-service.md @@ -25,10 +25,6 @@ The following policies complement the TOS. When accepting the TOS, you accept th * [**Acceptable Use Policy**](acceptable-use-policy) - What we understand as acceptable and fair use of our Services * [**Rate Limit Policy**](rate-limit-policy) - How we avoid overloads of our services -This Agreement is extended with additional terms, in case your Subscription requires a Dedicated Instance. When you enter the Agreement with us, you accept these additional agreements. - -* [**Dedicated Instance Terms**](terms-of-service-dedicated) - How we provide our services for a dedicated instance - ### Alterations Any provisions which deviate from these TOS must be agreed in writing between the Customer and us. Such agreements shall take precedence over the TOS outlined in this document. @@ -195,7 +191,7 @@ Should any provision of these TOS be or become invalid, this shall not affect th These TOS shall enter into force as of 15.07.2022. -Last revised: June 14, 2022 +Last revised: May 12, 2023 ### Amendments diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 409f3905e3..058d2c4626 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -83,7 +83,7 @@ module.exports = { }, { type: "doc", - docId: "legal/introduction", + docId: "legal", label: "Legal", position: "right", }, diff --git a/docs/sidebars.js b/docs/sidebars.js index 54ca65bb18..709c99b928 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -540,38 +540,50 @@ module.exports = { support: [ ], legal: [ - "legal/introduction", - "legal/terms-of-service", - "legal/data-processing-agreement", { type: "category", - label: "Service Description", + label: "Legal Agreements", collapsed: false, + link: { + type: "generated-index", + title: "Legal Agreements", + slug: "legal", + description: + "This section contains important agreements, policies and appendices relevant for users of our websites and services. All documents will be provided in English language.", + }, items: [ - "legal/cloud-service-description", - "legal/service-level-description", - "legal/support-services", - ], - }, - { - type: "category", - label: "Additional terms", - collapsed: true, - items: [ - "legal/terms-support-service", - "legal/terms-of-service-dedicated", - ], - }, - { - type: "category", - label: "Policies", - collapsed: false, - items: [ - "legal/privacy-policy", - "legal/acceptable-use-policy", - "legal/rate-limit-policy", - "legal/vulnerability-disclosure-policy", - ], + "legal/terms-of-service", + "legal/data-processing-agreement", + { + type: "category", + label: "Service Description", + collapsed: false, + items: [ + "legal/cloud-service-description", + "legal/service-level-description", + "legal/support-services", + ], + }, + { + type: "category", + label: "Support Program", + collapsed: true, + items: [ + "legal/terms-support-service", + ], + }, + { + type: "category", + label: "Policies", + collapsed: false, + items: [ + "legal/privacy-policy", + "legal/acceptable-use-policy", + "legal/rate-limit-policy", + "legal/vulnerability-disclosure-policy", + ], + }, + ] }, ], }; From b449762aedac4907b697e4b81ec61b8439e1056f Mon Sep 17 00:00:00 2001 From: mffap Date: Fri, 12 May 2023 11:08:22 +0200 Subject: [PATCH 13/16] docs: add generated docs to gitignore (#5827) --- docs/.gitignore | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/.gitignore b/docs/.gitignore index aa32254704..0b04043a5e 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -9,6 +9,18 @@ .cache-loader .artifacts +# Generated by docusaurus-plugin-openapi-docs +docs/apis/auth +docs/apis/mgmt +docs/apis/admin +docs/apis/system +docs/apis/user_service +docs/apis/session_service +docs/apis/system +docs/apis/user_service +docs/apis/session_service +docs/apis/settings_service + # Misc .DS_Store .env.local From 0e251a29c8c6957978a41be3b65e195336807fc1 Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Mon, 15 May 2023 08:51:02 +0200 Subject: [PATCH 14/16] fix: set exhausted cookie with env json (#5868) * fix: set exhausted cookie with env json * lint --- cmd/start/start.go | 7 +- internal/api/api.go | 48 +++++------ internal/api/grpc/server/gateway.go | 58 ++++++------- .../api/http/middleware/access_interceptor.go | 86 +++++++++++-------- internal/api/ui/console/console.go | 12 ++- 5 files changed, 109 insertions(+), 102 deletions(-) diff --git a/cmd/start/start.go b/cmd/start/start.go index 0d2d973690..efa0e9326f 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -306,9 +306,8 @@ func startAPIs( http_util.WithNonHttpOnly(), http_util.WithMaxAge(int(math.Floor(config.Quotas.Access.ExhaustedCookieMaxAge.Seconds()))), ) - limitingAccessInterceptor := middleware.NewAccessInterceptor(accessSvc, exhaustedCookieHandler, config.Quotas.Access, false) - nonLimitingAccessInterceptor := middleware.NewAccessInterceptor(accessSvc, nil, config.Quotas.Access, true) - apis, err := api.New(ctx, config.Port, router, queries, verifier, config.InternalAuthZ, tlsConfig, config.HTTP2HostHeader, config.HTTP1HostHeader, accessSvc, exhaustedCookieHandler, config.Quotas.Access) + limitingAccessInterceptor := middleware.NewAccessInterceptor(accessSvc, exhaustedCookieHandler, config.Quotas.Access) + apis, err := api.New(ctx, config.Port, router, queries, verifier, config.InternalAuthZ, tlsConfig, config.HTTP2HostHeader, config.HTTP1HostHeader, limitingAccessInterceptor) if err != nil { return fmt.Errorf("error creating api %w", err) } @@ -376,7 +375,7 @@ func startAPIs( } apis.RegisterHandlerOnPrefix(saml.HandlerPrefix, samlProvider.HttpHandler()) - c, err := console.Start(config.Console, config.ExternalSecure, oidcProvider.IssuerFromRequest, middleware.CallDurationHandler, instanceInterceptor.Handler, nonLimitingAccessInterceptor.Handle, config.CustomerPortal) + c, err := console.Start(config.Console, config.ExternalSecure, oidcProvider.IssuerFromRequest, middleware.CallDurationHandler, instanceInterceptor.Handler, limitingAccessInterceptor, config.CustomerPortal) if err != nil { return fmt.Errorf("unable to start console: %w", err) } diff --git a/internal/api/api.go b/internal/api/api.go index 768f0f1a2e..578bae3a45 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -19,24 +19,22 @@ import ( http_mw "github.com/zitadel/zitadel/internal/api/http/middleware" "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" ) type API struct { - port uint16 - grpcServer *grpc.Server - verifier *internal_authz.TokenVerifier - health healthCheck - router *mux.Router - http1HostName string - grpcGateway *server.Gateway - healthServer *health.Server - cookieHandler *http_util.CookieHandler - cookieConfig *http_mw.AccessConfig - queries *query.Queries + port uint16 + grpcServer *grpc.Server + verifier *internal_authz.TokenVerifier + health healthCheck + router *mux.Router + http1HostName string + grpcGateway *server.Gateway + healthServer *health.Server + accessInterceptor *http_mw.AccessInterceptor + queries *query.Queries } type healthCheck interface { @@ -51,23 +49,20 @@ func New( verifier *internal_authz.TokenVerifier, authZ internal_authz.Config, tlsConfig *tls.Config, http2HostName, http1HostName string, - accessSvc *logstore.Service, - cookieHandler *http_util.CookieHandler, - cookieConfig *http_mw.AccessConfig, + accessInterceptor *http_mw.AccessInterceptor, ) (_ *API, err error) { api := &API{ - port: port, - verifier: verifier, - health: queries, - router: router, - http1HostName: http1HostName, - cookieConfig: cookieConfig, - cookieHandler: cookieHandler, - queries: queries, + port: port, + verifier: verifier, + health: queries, + router: router, + http1HostName: http1HostName, + queries: queries, + accessInterceptor: accessInterceptor, } - api.grpcServer = server.CreateServer(api.verifier, authZ, queries, http2HostName, tlsConfig, accessSvc) - api.grpcGateway, err = server.CreateGateway(ctx, port, http1HostName, cookieHandler, cookieConfig) + api.grpcServer = server.CreateServer(api.verifier, authZ, queries, http2HostName, tlsConfig, accessInterceptor.AccessService()) + api.grpcGateway, err = server.CreateGateway(ctx, port, http1HostName, accessInterceptor) if err != nil { return nil, err } @@ -90,8 +85,7 @@ func (a *API) RegisterServer(ctx context.Context, grpcServer server.WithGatewayP grpcServer, a.port, a.http1HostName, - a.cookieHandler, - a.cookieConfig, + a.accessInterceptor, a.queries, ) if err != nil { diff --git a/internal/api/grpc/server/gateway.go b/internal/api/grpc/server/gateway.go index 9798e5dbd0..eed0234be9 100644 --- a/internal/api/grpc/server/gateway.go +++ b/internal/api/grpc/server/gateway.go @@ -16,7 +16,6 @@ import ( client_middleware "github.com/zitadel/zitadel/internal/api/grpc/client/middleware" "github.com/zitadel/zitadel/internal/api/grpc/server/middleware" - http_utils "github.com/zitadel/zitadel/internal/api/http" http_mw "github.com/zitadel/zitadel/internal/api/http/middleware" "github.com/zitadel/zitadel/internal/query" ) @@ -66,16 +65,15 @@ var ( ) type Gateway struct { - mux *runtime.ServeMux - http1HostName string - connection *grpc.ClientConn - cookieHandler *http_utils.CookieHandler - cookieConfig *http_mw.AccessConfig - queries *query.Queries + mux *runtime.ServeMux + http1HostName string + connection *grpc.ClientConn + accessInterceptor *http_mw.AccessInterceptor + queries *query.Queries } func (g *Gateway) Handler() http.Handler { - return addInterceptors(g.mux, g.http1HostName, g.cookieHandler, g.cookieConfig, g.queries) + return addInterceptors(g.mux, g.http1HostName, g.accessInterceptor, g.queries) } type CustomHTTPResponse interface { @@ -89,8 +87,7 @@ func CreateGatewayWithPrefix( g WithGatewayPrefix, port uint16, http1HostName string, - cookieHandler *http_utils.CookieHandler, - cookieConfig *http_mw.AccessConfig, + accessInterceptor *http_mw.AccessInterceptor, queries *query.Queries, ) (http.Handler, string, error) { runtimeMux := runtime.NewServeMux(serveMuxOptions...) @@ -106,10 +103,10 @@ func CreateGatewayWithPrefix( if err != nil { return nil, "", fmt.Errorf("failed to register grpc gateway: %w", err) } - return addInterceptors(runtimeMux, http1HostName, cookieHandler, cookieConfig, queries), g.GatewayPathPrefix(), nil + return addInterceptors(runtimeMux, http1HostName, accessInterceptor, queries), g.GatewayPathPrefix(), nil } -func CreateGateway(ctx context.Context, port uint16, http1HostName string, cookieHandler *http_utils.CookieHandler, cookieConfig *http_mw.AccessConfig) (*Gateway, error) { +func CreateGateway(ctx context.Context, port uint16, http1HostName string, accessInterceptor *http_mw.AccessInterceptor) (*Gateway, error) { connection, err := dial(ctx, port, []grpc.DialOption{ @@ -121,11 +118,10 @@ func CreateGateway(ctx context.Context, port uint16, http1HostName string, cooki } runtimeMux := runtime.NewServeMux(append(serveMuxOptions, runtime.WithHealthzEndpoint(healthpb.NewHealthClient(connection)))...) return &Gateway{ - mux: runtimeMux, - http1HostName: http1HostName, - connection: connection, - cookieHandler: cookieHandler, - cookieConfig: cookieConfig, + mux: runtimeMux, + http1HostName: http1HostName, + connection: connection, + accessInterceptor: accessInterceptor, }, nil } @@ -163,8 +159,7 @@ func dial(ctx context.Context, port uint16, opts []grpc.DialOption) (*grpc.Clien func addInterceptors( handler http.Handler, http1HostName string, - cookieHandler *http_utils.CookieHandler, - cookieConfig *http_mw.AccessConfig, + accessInterceptor *http_mw.AccessInterceptor, queries *query.Queries, ) http.Handler { handler = http_mw.CallDurationHandler(handler) @@ -174,7 +169,7 @@ func addInterceptors( handler = http_mw.DefaultTelemetryHandler(handler) // For some non-obvious reason, the exhaustedCookieInterceptor sends the SetCookie header // only if it follows the http_mw.DefaultTelemetryHandler - handler = exhaustedCookieInterceptor(handler, cookieHandler, cookieConfig, queries) + handler = exhaustedCookieInterceptor(handler, accessInterceptor, queries) handler = http_mw.DefaultMetricsHandler(handler) return handler } @@ -193,35 +188,32 @@ func http1Host(next http.Handler, http1HostName string) http.Handler { func exhaustedCookieInterceptor( next http.Handler, - cookieHandler *http_utils.CookieHandler, - cookieConfig *http_mw.AccessConfig, + accessInterceptor *http_mw.AccessInterceptor, queries *query.Queries, ) http.Handler { return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { next.ServeHTTP(&cookieResponseWriter{ - ResponseWriter: writer, - cookieHandler: cookieHandler, - cookieConfig: cookieConfig, - request: request, - queries: queries, + ResponseWriter: writer, + accessInterceptor: accessInterceptor, + request: request, + queries: queries, }, request) }) } type cookieResponseWriter struct { http.ResponseWriter - cookieHandler *http_utils.CookieHandler - cookieConfig *http_mw.AccessConfig - request *http.Request - queries *query.Queries + accessInterceptor *http_mw.AccessInterceptor + request *http.Request + queries *query.Queries } func (r *cookieResponseWriter) WriteHeader(status int) { if status >= 200 && status < 300 { - http_mw.DeleteExhaustedCookie(r.cookieHandler, r.ResponseWriter, r.request, r.cookieConfig) + r.accessInterceptor.DeleteExhaustedCookie(r.ResponseWriter, r.request) } if status == http.StatusTooManyRequests { - http_mw.SetExhaustedCookie(r.cookieHandler, r.ResponseWriter, r.cookieConfig, r.request) + r.accessInterceptor.SetExhaustedCookie(r.ResponseWriter, r.request) } r.ResponseWriter.WriteHeader(status) } diff --git a/internal/api/http/middleware/access_interceptor.go b/internal/api/http/middleware/access_interceptor.go index cf52a597d6..469fdd16d7 100644 --- a/internal/api/http/middleware/access_interceptor.go +++ b/internal/api/http/middleware/access_interceptor.go @@ -1,6 +1,7 @@ package middleware import ( + "context" "net" "net/http" "net/url" @@ -32,15 +33,54 @@ type AccessConfig struct { // NewAccessInterceptor intercepts all requests and stores them to the logstore. // If storeOnly is false, it also checks if requests are exhausted. // If requests are exhausted, it also returns http.StatusTooManyRequests and sets a cookie -func NewAccessInterceptor(svc *logstore.Service, cookieHandler *http_utils.CookieHandler, cookieConfig *AccessConfig, storeOnly bool) *AccessInterceptor { +func NewAccessInterceptor(svc *logstore.Service, cookieHandler *http_utils.CookieHandler, cookieConfig *AccessConfig) *AccessInterceptor { return &AccessInterceptor{ svc: svc, cookieHandler: cookieHandler, limitConfig: cookieConfig, - storeOnly: storeOnly, } } +func (a *AccessInterceptor) WithoutLimiting() *AccessInterceptor { + return &AccessInterceptor{ + svc: a.svc, + cookieHandler: a.cookieHandler, + limitConfig: a.limitConfig, + storeOnly: true, + } +} + +func (a *AccessInterceptor) AccessService() *logstore.Service { + return a.svc +} + +func (a *AccessInterceptor) Limit(ctx context.Context) bool { + if !a.svc.Enabled() || a.storeOnly { + return false + } + instance := authz.GetInstance(ctx) + remaining := a.svc.Limit(ctx, instance.InstanceID()) + return remaining != nil && *remaining <= 0 +} + +func (a *AccessInterceptor) SetExhaustedCookie(writer http.ResponseWriter, request *http.Request) { + cookieValue := "true" + host := request.Header.Get(middleware.HTTP1Host) + domain := host + if strings.ContainsAny(host, ":") { + var err error + domain, _, err = net.SplitHostPort(host) + if err != nil { + logging.WithError(err).WithField("host", host).Warning("failed to extract cookie domain from request host") + } + } + a.cookieHandler.SetCookie(writer, a.limitConfig.ExhaustedCookieKey, domain, cookieValue) +} + +func (a *AccessInterceptor) DeleteExhaustedCookie(writer http.ResponseWriter, request *http.Request) { + a.cookieHandler.DeleteCookie(writer, request, a.limitConfig.ExhaustedCookieKey) +} + func (a *AccessInterceptor) Handle(next http.Handler) http.Handler { if !a.svc.Enabled() { return next @@ -49,23 +89,16 @@ func (a *AccessInterceptor) Handle(next http.Handler) http.Handler { ctx := request.Context() tracingCtx, checkSpan := tracing.NewNamedSpan(ctx, "checkAccess") wrappedWriter := &statusRecorder{ResponseWriter: writer, status: 0} - instance := authz.GetInstance(ctx) - limit := false - if !a.storeOnly { - remaining := a.svc.Limit(tracingCtx, instance.InstanceID()) - limit = remaining != nil && *remaining == 0 - } + limited := a.Limit(tracingCtx) checkSpan.End() - if limit { - // Limit can only be true when storeOnly is false, so set the cookie and the response code - SetExhaustedCookie(a.cookieHandler, wrappedWriter, a.limitConfig, request) + if limited { + a.SetExhaustedCookie(wrappedWriter, request) http.Error(wrappedWriter, "quota for authenticated requests is exhausted", http.StatusTooManyRequests) - } else { - if !a.storeOnly { - // If not limited and not storeOnly, ensure the cookie is deleted - DeleteExhaustedCookie(a.cookieHandler, wrappedWriter, request, a.limitConfig) - } - // Always serve if not limited + } + if !limited && !a.storeOnly { + a.DeleteExhaustedCookie(wrappedWriter, request) + } + if !limited { next.ServeHTTP(wrappedWriter, request) } tracingCtx, writeSpan := tracing.NewNamedSpan(tracingCtx, "writeAccess") @@ -75,6 +108,7 @@ func (a *AccessInterceptor) Handle(next http.Handler) http.Handler { if err != nil { logging.WithError(err).WithField("url", requestURL).Warning("failed to unescape request url") } + instance := authz.GetInstance(tracingCtx) a.svc.Handle(tracingCtx, &access.Record{ LogDate: time.Now(), Protocol: access.HTTP, @@ -90,24 +124,6 @@ func (a *AccessInterceptor) Handle(next http.Handler) http.Handler { }) } -func SetExhaustedCookie(cookieHandler *http_utils.CookieHandler, writer http.ResponseWriter, cookieConfig *AccessConfig, request *http.Request) { - cookieValue := "true" - host := request.Header.Get(middleware.HTTP1Host) - domain := host - if strings.ContainsAny(host, ":") { - var err error - domain, _, err = net.SplitHostPort(host) - if err != nil { - logging.WithError(err).WithField("host", host).Warning("failed to extract cookie domain from request host") - } - } - cookieHandler.SetCookie(writer, cookieConfig.ExhaustedCookieKey, domain, cookieValue) -} - -func DeleteExhaustedCookie(cookieHandler *http_utils.CookieHandler, writer http.ResponseWriter, request *http.Request, cookieConfig *AccessConfig) { - cookieHandler.DeleteCookie(writer, request, cookieConfig.ExhaustedCookieKey) -} - type statusRecorder struct { http.ResponseWriter status int diff --git a/internal/api/ui/console/console.go b/internal/api/ui/console/console.go index 45503ca0c1..1980f6cc5f 100644 --- a/internal/api/ui/console/console.go +++ b/internal/api/ui/console/console.go @@ -91,7 +91,7 @@ func (f *file) Stat() (_ fs.FileInfo, err error) { return f, nil } -func Start(config Config, externalSecure bool, issuer op.IssuerFromRequest, callDurationInterceptor, instanceHandler, accessInterceptor func(http.Handler) http.Handler, customerPortal string) (http.Handler, error) { +func Start(config Config, externalSecure bool, issuer op.IssuerFromRequest, callDurationInterceptor, instanceHandler func(http.Handler) http.Handler, limitingAccessInterceptor *middleware.AccessInterceptor, customerPortal string) (http.Handler, error) { fSys, err := fs.Sub(static, "static") if err != nil { return nil, err @@ -106,10 +106,11 @@ func Start(config Config, externalSecure bool, issuer op.IssuerFromRequest, call handler := mux.NewRouter() - handler.Use(callDurationInterceptor, instanceHandler, security, accessInterceptor) + handler.Use(callDurationInterceptor, instanceHandler, security, limitingAccessInterceptor.WithoutLimiting().Handle) handler.Handle(envRequestPath, middleware.TelemetryHandler()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { url := http_util.BuildOrigin(r.Host, externalSecure) - instance := authz.GetInstance(r.Context()) + ctx := r.Context() + instance := authz.GetInstance(ctx) instanceMgmtURL, err := templateInstanceManagementURL(config.InstanceManagementURL, instance) if err != nil { http.Error(w, fmt.Sprintf("unable to template instance management url for console: %v", err), http.StatusInternalServerError) @@ -120,6 +121,11 @@ func Start(config Config, externalSecure bool, issuer op.IssuerFromRequest, call http.Error(w, fmt.Sprintf("unable to marshal env for console: %v", err), http.StatusInternalServerError) return } + if limitingAccessInterceptor.Limit(ctx) { + limitingAccessInterceptor.SetExhaustedCookie(w, r) + } else { + limitingAccessInterceptor.DeleteExhaustedCookie(w, r) + } _, err = w.Write(environmentJSON) logging.OnError(err).Error("error serving environment.json") }))) From a21d184790548fdb9cca92717df57c35477d1a41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?kotori=E3=81=AE=E3=81=AD=E3=81=93?= Date: Mon, 15 May 2023 02:23:56 -0500 Subject: [PATCH 15/16] docs(nginx): fix nginx directory, system api proto description (#5809) Co-authored-by: Elio Bischof --- docs/nginx.conf | 6 +++--- proto/zitadel/system.proto | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/nginx.conf b/docs/nginx.conf index 1af667f386..7b64550084 100644 --- a/docs/nginx.conf +++ b/docs/nginx.conf @@ -15,7 +15,7 @@ http { } location /docs { - root /usr/share/nginx/html; + alias /usr/share/nginx/html; index /docs/index.html; try_files $uri $uri/ /docs/index.html?q=$query_string; } @@ -23,7 +23,7 @@ http { location = /docs/proxy/js/script.js { proxy_pass https://plausible.io/js/script.js; proxy_set_header Host plausible.io; - } + } location = /docs/proxy/api/event { proxy_pass https://plausible.io/api/event; @@ -53,4 +53,4 @@ http { application/xml application/json application/ld+json; -} \ No newline at end of file +} diff --git a/proto/zitadel/system.proto b/proto/zitadel/system.proto index b34977e621..4bfec35e6e 100644 --- a/proto/zitadel/system.proto +++ b/proto/zitadel/system.proto @@ -229,7 +229,7 @@ service SystemService { }; } - // Returns the domain of an instance + // Removes the domain of an instance rpc RemoveDomain(RemoveDomainRequest) returns (RemoveDomainResponse) { option (google.api.http) = { delete: "/instances/{instance_id}/domains/{domain}"; @@ -240,7 +240,7 @@ service SystemService { }; } - // Returns the domain of an instance + // Sets the primary domain of an instance rpc SetPrimaryDomain(SetPrimaryDomainRequest) returns (SetPrimaryDomainResponse) { option (google.api.http) = { post: "/instances/{instance_id}/domains/_set_primary"; From d78b273b44dcf70f6f3eb0c4aa13d548f2090647 Mon Sep 17 00:00:00 2001 From: mffap Date: Mon, 15 May 2023 14:30:05 +0200 Subject: [PATCH 16/16] docs(contributing): don't repeat yourself (#5869) --- CONTRIBUTING.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6d35289418..35c27ef3b6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -338,6 +338,7 @@ Please refer to the [README](./docs/README.md) for more information and local te - **Code with variables**: Make sure that code snippets can be used by setting environment variables, instead of manually replacing a placeholder. - **Embedded files**: When embedding mdx files, make sure the template ist prefixed by "_" (lowdash). The content will be rendered inside the parent page, but is not accessible individually (eg, by search). +- **Don't repeat yourself**: When using the same content in multiple places, save and manage the content as separate file and make use of embedded files to import it into other docs pages. - **Embedded code**: You can embed code snippets from a repository. See the [plugin](https://github.com/saucelabs/docusaurus-theme-github-codeblock#usage) for usage. ### Docs Pull Request