diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 1272432be4..b59ca669c3 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,5 +1,3 @@ -```[tasklist] - ### Definition of Ready - [ ] I am happy with the code @@ -14,4 +12,3 @@ - [ ] Documentation/examples are up-to-date - [ ] All non-functional requirements are met - [ ] Functionality of the acceptance criteria is checked manually on the dev system. -``` diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml new file mode 100644 index 0000000000..8f7ba48a11 --- /dev/null +++ b/.github/workflows/integration.yml @@ -0,0 +1,51 @@ +name: Integration tests + +on: + push: + tags-ignore: + - '**' + pull_request: + branches: + - '**' + +jobs: + run: + strategy: + matrix: + db: [cockroach, postgres] + runs-on: ubuntu-20.04 + env: + DOCKER_BUILDKIT: 1 + INTEGRATION_DB_FLAVOR: ${{ matrix.db }} + ZITADEL_MASTERKEY: MasterkeyNeedsToHave32Characters + steps: + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version: 1.19 + - name: Source checkout + uses: actions/checkout@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + with: + driver: docker + install: true + - name: Generate gRPC definitions + run: docker build -f build/grpc/Dockerfile -t zitadel-base:local . + - name: Copy gRPC definitions + run: docker build -f build/zitadel/Dockerfile . -t zitadel-go-base --target go-copy -o . + - name: Download Go modules + run: go mod download + - name: Start ${{ matrix.db }} database + run: docker compose -f internal/integration/config/docker-compose.yaml up --wait ${INTEGRATION_DB_FLAVOR} + - name: Run zitadel init and setup + run: | + go run main.go init --config internal/integration/config/zitadel.yaml --config internal/integration/config/${INTEGRATION_DB_FLAVOR}.yaml + go run main.go setup --masterkeyFromEnv --config internal/integration/config/zitadel.yaml --config internal/integration/config/${INTEGRATION_DB_FLAVOR}.yaml + - name: Run integration tests + run: go test -tags=integration -race -parallel 1 -v -coverprofile=profile.cov -coverpkg=./... ./internal/integration ./internal/api/grpc/... + - name: Publish go coverage + uses: codecov/codecov-action@v3.1.0 + with: + file: profile.cov + name: integration-tests diff --git a/.github/workflows/test-code.yml b/.github/workflows/test-code.yml index 7a4793dbe0..b681d3b2da 100644 --- a/.github/workflows/test-code.yml +++ b/.github/workflows/test-code.yml @@ -44,7 +44,7 @@ jobs: uses: codecov/codecov-action@v3.1.0 with: file: .artifacts/codecov/profile.cov - name: go-codecov + name: unit-tests # As goreleaser doesn't build a dockerfile in snapshot mode, we have to build it here - name: Build Docker Image run: docker build -t zitadel:pr --file build/Dockerfile .artifacts/zitadel diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 60936beb7d..6d35289418 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -199,6 +199,21 @@ When you are happy with your changes, you can cleanup your environment. docker compose --file ./e2e/config/host.docker.internal/docker-compose.yaml down ``` +#### Integration tests + +In order to run the integrations tests for the gRPC API, PostgreSQL and CockroachDB must be started and initialized: + +```bash +export INTEGRATION_DB_FLAVOR="cockroach" ZITADEL_MASTERKEY="MasterkeyNeedsToHave32Characters" +docker compose -f internal/integration/config/docker-compose.yaml up --wait ${INTEGRATION_DB_FLAVOR} +go run main.go init --config internal/integration/config/zitadel.yaml --config internal/integration/config/${INTEGRATION_DB_FLAVOR}.yaml +go run main.go setup --masterkeyFromEnv --config internal/integration/config/zitadel.yaml --config internal/integration/config/${INTEGRATION_DB_FLAVOR}.yaml +go test -tags=integration -race -parallel 1 ./internal/integration ./internal/api/grpc/... +docker compose -f internal/integration/config/docker-compose.yaml down +``` + +The above can be repeated with `INTEGRATION_DB_FLAVOR="postgres"`. + ### Console By executing the commands from this section, you run everything you need to develop the console locally. @@ -315,13 +330,15 @@ docker compose down Project documentation is made with docusaurus and is located under [./docs](./docs). -### Local Testing +### Local Testing + Please refer to the [README](./docs/README.md) for more information and local testing. -### Style Guide +### Style Guide - **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). +- **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 When making a pull request use `docs(): ` as title for the semantic release. diff --git a/build/zitadel/generate-grpc.sh b/build/zitadel/generate-grpc.sh index 6f85200da3..891125cb6a 100755 --- a/build/zitadel/generate-grpc.sh +++ b/build/zitadel/generate-grpc.sh @@ -18,8 +18,9 @@ protoc \ --validate_out=lang=go:${GOPATH}/src \ $(find ${PROTO_PATH} -iname *.proto) -# install authoption proto compiler +# install authoption and zitadel proto compiler go install ${ZITADEL_PATH}/internal/protoc/protoc-gen-auth +go install ${ZITADEL_PATH}/internal/protoc/protoc-gen-zitadel # output folder for openapi v2 mkdir -p ${OPENAPI_PATH} @@ -79,7 +80,7 @@ protoc \ --openapiv2_out ${OPENAPI_PATH} \ --openapiv2_opt logtostderr=true \ --openapiv2_opt allow_delete_body=true \ - --auth_out=${GOPATH}/src \ + --zitadel_out=${GOPATH}/src \ --validate_out=lang=go:${GOPATH}/src \ ${PROTO_PATH}/user/v2alpha/user_service.proto @@ -91,7 +92,7 @@ protoc \ --openapiv2_out ${OPENAPI_PATH} \ --openapiv2_opt logtostderr=true \ --openapiv2_opt allow_delete_body=true \ - --auth_out=${GOPATH}/src \ + --zitadel_out=${GOPATH}/src \ --validate_out=lang=go:${GOPATH}/src \ ${PROTO_PATH}/session/v2alpha/session_service.proto diff --git a/cmd/admin/admin.go b/cmd/admin/admin.go index 785ed1abf0..04741a4a88 100644 --- a/cmd/admin/admin.go +++ b/cmd/admin/admin.go @@ -26,8 +26,8 @@ func New() *cobra.Command { adminCMD.AddCommand( initialise.New(), setup.New(), - start.New(), - start.NewStartFromInit(), + start.New(nil), + start.NewStartFromInit(nil), key.New(), ) diff --git a/cmd/setup/03.go b/cmd/setup/03.go index 9774ab7b7b..416010946a 100644 --- a/cmd/setup/03.go +++ b/cmd/setup/03.go @@ -77,6 +77,7 @@ func (mig *FirstInstance) Execute(ctx context.Context) error { nil, nil, nil, + nil, ) if err != nil { diff --git a/cmd/setup/config_change.go b/cmd/setup/config_change.go index f112dd8c7b..14a6849cea 100644 --- a/cmd/setup/config_change.go +++ b/cmd/setup/config_change.go @@ -52,6 +52,7 @@ func (mig *externalConfigChange) Execute(ctx context.Context) error { nil, nil, nil, + nil, ) if err != nil { diff --git a/cmd/start/start.go b/cmd/start/start.go index e0829b7eb3..6f248c1083 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -21,6 +21,7 @@ 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" @@ -38,6 +39,7 @@ import ( http_util "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/api/http/middleware" "github.com/zitadel/zitadel/internal/api/oidc" + "github.com/zitadel/zitadel/internal/api/robots_txt" "github.com/zitadel/zitadel/internal/api/saml" "github.com/zitadel/zitadel/internal/api/ui/console" "github.com/zitadel/zitadel/internal/api/ui/login" @@ -45,8 +47,10 @@ import ( "github.com/zitadel/zitadel/internal/authz" authz_repo "github.com/zitadel/zitadel/internal/authz/repository" "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/crypto" cryptoDB "github.com/zitadel/zitadel/internal/crypto/database" "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/id" "github.com/zitadel/zitadel/internal/logstore" @@ -60,7 +64,7 @@ import ( "github.com/zitadel/zitadel/openapi" ) -func New() *cobra.Command { +func New(server chan<- *Server) *cobra.Command { start := &cobra.Command{ Use: "start", Short: "starts ZITADEL instance", @@ -78,7 +82,7 @@ Requirements: return err } - return startZitadel(config, masterKey) + return startZitadel(config, masterKey, server) }, } @@ -87,7 +91,23 @@ Requirements: return start } -func startZitadel(config *Config, masterKey string) error { +type Server struct { + Config *Config + DB *database.DB + KeyStorage crypto.KeyStorage + Keys *encryptionKeys + Eventstore *eventstore.Eventstore + Queries *query.Queries + AuthzRepo authz_repo.Repository + Storage static.Storage + Commands *command.Commands + LogStore *logstore.Service + Router *mux.Router + TLSConfig *tls.Config + Shutdown chan<- os.Signal +} + +func startZitadel(config *Config, masterKey string, server chan<- *Server) error { ctx := context.Background() dbClient, err := database.Connect(config.Database, false) @@ -110,7 +130,21 @@ func startZitadel(config *Config, masterKey string) error { return fmt.Errorf("cannot start eventstore for queries: %w", err) } - queries, err := query.StartQueries(ctx, eventstoreClient, dbClient, config.Projections, config.SystemDefaults, keys.IDPConfig, keys.OTP, keys.OIDC, keys.SAML, config.InternalAuthZ.RolePermissionMappings) + sessionTokenVerifier := internal_authz.SessionTokenVerifier(keys.OIDC) + + queries, err := query.StartQueries( + ctx, + eventstoreClient, + dbClient, + config.Projections, + config.SystemDefaults, + keys.IDPConfig, + keys.OTP, + keys.OIDC, + keys.SAML, + config.InternalAuthZ.RolePermissionMappings, + sessionTokenVerifier, + ) if err != nil { return fmt.Errorf("cannot start queries: %w", err) } @@ -119,6 +153,9 @@ func startZitadel(config *Config, masterKey string) error { if err != nil { return fmt.Errorf("error starting authz repo: %w", err) } + permissionCheck := func(ctx context.Context, permission, orgID, resourceID string) (err error) { + return internal_authz.CheckPermission(ctx, authZRepo, config.InternalAuthZ.RolePermissionMappings, permission, orgID, resourceID) + } storage, err := config.AssetStorage.NewStorage(dbClient.DB) if err != nil { @@ -146,7 +183,8 @@ func startZitadel(config *Config, masterKey string) error { keys.OIDC, keys.SAML, &http.Client{}, - authZRepo, + permissionCheck, + sessionTokenVerifier, ) if err != nil { return fmt.Errorf("cannot start commands: %w", err) @@ -176,11 +214,49 @@ func startZitadel(config *Config, masterKey string) error { if err != nil { return err } - err = startAPIs(ctx, clock, router, commands, queries, eventstoreClient, dbClient, config, storage, authZRepo, keys, queries, usageReporter) + err = startAPIs( + ctx, + clock, + router, + commands, + queries, + eventstoreClient, + dbClient, + config, + storage, + authZRepo, + keys, + queries, + usageReporter, + permissionCheck, + ) if err != nil { return err } - return listen(ctx, router, config.Port, tlsConfig) + + shutdown := make(chan os.Signal, 1) + signal.Notify(shutdown, os.Interrupt, syscall.SIGTERM) + + if server != nil { + server <- &Server{ + Config: config, + DB: dbClient, + KeyStorage: keyStorage, + Keys: keys, + Eventstore: eventstoreClient, + Queries: queries, + AuthzRepo: authZRepo, + Storage: storage, + Commands: commands, + LogStore: actionsLogstoreSvc, + Router: router, + TLSConfig: tlsConfig, + Shutdown: shutdown, + } + close(server) + } + + return listen(ctx, router, config.Port, tlsConfig, shutdown) } func startAPIs( @@ -197,6 +273,7 @@ func startAPIs( keys *encryptionKeys, quotaQuerier logstore.QuotaQuerier, usageReporter logstore.UsageReporter, + permissionCheck domain.PermissionCheck, ) error { repo := struct { authz_repo.Repository @@ -252,7 +329,7 @@ func startAPIs( if err := apis.RegisterService(ctx, user.CreateServer(commands, queries, keys.User)); err != nil { return err } - if err := apis.RegisterService(ctx, session.CreateServer(commands, queries)); err != nil { + if err := apis.RegisterService(ctx, session.CreateServer(commands, queries, permissionCheck)); err != nil { return err } instanceInterceptor := middleware.InstanceInterceptor(queries, config.HTTP1HostHeader, login.IgnoreInstanceEndpoints...) @@ -264,6 +341,13 @@ func startAPIs( return err } + // robots.txt handler + robotsTxtHandler, err := robots_txt.Start() + if err != nil { + return fmt.Errorf("unable to start robots txt handler: %w", err) + } + apis.RegisterHandlerOnPrefix(robots_txt.HandlerPrefix, robotsTxtHandler) + // TODO: Record openapi access logs? openAPIHandler, err := openapi.Start() if err != nil { @@ -301,10 +385,21 @@ func startAPIs( return nil } -func listen(ctx context.Context, router *mux.Router, port uint16, tlsConfig *tls.Config) error { +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} - lis, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) + + lc := &net.ListenConfig{Control: reusePort} + lis, err := lc.Listen(ctx, "tcp", fmt.Sprintf(":%d", port)) if err != nil { return fmt.Errorf("tcp listener on %d failed: %w", port, err) } @@ -321,9 +416,6 @@ func listen(ctx context.Context, router *mux.Router, port uint16, tlsConfig *tls } }() - shutdown := make(chan os.Signal, 1) - signal.Notify(shutdown, os.Interrupt, syscall.SIGTERM) - select { case err := <-errCh: return fmt.Errorf("error starting server: %w", err) diff --git a/cmd/start/start_from_init.go b/cmd/start/start_from_init.go index 89c74d5592..940efb4e84 100644 --- a/cmd/start/start_from_init.go +++ b/cmd/start/start_from_init.go @@ -11,7 +11,7 @@ import ( "github.com/zitadel/zitadel/cmd/tls" ) -func NewStartFromInit() *cobra.Command { +func NewStartFromInit(server chan<- *Server) *cobra.Command { cmd := &cobra.Command{ Use: "start-from-init", Short: "cold starts zitadel", @@ -37,7 +37,7 @@ Requirements: startConfig := MustNewConfig(viper.GetViper()) - err = startZitadel(startConfig, masterKey) + err = startZitadel(startConfig, masterKey, server) logging.OnError(err).Fatal("unable to start zitadel") }, } diff --git a/cmd/start/start_from_setup.go b/cmd/start/start_from_setup.go index 75c2347c2b..0be315fae9 100644 --- a/cmd/start/start_from_setup.go +++ b/cmd/start/start_from_setup.go @@ -10,7 +10,7 @@ import ( "github.com/zitadel/zitadel/cmd/tls" ) -func NewStartFromSetup() *cobra.Command { +func NewStartFromSetup(server chan<- *Server) *cobra.Command { cmd := &cobra.Command{ Use: "start-from-setup", Short: "cold starts zitadel", @@ -35,7 +35,7 @@ Requirements: startConfig := MustNewConfig(viper.GetViper()) - err = startZitadel(startConfig, masterKey) + err = startZitadel(startConfig, masterKey, server) logging.OnError(err).Fatal("unable to start zitadel") }, } diff --git a/cmd/zitadel.go b/cmd/zitadel.go index 9822e1da05..0c839f2678 100644 --- a/cmd/zitadel.go +++ b/cmd/zitadel.go @@ -26,7 +26,7 @@ var ( defaultConfig []byte ) -func New(out io.Writer, in io.Reader, args []string) *cobra.Command { +func New(out io.Writer, in io.Reader, args []string, server chan<- *start.Server) *cobra.Command { cmd := &cobra.Command{ Use: "zitadel", Short: "The ZITADEL CLI lets you interact with ZITADEL", @@ -51,9 +51,9 @@ func New(out io.Writer, in io.Reader, args []string) *cobra.Command { admin.New(), //is now deprecated, remove later on initialise.New(), setup.New(), - start.New(), - start.NewStartFromInit(), - start.NewStartFromSetup(), + start.New(server), + start.NewStartFromInit(server), + start.NewStartFromSetup(server), key.New(), ) diff --git a/console/src/index.html b/console/src/index.html index 14dc2035eb..fe500d8d28 100644 --- a/console/src/index.html +++ b/console/src/index.html @@ -21,6 +21,7 @@ + diff --git a/docs/docs/apis/ratelimits/ratelimits.md b/docs/docs/apis/ratelimits/ratelimits.md deleted file mode 100644 index d01b53b7e4..0000000000 --- a/docs/docs/apis/ratelimits/ratelimits.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: ZITADEL Cloud Rate Limits ---- - -Rate limits are implemented according to our [rate limit policy](/legal/rate-limit-policy.md) with the following rules: - -| Path | Description | Rate Limiting | One Minute Banning | -|--------------------------|----------------------------------------|--------------------------------------|----------------------------------------| -| /ui/login* | Global Login, Register and Reset Limit | 10 requests per second over a minute | 15 requests per second over 3 minutes | -| All other paths | All gRPC- and REST APIs as well as the ZITADEL Customer Portal | 10 requests per second over a minute | 10 requests per second over 3 minutes | diff --git a/docs/docs/concepts/features/audit-trail.md b/docs/docs/concepts/features/audit-trail.md new file mode 100644 index 0000000000..8b7136a22a --- /dev/null +++ b/docs/docs/concepts/features/audit-trail.md @@ -0,0 +1,64 @@ +--- +title: Audit Trail +--- + +ZITADEL provides you with an built-in audit trail to track all changes and events over an unlimited period of time. +Most other solutions replace a historic record and track changes in a separate log when information is updated. +ZITADEL only ever appends data in an [Eventstore](https://zitadel.com/docs/concepts/eventstore), keeping all historic record. +The audit trail itself is identical to the state, since ZITADEL calculates the state from all the past changes. + +![Example of events that happen for a profile change and a login](/img/concepts/audit-trail/audit-log-events.png) + +This form of audit log has several benefits over storing classic audit logs. +You can view past data in-context of the whole system at a single point in time. +Reviewing a past state of the application can be important when tracing an incident that happened months back. Moreover the eventstore provides a truly complete and clean audit log. + +## Accessing the Audit Log + +### Last changes of an object + +You can check the last changes of most objects in the [Console](/docs/guides/manage/console/overview). +In the following screenshot you can see an example of last changes on an [user](/docs/guides/manage/console/users). +The same view is available on several other objects such as organization or project. + +![Profile Self Manage](/img/guides/console/myprofile.png) + +### Event View + +Administrators can see all events across an instance and filter them directly in [Console](/docs/guides/manage/console/overview). +Go to your instance settings and then click on the Tab **Events** to open the Event Viewer or browse to $YOUR_DOMAIN/ui/console/events + +![Event viewer](/img/concepts/audit-trail/event-viewer.png) + +### Event API + +Since everything that is available in Console can also be called with our APIs, you can access all events and audit data trough our APIs: + +- [Event API Guide](/docs/guides/integrate/event-api) +- [API Documentation](/docs/category/apis/admin/events) + +Access to the API is possible with a [Service User](/docs/guides/integrate/serviceusers) account, allowing you to integrate the events with your own business logic. + +## Using logs in external systems + +You can use the [Event API](#event-api) to pull data and ingest it in an external system. + +[Actions](actions.md) can be used to write events to the stdout and [process the events as logs](../../self-hosting/manage/production#logging). +Please refer to the zitadel/actions repository for a [code sample](https://github.com/zitadel/actions/blob/main/examples/post_auth_log.js). +You can use your log processing pipeline to parse and ingest the events in your favorite analytics tool. + +It is possible to send events directly with an http request to an external tool. +We don't recommend this approach since this would create back-pressure and increase the overall processing time for requests. + +:::info Scope of Actions +At this moment Actions can be invoked on certain events, but not generally on every event. +This is not a technical limitation, but a [feature on our backlog](https://github.com/zitadel/zitadel/issues/5101). +::: + +## Future plans + +There will be three major areas for future development on the audit data + +- [Metrics](https://github.com/zitadel/zitadel/issues/4458) and [standard reports](https://github.com/zitadel/zitadel/discussions/2162#discussioncomment-1153259) +- [Feedback loop](https://github.com/zitadel/zitadel/issues/5102) and threat detection +- Forensics and replay of events diff --git a/docs/docs/concepts/structure/instance.mdx b/docs/docs/concepts/structure/instance.mdx index 825af63be0..ce4eb99530 100644 --- a/docs/docs/concepts/structure/instance.mdx +++ b/docs/docs/concepts/structure/instance.mdx @@ -14,6 +14,8 @@ which in turn can represent your own company (e.g. departments), your business c Read more about how to configure your instance in our [instance guide](/guides/manage/console/instance-settings). +![Two instances with each organizations in it using the same database](/img/concepts/objects/instances.png) + ## Multiple Virtual Instances ZITADEL has the concept of virtual instances. diff --git a/docs/docs/examples/call-zitadel-api/dot-net.md b/docs/docs/examples/call-zitadel-api/dot-net.md index 8aeafc563a..31e7ce7cfc 100644 --- a/docs/docs/examples/call-zitadel-api/dot-net.md +++ b/docs/docs/examples/call-zitadel-api/dot-net.md @@ -17,7 +17,7 @@ All that is required, is a service account with an Org Owner (or another role, d However, we recommend you read the guide on [how to access ZITADEL API](../../guides/integrate/access-zitadel-apis) and the associated guides for a basic knowledge of : - [Recommended Authorization Flows](../../guides/integrate/oauth-recommended-flows.md) - - [Service Users](../../guides/integrate/serviceusers.md) + - [Service Users](../../guides/integrate/serviceusers) > Be sure to have a valid key JSON and that its service account is either ORG_OWNER or at least ORG_OWNER_VIEWER before you continue with this guide. diff --git a/docs/docs/examples/call-zitadel-api/go.md b/docs/docs/examples/call-zitadel-api/go.md index eebef207bb..52435240df 100644 --- a/docs/docs/examples/call-zitadel-api/go.md +++ b/docs/docs/examples/call-zitadel-api/go.md @@ -14,7 +14,7 @@ All that is required, is a service account with an Org Owner (or another role, d However, we recommend you read the guide on [how to access ZITADEL API](../../guides/integrate/access-zitadel-apis) and the associated guides for a basic knowledge of : - [Recommended Authorization Flows](../../guides/integrate/oauth-recommended-flows.md) - - [Service Users](../../guides/integrate/serviceusers.md) + - [Service Users](../../guides/integrate/serviceusers) > Be sure to have a valid key JSON and that its service account is either ORG_OWNER or at least ORG_OWNER_VIEWER before you continue with this guide. diff --git a/docs/docs/guides/integrate/access-zitadel-apis.md b/docs/docs/guides/integrate/access-zitadel-apis.md index 77f39392f9..2509c07243 100644 --- a/docs/docs/guides/integrate/access-zitadel-apis.md +++ b/docs/docs/guides/integrate/access-zitadel-apis.md @@ -19,7 +19,7 @@ On each level we have some different Roles. Here you can find more about the dif ## Add ORG_OWNER to Service User -Make sure you have a Service User with a Key. (For more detailed informations about creating a service user go to [Service User](serviceusers.md)) +Make sure you have a Service User with a Key. (For more detailed informations about creating a service user go to [Service User](serviceusers)) 1. Navigate to Organization Detail 2. Click the **+** button in the right part of console, in the managers part of details @@ -31,7 +31,7 @@ Make sure you have a Service User with a Key. (For more detailed informations ab ## Authenticating a service user In ZITADEL we use the `urn:ietf:params:oauth:grant-type:jwt-bearer` (**“JWT bearer token with private key”**, [RFC7523](https://tools.ietf.org/html/rfc7523)) authorization grant for this non-interactive authentication. -This is already described in the [Service User](serviceusers.md), so make sure you follow this guide. +This is already described in the [Service User](./serviceusers), so make sure you follow this guide. ### Request an OAuth token, with audience for ZITADEL diff --git a/docs/docs/guides/integrate/access-zitadel-system-api.md b/docs/docs/guides/integrate/access-zitadel-system-api.md index 1f6d87c8a1..e8297b64ae 100644 --- a/docs/docs/guides/integrate/access-zitadel-system-api.md +++ b/docs/docs/guides/integrate/access-zitadel-system-api.md @@ -86,7 +86,7 @@ If your system is exposed without TLS or on a dedicated port, be sure to provide If you want to manually create a JWT for a test, you can also use our [ZITADEL Tools](https://github.com/zitadel/zitadel-tools). Download the latest release and run: ```bash -./key2jwt -audience=https://custom-domain.com -key=system-user-1.pem -issuer=system-user-1 +zitadel-tools key2jwt --audience=https://custom-domain.com --key=system-user-1.pem --issuer=system-user-1 ``` ## Call the System API diff --git a/docs/docs/guides/integrate/serviceusers.md b/docs/docs/guides/integrate/private-key-jwt.md similarity index 99% rename from docs/docs/guides/integrate/serviceusers.md rename to docs/docs/guides/integrate/private-key-jwt.md index 278e4d9083..a02f48b376 100644 --- a/docs/docs/guides/integrate/serviceusers.md +++ b/docs/docs/guides/integrate/private-key-jwt.md @@ -1,5 +1,5 @@ --- -title: Service Users +title: Private Key JWT --- This is a guide on how to create service users in ZITADEL. You can read more about users [here](/concepts/structure/users.md). diff --git a/docs/docs/self-hosting/manage/production.md b/docs/docs/self-hosting/manage/production.md index 8e2aae0be7..1bc926db2e 100644 --- a/docs/docs/self-hosting/manage/production.md +++ b/docs/docs/self-hosting/manage/production.md @@ -45,6 +45,22 @@ Tracing: MetricPrefix: zitadel ``` +## Logging + +ZITADEL follows the principles that guide cloud-native and twelve factor applications. +Logs are a stream of time-ordered events collected from all running processes. + +ZITADEL processes write the following events to the standard output: + +- Runtime Logs: Define the log level and record format [in the Log configuration section](https://github.com/zitadel/zitadel/blob/main/cmd/defaults.yaml#L1-L4) +- Access Logs: Enable logging all HTTP and gRPC responses from the ZITADEL binary [in the LogStore section](https://github.com/zitadel/zitadel/blob/main/cmd/defaults.yaml#L366) +- Actions Exectution Logs: Actions can emit custom logs at different levels. For example, a log record can be emitted each time a user is created or authenticated. If you don't want to have these logs in STDOUT, you can disable this [in the LogStore section](https://github.com/zitadel/zitadel/blob/main/cmd/defaults.yaml#L387) . + +Log file management should not be in each business apps responsibility. +Instead, your execution environment should provide tooling for managing logs in a generic way. +This includes tasks like rotating files, routing, collecting, archiving and cleaning-up. +For example, systemd has journald and kubernetes has fluentd and fluentbit. + ## Database ### Prefer CockroachDB diff --git a/docs/docs/support/advisory/a10000.md b/docs/docs/support/advisory/a10000.md new file mode 100644 index 0000000000..9943bcc307 --- /dev/null +++ b/docs/docs/support/advisory/a10000.md @@ -0,0 +1,26 @@ +--- +title: Technical Advisory 10000 +--- + +## Description + +Currently, by default, users are directed to the "Select Account Page" on the ZITADEL login. +However, this can be modified by including a [prompt or a login hint](/docs/apis/openidoauth/endpoints#additional-parameters) in the authentication request. + +As a result of this default behavior, users who already have an active session in one application and wish to log in to a second one will need to select their user account, even if no other session is active. + +To address this, we are going to change this behavior so that users will be automatically authenticated when logging into a second application, as long as they only have one active session. + +## Statement + +This behaviour change is tracked in the following issue: [Reuse current session if no prompt is selected ](https://github.com/zitadel/zitadel/issues/4841) +As soon as the release version is published, we will include the version here. + +## Mitigation + +If you want to prompt users to always select their account on purpose, please make sure to include the `select_account` [prompt](/docs/apis/openidoauth/endpoints#additional-parameters) in your authentication request. + +## Impact + +Once this update has been released and deployed, your users will be automatically authenticated +No action will be required on your part if this is the intended behavior. diff --git a/docs/docs/support/technical_advisory.mdx b/docs/docs/support/technical_advisory.mdx new file mode 100644 index 0000000000..c866c32934 --- /dev/null +++ b/docs/docs/support/technical_advisory.mdx @@ -0,0 +1,39 @@ +--- +title: Technical Advisory +--- + +Technical advisories are notices that report major issues with ZITADEL Self-Hosted or the ZITADEL Cloud platform that could potentially impact security or stability in production environments. +These advisories may include details about the nature of the issue, its potential impact, and recommended mitigation actions. + +Users are strongly encouraged to evaluate these advisories and consider the recommended mitigation actions independently from their version upgrade schedule. +We understand that these advisories may include breaking changes, and we aim to provide clear guidance on how to address these changes. + + + + + + + + + + + + + + + + + + + +
AdvisoryNameTypeSummaryAffected versionsDate
A-10000Reusing user sessionBreaking Behaviour ChangeThe default behavior for users logging in is to be directed to the Select Account Page on the Login. With the upcoming changes, users will be automatically authenticated when logging into a second application, as long as they only have one active session. No action is required on your part if this is the intended behavior.TBDTBD
+ +## Categories + +### Breaking Behaviour Change + +A breaking behavior change refers to a modification or update that changes the behavior of ZITADEL. +This change does not necessarily affect the APIs or any functions you are calling, so it may not require an update to your code. +However, if you rely on specific results or behaviors, they may no longer be guaranteed after the change is implemented. +Therefore, it is important to be aware of breaking behavior changes and their potential impact on your use of ZITADEL, and to take appropriate action if needed to ensure continued functionality. + diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index a660dae88d..5ed0a099d8 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -266,6 +266,13 @@ module.exports = { sidebarOptions: { groupPathsBy: "tag", }, + }, + session: { + specPath: ".artifacts/openapi/zitadel/session/v2alpha/session_service.swagger.json", + outputDir: "docs/apis/session_service", + sidebarOptions: { + groupPathsBy: "tag", + }, } } }, diff --git a/docs/sidebars.js b/docs/sidebars.js index 2d7bc5578d..25e84b4b91 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -175,9 +175,16 @@ module.exports = { { type: "category", label: "Authenticate Service Users", + link: { + type: "generated-index", + title: "Authenticate Service Users", + slug: "/guides/integrate/serviceusers", + description: + "How to authenticate service users", + }, collapsed: true, items: [ - "guides/integrate/serviceusers", + "guides/integrate/private-key-jwt", "guides/integrate/client-credentials", "guides/integrate/pat", ], @@ -274,6 +281,7 @@ module.exports = { "concepts/features/identity-brokering", "concepts/structure/jwt_idp", "concepts/features/actions", + "concepts/features/audit-trail", "concepts/features/selfservice", ] }, @@ -303,6 +311,21 @@ module.exports = { collapsed: true, items: [ "support/troubleshooting", + { + type: 'category', + label: "Technical Advisory", + link: { + type: 'doc', + id: 'support/technical_advisory', + }, + collapsed: true, + items: [ + { + type: 'autogenerated', + dirName: 'support/advisory', + }, + ], + }, { type: "category", label: "Trainings", @@ -389,6 +412,20 @@ module.exports = { }, items: require("./docs/apis/user_service/sidebar.js"), }, + { + type: "category", + label: "Session Lifecycle (Alpha)", + link: { + type: "generated-index", + title: "Session Service API (Alpha)", + slug: "/apis/session_service", + description: + "This API is intended to manage sessions 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/session_service/sidebar.js"), + }, { type: "category", label: "Assets", diff --git a/docs/static/img/concepts/audit-trail/audit-log-events.png b/docs/static/img/concepts/audit-trail/audit-log-events.png new file mode 100644 index 0000000000..1774aa9dc7 Binary files /dev/null and b/docs/static/img/concepts/audit-trail/audit-log-events.png differ diff --git a/docs/static/img/concepts/audit-trail/event-viewer.png b/docs/static/img/concepts/audit-trail/event-viewer.png new file mode 100644 index 0000000000..a6f23a8eac Binary files /dev/null and b/docs/static/img/concepts/audit-trail/event-viewer.png differ diff --git a/docs/static/img/concepts/objects/instances.png b/docs/static/img/concepts/objects/instances.png new file mode 100644 index 0000000000..6ab053a1e1 Binary files /dev/null and b/docs/static/img/concepts/objects/instances.png differ diff --git a/go.mod b/go.mod index 668aa02305..24774459bf 100644 --- a/go.mod +++ b/go.mod @@ -192,7 +192,7 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.14.0 // indirect go.opentelemetry.io/proto/otlp v0.19.0 // indirect golang.org/x/mod v0.10.0 // indirect - golang.org/x/sys v0.7.0 // indirect + golang.org/x/sys v0.7.0 golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect google.golang.org/appengine v1.6.7 // indirect gopkg.in/ini.v1 v1.67.0 // indirect diff --git a/internal/api/api.go b/internal/api/api.go index 8763549eba..0e89ae01f3 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -17,6 +17,7 @@ import ( internal_authz "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" http_util "github.com/zitadel/zitadel/internal/api/http" + 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" @@ -169,6 +170,7 @@ func (a *API) routeGRPCWeb() { return true }), ) + a.router.Use(http_mw.RobotsTagHandler) a.router.NewRoute(). Methods(http.MethodPost, http.MethodOptions). MatcherFunc( diff --git a/internal/api/authz/authorization.go b/internal/api/authz/authorization.go index 0df781f4b0..2db8f14ad4 100644 --- a/internal/api/authz/authorization.go +++ b/internal/api/authz/authorization.go @@ -14,11 +14,16 @@ const ( authenticated = "authenticated" ) -func CheckUserAuthorization(ctx context.Context, req interface{}, token, orgIDHeader string, verifier *TokenVerifier, authConfig Config, requiredAuthOption Option, method string) (ctxSetter func(context.Context) context.Context, err error) { +// CheckUserAuthorization verifies that: +// - the token is active, +// - the organisation (**either** provided by ID or verified domain) exists +// - the user is permitted to call the requested endpoint (permission option in proto) +// it will pass the [CtxData] and permission of the user into the ctx [context.Context] +func CheckUserAuthorization(ctx context.Context, req interface{}, token, orgID, orgDomain string, verifier *TokenVerifier, authConfig Config, requiredAuthOption Option, method string) (ctxSetter func(context.Context) context.Context, err error) { ctx, span := tracing.NewServerInterceptorSpan(ctx) defer func() { span.EndWithError(err) }() - ctxData, err := VerifyTokenAndCreateCtxData(ctx, token, orgIDHeader, verifier, method) + ctxData, err := VerifyTokenAndCreateCtxData(ctx, token, orgID, orgDomain, verifier, method) if err != nil { return nil, err } diff --git a/internal/api/authz/context.go b/internal/api/authz/context.go index 938241232e..ec2e13d0ee 100644 --- a/internal/api/authz/context.go +++ b/internal/api/authz/context.go @@ -60,7 +60,7 @@ const ( MemberTypeIam ) -func VerifyTokenAndCreateCtxData(ctx context.Context, token, orgID string, t *TokenVerifier, method string) (_ CtxData, err error) { +func VerifyTokenAndCreateCtxData(ctx context.Context, token, orgID, orgDomain string, t *TokenVerifier, method string) (_ CtxData, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -82,14 +82,15 @@ func VerifyTokenAndCreateCtxData(ctx context.Context, token, orgID string, t *To if err := checkOrigin(ctx, origins); err != nil { return CtxData{}, err } - if orgID == "" { + if orgID == "" && orgDomain == "" { orgID = resourceOwner } - err = t.ExistsOrg(ctx, orgID) + verifiedOrgID, err := t.ExistsOrg(ctx, orgID, orgDomain) if err != nil { err = retry(func() error { - return t.ExistsOrg(ctx, orgID) + verifiedOrgID, err = t.ExistsOrg(ctx, orgID, orgDomain) + return err }) if err != nil { return CtxData{}, errors.ThrowPermissionDenied(nil, "AUTH-Bs7Ds", "Organisation doesn't exist") @@ -98,7 +99,7 @@ func VerifyTokenAndCreateCtxData(ctx context.Context, token, orgID string, t *To return CtxData{ UserID: userID, - OrgID: orgID, + OrgID: verifiedOrgID, ProjectID: projectID, AgentID: agentID, PreferredLanguage: prefLang, diff --git a/internal/api/authz/permissions.go b/internal/api/authz/permissions.go index b392ca7958..4312651bd4 100644 --- a/internal/api/authz/permissions.go +++ b/internal/api/authz/permissions.go @@ -7,12 +7,8 @@ import ( "github.com/zitadel/zitadel/internal/telemetry/tracing" ) -func CheckPermission(ctx context.Context, resolver MembershipsResolver, roleMappings []RoleMapping, permission, orgID, resourceID string, allowSelf bool) (err error) { - ctxData := GetCtxData(ctx) - if allowSelf && ctxData.UserID == resourceID { - return nil - } - requestedPermissions, _, err := getUserPermissions(ctx, resolver, permission, roleMappings, ctxData, orgID) +func CheckPermission(ctx context.Context, resolver MembershipsResolver, roleMappings []RoleMapping, permission, orgID, resourceID string) (err error) { + requestedPermissions, _, err := getUserPermissions(ctx, resolver, permission, roleMappings, GetCtxData(ctx), orgID) if err != nil { return err } diff --git a/internal/api/authz/permissions_test.go b/internal/api/authz/permissions_test.go index ea217e1b85..01cfa3543c 100644 --- a/internal/api/authz/permissions_test.go +++ b/internal/api/authz/permissions_test.go @@ -26,8 +26,8 @@ func (v *testVerifier) ProjectIDAndOriginsByClientID(ctx context.Context, client return "", nil, nil } -func (v *testVerifier) ExistsOrg(ctx context.Context, orgID string) error { - return nil +func (v *testVerifier) ExistsOrg(ctx context.Context, orgID, domain string) (string, error) { + return orgID, nil } func (v *testVerifier) VerifierClientID(ctx context.Context, appName string) (string, string, error) { diff --git a/internal/api/authz/token.go b/internal/api/authz/token.go index a8a9b3afb1..b56d042dac 100644 --- a/internal/api/authz/token.go +++ b/internal/api/authz/token.go @@ -3,6 +3,8 @@ package authz import ( "context" "crypto/rsa" + "encoding/base64" + "fmt" "os" "strings" "sync" @@ -17,7 +19,8 @@ import ( ) const ( - BearerPrefix = "Bearer " + BearerPrefix = "Bearer " + SessionTokenFormat = "sess_%s:%s" ) type TokenVerifier struct { @@ -36,7 +39,7 @@ type authZRepo interface { VerifierClientID(ctx context.Context, name string) (clientID, projectID string, err error) SearchMyMemberships(ctx context.Context, orgID string) ([]*Membership, error) ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (projectID string, origins []string, err error) - ExistsOrg(ctx context.Context, orgID string) error + ExistsOrg(ctx context.Context, id, domain string) (string, error) } func Start(authZRepo authZRepo, issuer string, keys map[string]*SystemAPIUser) (v *TokenVerifier) { @@ -144,10 +147,10 @@ func (v *TokenVerifier) ProjectIDAndOriginsByClientID(ctx context.Context, clien return v.authZRepo.ProjectIDAndOriginsByClientID(ctx, clientID) } -func (v *TokenVerifier) ExistsOrg(ctx context.Context, orgID string) (err error) { +func (v *TokenVerifier) ExistsOrg(ctx context.Context, id, domain string) (orgID string, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - return v.authZRepo.ExistsOrg(ctx, orgID) + return v.authZRepo.ExistsOrg(ctx, id, domain) } func (v *TokenVerifier) CheckAuthMethod(method string) (Option, bool) { @@ -165,3 +168,20 @@ func verifyAccessToken(ctx context.Context, token string, t *TokenVerifier, meth } return t.VerifyAccessToken(ctx, parts[1], method) } + +func SessionTokenVerifier(algorithm crypto.EncryptionAlgorithm) func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) { + return func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) { + decodedToken, err := base64.RawURLEncoding.DecodeString(sessionToken) + if err != nil { + return err + } + _, spanPasswordComparison := tracing.NewNamedSpan(ctx, "crypto.CompareHash") + var token string + token, err = algorithm.DecryptString(decodedToken, algorithm.EncryptionKeyID()) + spanPasswordComparison.EndWithError(err) + if err != nil || token != fmt.Sprintf(SessionTokenFormat, sessionID, tokenID) { + return caos_errs.ThrowPermissionDenied(err, "COMMAND-sGr42", "Errors.Session.Token.Invalid") + } + return nil + } +} diff --git a/internal/api/grpc/admin/information_integration_test.go b/internal/api/grpc/admin/information_integration_test.go new file mode 100644 index 0000000000..8eb9818241 --- /dev/null +++ b/internal/api/grpc/admin/information_integration_test.go @@ -0,0 +1,36 @@ +//go:build integration + +package admin_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/admin" +) + +var ( + Tester *integration.Tester +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, _, cancel := integration.Contexts(time.Minute) + defer cancel() + Tester = integration.NewTester(ctx) + defer Tester.Done() + + return m.Run() + }()) +} + +func TestServer_Healthz(t *testing.T) { + client := admin.NewAdminServiceClient(Tester.GRPCClientConn) + _, err := client.Healthz(context.TODO(), &admin.HealthzRequest{}) + require.NoError(t, err) +} diff --git a/internal/api/grpc/object/v2/converter.go b/internal/api/grpc/object/v2/converter.go index eca168f70c..feb8dbd62a 100644 --- a/internal/api/grpc/object/v2/converter.go +++ b/internal/api/grpc/object/v2/converter.go @@ -4,6 +4,7 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha" ) @@ -17,3 +18,21 @@ func DomainToDetailsPb(objectDetail *domain.ObjectDetails) *object.Details { } return details } + +func ToListDetails(response query.SearchResponse) *object.ListDetails { + details := &object.ListDetails{ + TotalResult: response.Count, + ProcessedSequence: response.Sequence, + } + if !response.Timestamp.IsZero() { + details.Timestamp = timestamppb.New(response.Timestamp) + } + + return details +} +func ListQueryToQuery(query *object.ListQuery) (offset, limit uint64, asc bool) { + if query == nil { + return 0, 0, false + } + return query.Offset, uint64(query.Limit), query.Asc +} diff --git a/internal/api/grpc/server/gateway.go b/internal/api/grpc/server/gateway.go index 7fae522d95..faba435940 100644 --- a/internal/api/grpc/server/gateway.go +++ b/internal/api/grpc/server/gateway.go @@ -12,6 +12,7 @@ import ( "google.golang.org/grpc/credentials/insecure" healthpb "google.golang.org/grpc/health/grpc_health_v1" "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" client_middleware "github.com/zitadel/zitadel/internal/api/grpc/client/middleware" "github.com/zitadel/zitadel/internal/api/grpc/server/middleware" @@ -38,6 +39,7 @@ var ( runtime.WithMarshalerOption(runtime.MIMEWildcard, jsonMarshaler), runtime.WithIncomingHeaderMatcher(headerMatcher), runtime.WithOutgoingHeaderMatcher(runtime.DefaultHeaderMatcher), + runtime.WithForwardResponseOption(responseForwarder), } headerMatcher = runtime.HeaderMatcherFunc( @@ -50,6 +52,15 @@ var ( return runtime.DefaultHeaderMatcher(header) }, ) + + responseForwarder = func(ctx context.Context, w http.ResponseWriter, resp proto.Message) error { + t, ok := resp.(CustomHTTPResponse) + if ok { + // TODO: find a way to return a location header if needed w.Header().Set("location", t.Location()) + w.WriteHeader(t.CustomHTTPCode()) + } + return nil + } ) type Gateway struct { @@ -62,6 +73,10 @@ func (g *Gateway) Handler() http.Handler { return addInterceptors(g.mux, g.http1HostName) } +type CustomHTTPResponse interface { + CustomHTTPCode() int +} + 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) { @@ -134,6 +149,7 @@ func addInterceptors(handler http.Handler, http1HostName string) 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) } diff --git a/internal/api/grpc/server/middleware/auth_interceptor.go b/internal/api/grpc/server/middleware/auth_interceptor.go index f02a822d86..e9d90c7cfa 100644 --- a/internal/api/grpc/server/middleware/auth_interceptor.go +++ b/internal/api/grpc/server/middleware/auth_interceptor.go @@ -11,6 +11,7 @@ import ( grpc_util "github.com/zitadel/zitadel/internal/api/grpc" "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/telemetry/tracing" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha" ) func AuthorizationInterceptor(verifier *authz.TokenVerifier, authConfig authz.Config) grpc.UnaryServerInterceptor { @@ -33,12 +34,14 @@ func authorize(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, return nil, status.Error(codes.Unauthenticated, "auth header missing") } + var orgDomain string orgID := grpc_util.GetHeader(authCtx, http.ZitadelOrgID) - if o, ok := req.(AuthContext); ok { - orgID = o.AuthContext() + if o, ok := req.(OrganisationFromRequest); ok { + orgID = o.OrganisationFromRequest().GetOrgId() + orgDomain = o.OrganisationFromRequest().GetOrgDomain() } - ctxSetter, err := authz.CheckUserAuthorization(authCtx, req, authToken, orgID, verifier, authConfig, authOpt, info.FullMethod) + ctxSetter, err := authz.CheckUserAuthorization(authCtx, req, authToken, orgID, orgDomain, verifier, authConfig, authOpt, info.FullMethod) if err != nil { return nil, err } @@ -46,6 +49,6 @@ func authorize(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, return handler(ctxSetter(ctx), req) } -type AuthContext interface { - AuthContext() string +type OrganisationFromRequest interface { + OrganisationFromRequest() *object.Organisation } diff --git a/internal/api/grpc/server/middleware/auth_interceptor_test.go b/internal/api/grpc/server/middleware/auth_interceptor_test.go index c041d9ca47..ac091e1d11 100644 --- a/internal/api/grpc/server/middleware/auth_interceptor_test.go +++ b/internal/api/grpc/server/middleware/auth_interceptor_test.go @@ -31,8 +31,8 @@ func (v *verifierMock) SearchMyMemberships(ctx context.Context, orgID string) ([ func (v *verifierMock) ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (string, []string, error) { return "", nil, nil } -func (v *verifierMock) ExistsOrg(ctx context.Context, orgID string) error { - return nil +func (v *verifierMock) ExistsOrg(ctx context.Context, orgID, domain string) (string, error) { + return orgID, nil } func (v *verifierMock) VerifierClientID(ctx context.Context, appName string) (string, string, error) { return "", "", nil diff --git a/internal/api/grpc/session/v2/server.go b/internal/api/grpc/session/v2/server.go index 22919fdb7d..3dc5a0c839 100644 --- a/internal/api/grpc/session/v2/server.go +++ b/internal/api/grpc/session/v2/server.go @@ -6,16 +6,18 @@ import ( "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/domain" "github.com/zitadel/zitadel/internal/query" - "github.com/zitadel/zitadel/pkg/grpc/session/v2alpha" + session "github.com/zitadel/zitadel/pkg/grpc/session/v2alpha" ) var _ session.SessionServiceServer = (*Server)(nil) type Server struct { session.UnimplementedSessionServiceServer - command *command.Commands - query *query.Queries + command *command.Commands + query *query.Queries + checkPermission domain.PermissionCheck } type Config struct{} @@ -23,10 +25,12 @@ type Config struct{} func CreateServer( command *command.Commands, query *query.Queries, + checkPermission domain.PermissionCheck, ) *Server { return &Server{ - command: command, - query: query, + command: command, + query: query, + checkPermission: checkPermission, } } diff --git a/internal/api/grpc/session/v2/session.go b/internal/api/grpc/session/v2/session.go index 3e90e76a99..413989ba12 100644 --- a/internal/api/grpc/session/v2/session.go +++ b/internal/api/grpc/session/v2/session.go @@ -3,16 +3,260 @@ package session import ( "context" + "google.golang.org/protobuf/types/known/timestamppb" + "github.com/zitadel/zitadel/internal/api/authz" - "github.com/zitadel/zitadel/pkg/grpc/session/v2alpha" - "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha" + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" + "github.com/zitadel/zitadel/internal/command" + caos_errs "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/query" + session "github.com/zitadel/zitadel/pkg/grpc/session/v2alpha" ) func (s *Server) GetSession(ctx context.Context, req *session.GetSessionRequest) (*session.GetSessionResponse, error) { + res, err := s.query.SessionByID(ctx, req.GetSessionId(), req.GetSessionToken()) + if err != nil { + return nil, err + } return &session.GetSessionResponse{ - Session: &session.Session{ - Id: req.Id, - User: &user.User{Id: authz.GetCtxData(ctx).UserID}, - }, + Session: sessionToPb(res), }, nil } + +func (s *Server) ListSessions(ctx context.Context, req *session.ListSessionsRequest) (*session.ListSessionsResponse, error) { + queries, err := listSessionsRequestToQuery(ctx, req) + if err != nil { + return nil, err + } + sessions, err := s.query.SearchSessions(ctx, queries) + if err != nil { + return nil, err + } + return &session.ListSessionsResponse{ + Details: object.ToListDetails(sessions.SearchResponse), + Sessions: sessionsToPb(sessions.Sessions), + }, nil +} + +func (s *Server) CreateSession(ctx context.Context, req *session.CreateSessionRequest) (*session.CreateSessionResponse, error) { + checks, metadata, err := s.createSessionRequestToCommand(ctx, req) + if err != nil { + return nil, err + } + set, err := s.command.CreateSession(ctx, checks, metadata) + if err != nil { + return nil, err + } + return &session.CreateSessionResponse{ + Details: object.DomainToDetailsPb(set.ObjectDetails), + SessionId: set.ID, + SessionToken: set.NewToken, + }, nil +} + +func (s *Server) SetSession(ctx context.Context, req *session.SetSessionRequest) (*session.SetSessionResponse, error) { + checks, err := s.setSessionRequestToCommand(ctx, req) + if err != nil { + return nil, err + } + set, err := s.command.UpdateSession(ctx, req.GetSessionId(), req.GetSessionToken(), checks, req.GetMetadata()) + if err != nil { + return nil, err + } + // if there's no new token, just return the current + if set.NewToken == "" { + set.NewToken = req.GetSessionToken() + } + return &session.SetSessionResponse{ + Details: object.DomainToDetailsPb(set.ObjectDetails), + SessionToken: set.NewToken, + }, nil +} + +func (s *Server) DeleteSession(ctx context.Context, req *session.DeleteSessionRequest) (*session.DeleteSessionResponse, error) { + details, err := s.command.TerminateSession(ctx, req.GetSessionId(), req.GetSessionToken()) + if err != nil { + return nil, err + } + return &session.DeleteSessionResponse{ + Details: object.DomainToDetailsPb(details), + }, nil +} + +func sessionsToPb(sessions []*query.Session) []*session.Session { + s := make([]*session.Session, len(sessions)) + for i, session := range sessions { + s[i] = sessionToPb(session) + } + return s +} + +func sessionToPb(s *query.Session) *session.Session { + return &session.Session{ + Id: s.ID, + CreationDate: timestamppb.New(s.CreationDate), + ChangeDate: timestamppb.New(s.ChangeDate), + Sequence: s.Sequence, + Factors: factorsToPb(s), + Metadata: s.Metadata, + } +} + +func factorsToPb(s *query.Session) *session.Factors { + user := userFactorToPb(s.UserFactor) + pw := passwordFactorToPb(s.PasswordFactor) + if user == nil && pw == nil { + return nil + } + return &session.Factors{ + User: user, + Password: pw, + } +} + +func passwordFactorToPb(factor query.SessionPasswordFactor) *session.PasswordFactor { + if factor.PasswordCheckedAt.IsZero() { + return nil + } + return &session.PasswordFactor{ + VerifiedAt: timestamppb.New(factor.PasswordCheckedAt), + } +} + +func userFactorToPb(factor query.SessionUserFactor) *session.UserFactor { + if factor.UserID == "" || factor.UserCheckedAt.IsZero() { + return nil + } + return &session.UserFactor{ + VerifiedAt: timestamppb.New(factor.UserCheckedAt), + Id: factor.UserID, + LoginName: factor.LoginName, + DisplayName: factor.DisplayName, + } +} + +func listSessionsRequestToQuery(ctx context.Context, req *session.ListSessionsRequest) (*query.SessionsSearchQueries, error) { + offset, limit, asc := object.ListQueryToQuery(req.Query) + queries, err := sessionQueriesToQuery(ctx, req.GetQueries()) + if err != nil { + return nil, err + } + return &query.SessionsSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + }, + Queries: queries, + }, nil +} + +func sessionQueriesToQuery(ctx context.Context, queries []*session.SearchQuery) (_ []query.SearchQuery, err error) { + q := make([]query.SearchQuery, len(queries)+1) + for i, query := range queries { + q[i], err = sessionQueryToQuery(query) + if err != nil { + return nil, err + } + } + creatorQuery, err := query.NewSessionCreatorSearchQuery(authz.GetCtxData(ctx).UserID) + if err != nil { + return nil, err + } + q[len(queries)] = creatorQuery + return q, nil +} + +func sessionQueryToQuery(query *session.SearchQuery) (query.SearchQuery, error) { + switch q := query.Query.(type) { + case *session.SearchQuery_IdsQuery: + return idsQueryToQuery(q.IdsQuery) + default: + return nil, caos_errs.ThrowInvalidArgument(nil, "GRPC-Sfefs", "List.Query.Invalid") + } +} + +func idsQueryToQuery(q *session.IDsQuery) (query.SearchQuery, error) { + return query.NewSessionIDsSearchQuery(q.Ids) +} + +func (s *Server) createSessionRequestToCommand(ctx context.Context, req *session.CreateSessionRequest) ([]command.SessionCheck, map[string][]byte, error) { + checks, err := s.checksToCommand(ctx, req.Checks) + if err != nil { + return nil, nil, err + } + return checks, req.GetMetadata(), nil +} + +func (s *Server) setSessionRequestToCommand(ctx context.Context, req *session.SetSessionRequest) ([]command.SessionCheck, error) { + checks, err := s.checksToCommand(ctx, req.Checks) + if err != nil { + return nil, err + } + return checks, nil +} + +func (s *Server) checksToCommand(ctx context.Context, checks *session.Checks) ([]command.SessionCheck, error) { + checkUser, err := userCheck(checks.GetUser()) + if err != nil { + return nil, err + } + sessionChecks := make([]command.SessionCheck, 0, 2) + if checkUser != nil { + user, err := checkUser.search(ctx, s.query) + if err != nil { + return nil, err + } + sessionChecks = append(sessionChecks, command.CheckUser(user.ID)) + } + if password := checks.GetPassword(); password != nil { + sessionChecks = append(sessionChecks, command.CheckPassword(password.GetPassword())) + } + return sessionChecks, nil +} + +func userCheck(user *session.CheckUser) (userSearch, error) { + if user == nil { + return nil, nil + } + switch s := user.GetSearch().(type) { + case *session.CheckUser_UserId: + return userByID(s.UserId), nil + case *session.CheckUser_LoginName: + return userByLoginName(s.LoginName) + default: + return nil, caos_errs.ThrowUnimplementedf(nil, "SESSION-d3b4g0", "user search %T not implemented", s) + } +} + +type userSearch interface { + search(ctx context.Context, q *query.Queries) (*query.User, error) +} + +func userByID(userID string) userSearch { + return userSearchByID{userID} +} + +func userByLoginName(loginName string) (userSearch, error) { + loginNameQuery, err := query.NewUserLoginNamesSearchQuery(loginName) + if err != nil { + return nil, err + } + return userSearchByLoginName{loginNameQuery}, nil +} + +type userSearchByID struct { + id string +} + +func (u userSearchByID) search(ctx context.Context, q *query.Queries) (*query.User, error) { + return q.GetUserByID(ctx, true, u.id, false) +} + +type userSearchByLoginName struct { + loginNameQuery query.SearchQuery +} + +func (u userSearchByLoginName) search(ctx context.Context, q *query.Queries) (*query.User, error) { + return q.GetUser(ctx, true, false, u.loginNameQuery) +} diff --git a/internal/api/grpc/session/v2/session_test.go b/internal/api/grpc/session/v2/session_test.go new file mode 100644 index 0000000000..6d2f386069 --- /dev/null +++ b/internal/api/grpc/session/v2/session_test.go @@ -0,0 +1,379 @@ +package session + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/domain" + caos_errs "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/query" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha" + session "github.com/zitadel/zitadel/pkg/grpc/session/v2alpha" +) + +func Test_sessionsToPb(t *testing.T) { + now := time.Now() + past := now.Add(-time.Hour) + + sessions := []*query.Session{ + { // no factor + ID: "999", + CreationDate: now, + ChangeDate: now, + Sequence: 123, + State: domain.SessionStateActive, + ResourceOwner: "me", + Creator: "he", + Metadata: map[string][]byte{"hello": []byte("world")}, + }, + { // user factor + ID: "999", + CreationDate: now, + ChangeDate: now, + Sequence: 123, + State: domain.SessionStateActive, + ResourceOwner: "me", + Creator: "he", + UserFactor: query.SessionUserFactor{ + UserID: "345", + UserCheckedAt: past, + LoginName: "donald", + DisplayName: "donald duck", + }, + Metadata: map[string][]byte{"hello": []byte("world")}, + }, + { // no factor + ID: "999", + CreationDate: now, + ChangeDate: now, + Sequence: 123, + State: domain.SessionStateActive, + ResourceOwner: "me", + Creator: "he", + PasswordFactor: query.SessionPasswordFactor{ + PasswordCheckedAt: past, + }, + Metadata: map[string][]byte{"hello": []byte("world")}, + }, + } + + want := []*session.Session{ + { // no factor + Id: "999", + CreationDate: timestamppb.New(now), + ChangeDate: timestamppb.New(now), + Sequence: 123, + Factors: nil, + Metadata: map[string][]byte{"hello": []byte("world")}, + }, + { // user factor + Id: "999", + CreationDate: timestamppb.New(now), + ChangeDate: timestamppb.New(now), + Sequence: 123, + Factors: &session.Factors{ + User: &session.UserFactor{ + VerifiedAt: timestamppb.New(past), + Id: "345", + LoginName: "donald", + DisplayName: "donald duck", + }, + }, + Metadata: map[string][]byte{"hello": []byte("world")}, + }, + { // password factor + Id: "999", + CreationDate: timestamppb.New(now), + ChangeDate: timestamppb.New(now), + Sequence: 123, + Factors: &session.Factors{ + Password: &session.PasswordFactor{ + VerifiedAt: timestamppb.New(past), + }, + }, + Metadata: map[string][]byte{"hello": []byte("world")}, + }, + } + + out := sessionsToPb(sessions) + require.Len(t, out, len(want)) + + for i, got := range out { + if !proto.Equal(got, want[i]) { + t.Errorf("session %d got:\n%v\nwant:\n%v", i, got, want) + } + } +} + +func mustNewTextQuery(t testing.TB, column query.Column, value string, compare query.TextComparison) query.SearchQuery { + q, err := query.NewTextQuery(column, value, compare) + require.NoError(t, err) + return q +} + +func mustNewListQuery(t testing.TB, column query.Column, list []any, compare query.ListComparison) query.SearchQuery { + q, err := query.NewListQuery(query.SessionColumnID, list, compare) + require.NoError(t, err) + return q +} + +func Test_listSessionsRequestToQuery(t *testing.T) { + type args struct { + ctx context.Context + req *session.ListSessionsRequest + } + tests := []struct { + name string + args args + want *query.SessionsSearchQueries + wantErr error + }{ + { + name: "default request", + args: args{ + ctx: authz.NewMockContext("123", "456", "789"), + req: &session.ListSessionsRequest{}, + }, + want: &query.SessionsSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: 0, + Limit: 0, + Asc: false, + }, + Queries: []query.SearchQuery{ + mustNewTextQuery(t, query.SessionColumnCreator, "789", query.TextEquals), + }, + }, + }, + { + name: "with list query and sessions", + args: args{ + ctx: authz.NewMockContext("123", "456", "789"), + req: &session.ListSessionsRequest{ + Query: &object.ListQuery{ + Offset: 10, + Limit: 20, + Asc: true, + }, + Queries: []*session.SearchQuery{ + {Query: &session.SearchQuery_IdsQuery{ + IdsQuery: &session.IDsQuery{ + Ids: []string{"1", "2", "3"}, + }, + }}, + {Query: &session.SearchQuery_IdsQuery{ + IdsQuery: &session.IDsQuery{ + Ids: []string{"4", "5", "6"}, + }, + }}, + }, + }, + }, + want: &query.SessionsSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: 10, + Limit: 20, + Asc: true, + }, + Queries: []query.SearchQuery{ + mustNewListQuery(t, query.SessionColumnID, []interface{}{"1", "2", "3"}, query.ListIn), + mustNewListQuery(t, query.SessionColumnID, []interface{}{"4", "5", "6"}, query.ListIn), + mustNewTextQuery(t, query.SessionColumnCreator, "789", query.TextEquals), + }, + }, + }, + { + name: "invalid argument error", + args: args{ + ctx: authz.NewMockContext("123", "456", "789"), + req: &session.ListSessionsRequest{ + Query: &object.ListQuery{ + Offset: 10, + Limit: 20, + Asc: true, + }, + Queries: []*session.SearchQuery{ + {Query: &session.SearchQuery_IdsQuery{ + IdsQuery: &session.IDsQuery{ + Ids: []string{"1", "2", "3"}, + }, + }}, + {Query: nil}, + }, + }, + }, + wantErr: caos_errs.ThrowInvalidArgument(nil, "GRPC-Sfefs", "List.Query.Invalid"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := listSessionsRequestToQuery(tt.args.ctx, tt.args.req) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_sessionQueriesToQuery(t *testing.T) { + type args struct { + ctx context.Context + queries []*session.SearchQuery + } + tests := []struct { + name string + args args + want []query.SearchQuery + wantErr error + }{ + { + name: "creator only", + args: args{ + ctx: authz.NewMockContext("123", "456", "789"), + }, + want: []query.SearchQuery{ + mustNewTextQuery(t, query.SessionColumnCreator, "789", query.TextEquals), + }, + }, + { + name: "invalid argument", + args: args{ + ctx: authz.NewMockContext("123", "456", "789"), + queries: []*session.SearchQuery{ + {Query: nil}, + }, + }, + wantErr: caos_errs.ThrowInvalidArgument(nil, "GRPC-Sfefs", "List.Query.Invalid"), + }, + { + name: "creator and sessions", + args: args{ + ctx: authz.NewMockContext("123", "456", "789"), + queries: []*session.SearchQuery{ + {Query: &session.SearchQuery_IdsQuery{ + IdsQuery: &session.IDsQuery{ + Ids: []string{"1", "2", "3"}, + }, + }}, + {Query: &session.SearchQuery_IdsQuery{ + IdsQuery: &session.IDsQuery{ + Ids: []string{"4", "5", "6"}, + }, + }}, + }, + }, + want: []query.SearchQuery{ + mustNewListQuery(t, query.SessionColumnID, []interface{}{"1", "2", "3"}, query.ListIn), + mustNewListQuery(t, query.SessionColumnID, []interface{}{"4", "5", "6"}, query.ListIn), + mustNewTextQuery(t, query.SessionColumnCreator, "789", query.TextEquals), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := sessionQueriesToQuery(tt.args.ctx, tt.args.queries) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_sessionQueryToQuery(t *testing.T) { + type args struct { + query *session.SearchQuery + } + tests := []struct { + name string + args args + want query.SearchQuery + wantErr error + }{ + { + name: "invalid argument", + args: args{&session.SearchQuery{ + Query: nil, + }}, + wantErr: caos_errs.ThrowInvalidArgument(nil, "GRPC-Sfefs", "List.Query.Invalid"), + }, + { + name: "query", + args: args{&session.SearchQuery{ + Query: &session.SearchQuery_IdsQuery{ + IdsQuery: &session.IDsQuery{ + Ids: []string{"1", "2", "3"}, + }, + }, + }}, + want: mustNewListQuery(t, query.SessionColumnID, []interface{}{"1", "2", "3"}, query.ListIn), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := sessionQueryToQuery(tt.args.query) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.want, got) + }) + } +} + +func mustUserLoginNamesSearchQuery(t testing.TB, value string) query.SearchQuery { + loginNameQuery, err := query.NewUserLoginNamesSearchQuery("bar") + require.NoError(t, err) + return loginNameQuery +} + +func Test_userCheck(t *testing.T) { + type args struct { + user *session.CheckUser + } + tests := []struct { + name string + args args + want userSearch + wantErr error + }{ + { + name: "nil user", + args: args{nil}, + want: nil, + }, + { + name: "by user id", + args: args{&session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: "foo", + }, + }}, + want: userSearchByID{"foo"}, + }, + { + name: "by user id", + args: args{&session.CheckUser{ + Search: &session.CheckUser_LoginName{ + LoginName: "bar", + }, + }}, + want: userSearchByLoginName{mustUserLoginNamesSearchQuery(t, "bar")}, + }, + { + name: "unimplemented error", + args: args{&session.CheckUser{ + Search: nil, + }}, + wantErr: caos_errs.ThrowUnimplementedf(nil, "SESSION-d3b4g0", "user search %T not implemented", nil), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := userCheck(tt.args.user) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/api/grpc/user/v2/email.go b/internal/api/grpc/user/v2/email.go index 95928e2e61..d1c91333e0 100644 --- a/internal/api/grpc/user/v2/email.go +++ b/internal/api/grpc/user/v2/email.go @@ -21,11 +21,7 @@ func (s *Server) SetEmail(ctx context.Context, req *user.SetEmailRequest) (resp case *user.SetEmailRequest_ReturnCode: email, err = s.command.ChangeUserEmailReturnCode(ctx, req.GetUserId(), resourceOwner, req.GetEmail(), s.userCodeAlg) case *user.SetEmailRequest_IsVerified: - if v.IsVerified { - email, err = s.command.ChangeUserEmailVerified(ctx, req.GetUserId(), resourceOwner, req.GetEmail()) - } else { - email, err = s.command.ChangeUserEmail(ctx, req.GetUserId(), resourceOwner, req.GetEmail(), s.userCodeAlg) - } + email, err = s.command.ChangeUserEmailVerified(ctx, req.GetUserId(), resourceOwner, req.GetEmail()) case nil: email, err = s.command.ChangeUserEmail(ctx, req.GetUserId(), resourceOwner, req.GetEmail(), s.userCodeAlg) default: diff --git a/internal/api/grpc/user/v2/email_integration_test.go b/internal/api/grpc/user/v2/email_integration_test.go new file mode 100644 index 0000000000..91c4806b29 --- /dev/null +++ b/internal/api/grpc/user/v2/email_integration_test.go @@ -0,0 +1,210 @@ +//go:build integration + +package user_test + +import ( + "fmt" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zitadel/zitadel/internal/integration" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func createHumanUser(t *testing.T) *user.AddHumanUserResponse { + resp, err := Client.AddHumanUser(CTX, &user.AddHumanUserRequest{ + Organisation: &object.Organisation{ + Org: &object.Organisation_OrgId{ + OrgId: Tester.Organisation.ID, + }, + }, + Profile: &user.SetHumanProfile{ + FirstName: "Mickey", + LastName: "Mouse", + }, + Email: &user.SetHumanEmail{ + Email: fmt.Sprintf("%d@mouse.com", time.Now().UnixNano()), + Verification: &user.SetHumanEmail_ReturnCode{ + ReturnCode: &user.ReturnEmailVerificationCode{}, + }, + }, + }) + require.NoError(t, err) + require.NotEmpty(t, resp.GetUserId()) + return resp +} + +func TestServer_SetEmail(t *testing.T) { + userID := createHumanUser(t).GetUserId() + + tests := []struct { + name string + req *user.SetEmailRequest + want *user.SetEmailResponse + wantErr bool + }{ + { + name: "default verfication", + req: &user.SetEmailRequest{ + UserId: userID, + Email: "default-verifier@mouse.com", + }, + want: &user.SetEmailResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "custom url template", + req: &user.SetEmailRequest{ + UserId: userID, + Email: "custom-url@mouse.com", + Verification: &user.SetEmailRequest_SendCode{ + SendCode: &user.SendEmailVerificationCode{ + UrlTemplate: gu.Ptr("https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}"), + }, + }, + }, + want: &user.SetEmailResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "template error", + req: &user.SetEmailRequest{ + UserId: userID, + Email: "custom-url@mouse.com", + Verification: &user.SetEmailRequest_SendCode{ + SendCode: &user.SendEmailVerificationCode{ + UrlTemplate: gu.Ptr("{{"), + }, + }, + }, + wantErr: true, + }, + { + name: "return code", + req: &user.SetEmailRequest{ + UserId: userID, + Email: "return-code@mouse.com", + Verification: &user.SetEmailRequest_ReturnCode{ + ReturnCode: &user.ReturnEmailVerificationCode{}, + }, + }, + want: &user.SetEmailResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + VerificationCode: gu.Ptr("xxx"), + }, + }, + { + name: "is verified true", + req: &user.SetEmailRequest{ + UserId: userID, + Email: "verified-true@mouse.com", + Verification: &user.SetEmailRequest_IsVerified{ + IsVerified: true, + }, + }, + want: &user.SetEmailResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "is verified false", + req: &user.SetEmailRequest{ + UserId: userID, + Email: "verified-false@mouse.com", + Verification: &user.SetEmailRequest_IsVerified{ + IsVerified: false, + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.SetEmail(CTX, tt.req) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + integration.AssertDetails(t, tt.want, got) + if tt.want.GetVerificationCode() != "" { + assert.NotEmpty(t, got.GetVerificationCode()) + } + }) + } +} + +func TestServer_VerifyEmail(t *testing.T) { + userResp := createHumanUser(t) + tests := []struct { + name string + req *user.VerifyEmailRequest + want *user.VerifyEmailResponse + wantErr bool + }{ + { + name: "wrong code", + req: &user.VerifyEmailRequest{ + UserId: userResp.GetUserId(), + VerificationCode: "xxx", + }, + wantErr: true, + }, + { + name: "wrong user", + req: &user.VerifyEmailRequest{ + UserId: "xxx", + VerificationCode: userResp.GetEmailCode(), + }, + wantErr: true, + }, + { + name: "verify user", + req: &user.VerifyEmailRequest{ + UserId: userResp.GetUserId(), + VerificationCode: userResp.GetEmailCode(), + }, + want: &user.VerifyEmailResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.VerifyEmail(CTX, tt.req) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + integration.AssertDetails(t, tt.want, got) + }) + } +} diff --git a/internal/api/grpc/user/v2/user.go b/internal/api/grpc/user/v2/user.go index 02b7e06282..4388f69f99 100644 --- a/internal/api/grpc/user/v2/user.go +++ b/internal/api/grpc/user/v2/user.go @@ -11,7 +11,7 @@ import ( "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/errors" - "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha" ) func (s *Server) AddHumanUser(ctx context.Context, req *user.AddHumanUserRequest) (_ *user.AddHumanUserResponse, err error) { @@ -19,10 +19,7 @@ func (s *Server) AddHumanUser(ctx context.Context, req *user.AddHumanUserRequest if err != nil { return nil, err } - orgID := req.GetOrganisation().GetOrgId() - if orgID == "" { - orgID = authz.GetCtxData(ctx).OrgID - } + orgID := authz.GetCtxData(ctx).OrgID err = s.command.AddHuman(ctx, orgID, human, false) if err != nil { return nil, err diff --git a/internal/api/grpc/user/v2/user_integration_test.go b/internal/api/grpc/user/v2/user_integration_test.go new file mode 100644 index 0000000000..c389ee073a --- /dev/null +++ b/internal/api/grpc/user/v2/user_integration_test.go @@ -0,0 +1,317 @@ +//go:build integration + +package user_test + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zitadel/zitadel/internal/integration" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha" + "google.golang.org/protobuf/types/known/timestamppb" +) + +var ( + CTX context.Context + ErrCTX context.Context + Tester *integration.Tester + Client user.UserServiceClient +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, errCtx, cancel := integration.Contexts(time.Hour) + defer cancel() + + Tester = integration.NewTester(ctx) + defer Tester.Done() + + CTX, ErrCTX = Tester.WithSystemAuthorization(ctx, integration.OrgOwner), errCtx + Client = user.NewUserServiceClient(Tester.GRPCClientConn) + return m.Run() + }()) +} + +func TestServer_AddHumanUser(t *testing.T) { + type args struct { + ctx context.Context + req *user.AddHumanUserRequest + } + tests := []struct { + name string + args args + want *user.AddHumanUserResponse + wantErr bool + }{ + { + name: "default verification", + args: args{ + CTX, + &user.AddHumanUserRequest{ + Organisation: &object.Organisation{ + Org: &object.Organisation_OrgId{ + OrgId: Tester.Organisation.ID, + }, + }, + Profile: &user.SetHumanProfile{ + FirstName: "Donald", + LastName: "Duck", + NickName: gu.Ptr("Dukkie"), + DisplayName: gu.Ptr("Donald Duck"), + PreferredLanguage: gu.Ptr("en"), + Gender: user.Gender_GENDER_DIVERSE.Enum(), + }, + Email: &user.SetHumanEmail{}, + Metadata: []*user.SetMetadataEntry{ + { + Key: "somekey", + Value: []byte("somevalue"), + }, + }, + PasswordType: &user.AddHumanUserRequest_Password{ + Password: &user.Password{ + Password: "DifficultPW666!", + ChangeRequired: true, + }, + }, + }, + }, + want: &user.AddHumanUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "return verification code", + args: args{ + CTX, + &user.AddHumanUserRequest{ + Organisation: &object.Organisation{ + Org: &object.Organisation_OrgId{ + OrgId: Tester.Organisation.ID, + }, + }, + Profile: &user.SetHumanProfile{ + FirstName: "Donald", + LastName: "Duck", + NickName: gu.Ptr("Dukkie"), + DisplayName: gu.Ptr("Donald Duck"), + PreferredLanguage: gu.Ptr("en"), + Gender: user.Gender_GENDER_DIVERSE.Enum(), + }, + Email: &user.SetHumanEmail{ + Verification: &user.SetHumanEmail_ReturnCode{ + ReturnCode: &user.ReturnEmailVerificationCode{}, + }, + }, + Metadata: []*user.SetMetadataEntry{ + { + Key: "somekey", + Value: []byte("somevalue"), + }, + }, + PasswordType: &user.AddHumanUserRequest_Password{ + Password: &user.Password{ + Password: "DifficultPW666!", + ChangeRequired: true, + }, + }, + }, + }, + want: &user.AddHumanUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + EmailCode: gu.Ptr("something"), + }, + }, + { + name: "custom template", + args: args{ + CTX, + &user.AddHumanUserRequest{ + Organisation: &object.Organisation{ + Org: &object.Organisation_OrgId{ + OrgId: Tester.Organisation.ID, + }, + }, + Profile: &user.SetHumanProfile{ + FirstName: "Donald", + LastName: "Duck", + NickName: gu.Ptr("Dukkie"), + DisplayName: gu.Ptr("Donald Duck"), + PreferredLanguage: gu.Ptr("en"), + Gender: user.Gender_GENDER_DIVERSE.Enum(), + }, + Email: &user.SetHumanEmail{ + Verification: &user.SetHumanEmail_SendCode{ + SendCode: &user.SendEmailVerificationCode{ + UrlTemplate: gu.Ptr("https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}"), + }, + }, + }, + Metadata: []*user.SetMetadataEntry{ + { + Key: "somekey", + Value: []byte("somevalue"), + }, + }, + PasswordType: &user.AddHumanUserRequest_Password{ + Password: &user.Password{ + Password: "DifficultPW666!", + ChangeRequired: true, + }, + }, + }, + }, + want: &user.AddHumanUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "custom template error", + args: args{ + CTX, + &user.AddHumanUserRequest{ + Organisation: &object.Organisation{ + Org: &object.Organisation_OrgId{ + OrgId: Tester.Organisation.ID, + }, + }, + Profile: &user.SetHumanProfile{ + FirstName: "Donald", + LastName: "Duck", + NickName: gu.Ptr("Dukkie"), + DisplayName: gu.Ptr("Donald Duck"), + PreferredLanguage: gu.Ptr("en"), + Gender: user.Gender_GENDER_DIVERSE.Enum(), + }, + Email: &user.SetHumanEmail{ + Verification: &user.SetHumanEmail_SendCode{ + SendCode: &user.SendEmailVerificationCode{ + UrlTemplate: gu.Ptr("{{"), + }, + }, + }, + Metadata: []*user.SetMetadataEntry{ + { + Key: "somekey", + Value: []byte("somevalue"), + }, + }, + PasswordType: &user.AddHumanUserRequest_Password{ + Password: &user.Password{ + Password: "DifficultPW666!", + ChangeRequired: true, + }, + }, + }, + }, + wantErr: true, + }, + { + name: "missing REQUIRED profile", + args: args{ + CTX, + &user.AddHumanUserRequest{ + Organisation: &object.Organisation{ + Org: &object.Organisation_OrgId{ + OrgId: Tester.Organisation.ID, + }, + }, + Email: &user.SetHumanEmail{ + Verification: &user.SetHumanEmail_ReturnCode{ + ReturnCode: &user.ReturnEmailVerificationCode{}, + }, + }, + Metadata: []*user.SetMetadataEntry{ + { + Key: "somekey", + Value: []byte("somevalue"), + }, + }, + PasswordType: &user.AddHumanUserRequest_Password{ + Password: &user.Password{ + Password: "DifficultPW666!", + ChangeRequired: true, + }, + }, + }, + }, + wantErr: true, + }, + { + name: "missing REQUIRED email", + args: args{ + CTX, + &user.AddHumanUserRequest{ + Organisation: &object.Organisation{ + Org: &object.Organisation_OrgId{ + OrgId: Tester.Organisation.ID, + }, + }, + Profile: &user.SetHumanProfile{ + FirstName: "Donald", + LastName: "Duck", + NickName: gu.Ptr("Dukkie"), + DisplayName: gu.Ptr("Donald Duck"), + PreferredLanguage: gu.Ptr("en"), + Gender: user.Gender_GENDER_DIVERSE.Enum(), + }, + Metadata: []*user.SetMetadataEntry{ + { + Key: "somekey", + Value: []byte("somevalue"), + }, + }, + PasswordType: &user.AddHumanUserRequest_Password{ + Password: &user.Password{ + Password: "DifficultPW666!", + ChangeRequired: true, + }, + }, + }, + }, + wantErr: true, + }, + } + for i, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + userID := fmt.Sprint(time.Now().UnixNano() + int64(i)) + tt.args.req.UserId = &userID + if email := tt.args.req.GetEmail(); email != nil { + email.Email = fmt.Sprintf("%s@me.now", userID) + } + + if tt.want != nil { + tt.want.UserId = userID + } + + got, err := Client.AddHumanUser(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + assert.Equal(t, tt.want.GetUserId(), got.GetUserId()) + if tt.want.GetEmailCode() != "" { + assert.NotEmpty(t, got.GetEmailCode()) + } + integration.AssertDetails(t, tt.want, got) + }) + } +} diff --git a/internal/api/http/header.go b/internal/api/http/header.go index 8402c271d6..f2d3e19c13 100644 --- a/internal/api/http/header.go +++ b/internal/api/http/header.go @@ -23,6 +23,7 @@ const ( XUserAgent = "x-user-agent" XGrpcWeb = "x-grpc-web" XRequestedWith = "x-requested-with" + XRobotsTag = "x-robots-tag" IfNoneMatch = "If-None-Match" LastModified = "Last-Modified" Etag = "Etag" diff --git a/internal/api/http/middleware/access_interceptor.go b/internal/api/http/middleware/access_interceptor.go index 0de0e8c19b..4e95a83f6f 100644 --- a/internal/api/http/middleware/access_interceptor.go +++ b/internal/api/http/middleware/access_interceptor.go @@ -43,12 +43,10 @@ func (a *AccessInterceptor) Handle(next http.Handler) http.Handler { return next } return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - ctx := request.Context() var err error - tracingCtx, span := tracing.NewServerInterceptorSpan(ctx) - defer func() { span.EndWithError(err) }() + tracingCtx, checkSpan := tracing.NewNamedSpan(ctx, "checkAccess") wrappedWriter := &statusRecorder{ResponseWriter: writer, status: 0} @@ -63,8 +61,13 @@ func (a *AccessInterceptor) Handle(next http.Handler) http.Handler { wrappedWriter.ignoreWrites = true } + checkSpan.End() + next.ServeHTTP(wrappedWriter, request) + tracingCtx, writeSpan := tracing.NewNamedSpan(tracingCtx, "writeAccess") + defer writeSpan.End() + requestURL := request.RequestURI unescapedURL, err := url.QueryUnescape(requestURL) if err != nil { diff --git a/internal/api/http/middleware/auth_interceptor.go b/internal/api/http/middleware/auth_interceptor.go index 9af48a274e..047ee77541 100644 --- a/internal/api/http/middleware/auth_interceptor.go +++ b/internal/api/http/middleware/auth_interceptor.go @@ -62,7 +62,7 @@ func authorize(r *http.Request, verifier *authz.TokenVerifier, authConfig authz. return nil, errors.New("auth header missing") } - ctxSetter, err := authz.CheckUserAuthorization(authCtx, &httpReq{}, authToken, http_util.GetOrgID(r), verifier, authConfig, authOpt, r.RequestURI) + ctxSetter, err := authz.CheckUserAuthorization(authCtx, &httpReq{}, authToken, http_util.GetOrgID(r), "", verifier, authConfig, authOpt, r.RequestURI) if err != nil { return nil, err } diff --git a/internal/api/http/middleware/robots_tag_interceptor.go b/internal/api/http/middleware/robots_tag_interceptor.go new file mode 100644 index 0000000000..076a91cfa2 --- /dev/null +++ b/internal/api/http/middleware/robots_tag_interceptor.go @@ -0,0 +1,14 @@ +package middleware + +import ( + "net/http" + + http_utils "github.com/zitadel/zitadel/internal/api/http" +) + +func RobotsTagHandler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set(http_utils.XRobotsTag, "none") + next.ServeHTTP(w, r) + }) +} diff --git a/internal/api/http/middleware/robots_tag_interceptor_test.go b/internal/api/http/middleware/robots_tag_interceptor_test.go new file mode 100644 index 0000000000..7e30d4a9b9 --- /dev/null +++ b/internal/api/http/middleware/robots_tag_interceptor_test.go @@ -0,0 +1,24 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_RobotsTagInterceptor(t *testing.T) { + testHandler := func(w http.ResponseWriter, r *http.Request) {} + req := httptest.NewRequest(http.MethodGet, "/", nil) + recorder := httptest.NewRecorder() + + handler := RobotsTagHandler(http.HandlerFunc(testHandler)) + handler.ServeHTTP(recorder, req) + + res := recorder.Result() + exp := res.Header.Get("X-Robots-Tag") + assert.Equal(t, "none", exp) + + defer res.Body.Close() +} diff --git a/internal/api/robots_txt/robots_txt.go b/internal/api/robots_txt/robots_txt.go new file mode 100644 index 0000000000..be487740dc --- /dev/null +++ b/internal/api/robots_txt/robots_txt.go @@ -0,0 +1,19 @@ +package robots_txt + +import ( + "fmt" + "net/http" +) + +const ( + HandlerPrefix = "/robots.txt" +) + +func Start() (http.Handler, error) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/text") + fmt.Fprintf(w, "User-agent: *\nDisallow: /\n") + }) + return handler, nil +} diff --git a/internal/api/robots_txt/robots_txt_test.go b/internal/api/robots_txt/robots_txt_test.go new file mode 100644 index 0000000000..ca70b6bc3c --- /dev/null +++ b/internal/api/robots_txt/robots_txt_test.go @@ -0,0 +1,28 @@ +package robots_txt + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_RobotsTxt(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/robots.txt", nil) + recorder := httptest.NewRecorder() + + handler, err := Start() + handler.ServeHTTP(recorder, req) + assert.Equal(t, nil, err) + + res := recorder.Result() + body, err := io.ReadAll(res.Body) + assert.Equal(t, nil, err) + + assert.Equal(t, 200, res.StatusCode) + assert.Equal(t, "User-agent: *\nDisallow: /\n", string(body)) + + defer res.Body.Close() +} diff --git a/internal/api/ui/login/custom_action.go b/internal/api/ui/login/custom_action.go index 772017277e..d6dbe6cdd4 100644 --- a/internal/api/ui/login/custom_action.go +++ b/internal/api/ui/login/custom_action.go @@ -6,6 +6,7 @@ import ( "net/http" "github.com/dop251/goja" + "github.com/zitadel/logging" "github.com/zitadel/oidc/v2/pkg/oidc" "golang.org/x/text/language" @@ -14,6 +15,7 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/idp" + "github.com/zitadel/zitadel/internal/query" ) func (l *Login) runPostExternalAuthenticationActions( @@ -26,7 +28,21 @@ func (l *Login) runPostExternalAuthenticationActions( ) (_ *domain.ExternalUser, userChanged bool, err error) { ctx := httpRequest.Context() + // use the request org (scopes or domain discovery) as default resourceOwner := authRequest.RequestedOrgID + // if the user was already linked to an IDP and redirected to that, the requested org might be empty + if resourceOwner == "" { + resourceOwner = authRequest.UserOrgID + } + // if we will have no org (e.g. user clicked directly on the IDP on the login page) + if resourceOwner == "" { + // in this case the user might nevertheless already be linked to an IDP, + // so let's do a workaround and resourceOwnerOfUserIDPLink if there would be a IDP link + resourceOwner, err = l.resourceOwnerOfUserIDPLink(ctx, authRequest.SelectedIDPConfigID, user.ExternalUserID) + logging.WithFields("authReq", authRequest.ID, "idpID", authRequest.SelectedIDPConfigID).OnError(err). + Warn("could not determine resource owner for runPostExternalAuthenticationActions, fall back to default org id") + } + // fallback to default org id if resourceOwner == "" { resourceOwner = authz.GetInstance(ctx).DefaultOrganisationID() } @@ -394,3 +410,25 @@ func tokenCtxFields(tokens *oidc.Tokens[*oidc.IDTokenClaims]) []actions.FieldOpt actions.SetFields("claimsJSON", claimsJSON), } } + +func (l *Login) resourceOwnerOfUserIDPLink(ctx context.Context, idpConfigID string, externalUserID string) (string, error) { + idQuery, err := query.NewIDPUserLinkIDPIDSearchQuery(idpConfigID) + if err != nil { + return "", err + } + externalIDQuery, err := query.NewIDPUserLinksExternalIDSearchQuery(externalUserID) + if err != nil { + return "", err + } + queries := []query.SearchQuery{ + idQuery, externalIDQuery, + } + links, err := l.query.IDPUserLinks(ctx, &query.IDPUserLinksSearchQuery{Queries: queries}, false) + if err != nil { + return "", err + } + if len(links.Links) != 1 { + return "", nil + } + return links.Links[0].ResourceOwner, nil +} diff --git a/internal/api/ui/login/static/templates/main.html b/internal/api/ui/login/static/templates/main.html index 08aac7c1bb..04ec281ebe 100644 --- a/internal/api/ui/login/static/templates/main.html +++ b/internal/api/ui/login/static/templates/main.html @@ -23,6 +23,7 @@ {{ .Title }} + diff --git a/internal/command/command.go b/internal/command/command.go index 82f2a4c455..e9fa9f739f 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -20,6 +20,7 @@ import ( "github.com/zitadel/zitadel/internal/repository/org" proj_repo "github.com/zitadel/zitadel/internal/repository/project" "github.com/zitadel/zitadel/internal/repository/quota" + "github.com/zitadel/zitadel/internal/repository/session" usr_repo "github.com/zitadel/zitadel/internal/repository/user" usr_grant_repo "github.com/zitadel/zitadel/internal/repository/usergrant" "github.com/zitadel/zitadel/internal/static" @@ -29,7 +30,7 @@ import ( type Commands struct { httpClient *http.Client - checkPermission permissionCheck + checkPermission domain.PermissionCheck newEmailCode func(ctx context.Context, filter preparation.FilterToQueryReducer, codeAlg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error) eventstore *eventstore.Eventstore @@ -50,6 +51,8 @@ type Commands struct { domainVerificationAlg crypto.EncryptionAlgorithm domainVerificationGenerator crypto.Generator domainVerificationValidator func(domain, token, verifier string, checkType api_http.CheckType) error + sessionTokenCreator func(sessionID string) (id string, token string, err error) + sessionTokenVerifier func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) multifactors domain.MultifactorConfigs webauthnConfig *webauthn_helper.Config @@ -71,24 +74,21 @@ func StartCommands( externalDomain string, externalSecure bool, externalPort uint16, - idpConfigEncryption, - otpEncryption, - smtpEncryption, - smsEncryption, - userEncryption, - domainVerificationEncryption, - oidcEncryption, - samlEncryption crypto.EncryptionAlgorithm, + idpConfigEncryption, otpEncryption, smtpEncryption, smsEncryption, userEncryption, domainVerificationEncryption, oidcEncryption, samlEncryption crypto.EncryptionAlgorithm, httpClient *http.Client, - membershipsResolver authz.MembershipsResolver, + permissionCheck domain.PermissionCheck, + sessionTokenVerifier func(ctx context.Context, sessionToken string, sessionID string, tokenID string) (err error), ) (repo *Commands, err error) { if externalDomain == "" { return nil, errors.ThrowInvalidArgument(nil, "COMMAND-Df21s", "no external domain specified") } + idGenerator := id.SonyFlakeGenerator() + // reuse the oidcEncryption to be able to handle both tokens in the interceptor later on + sessionAlg := oidcEncryption repo = &Commands{ eventstore: es, static: staticStore, - idGenerator: id.SonyFlakeGenerator(), + idGenerator: idGenerator, zitadelRoles: zitadelRoles, externalDomain: externalDomain, externalSecure: externalSecure, @@ -107,10 +107,10 @@ func StartCommands( certificateAlgorithm: samlEncryption, webauthnConfig: webAuthN, httpClient: httpClient, - checkPermission: func(ctx context.Context, permission, orgID, resourceID string, allowSelf bool) (err error) { - return authz.CheckPermission(ctx, membershipsResolver, zitadelRoles, permission, orgID, resourceID, allowSelf) - }, - newEmailCode: newEmailCode, + checkPermission: permissionCheck, + newEmailCode: newEmailCode, + sessionTokenCreator: sessionTokenCreator(idGenerator, sessionAlg), + sessionTokenVerifier: sessionTokenVerifier, } instance_repo.RegisterEventMappers(repo.eventstore) @@ -121,6 +121,7 @@ func StartCommands( keypair.RegisterEventMappers(repo.eventstore) action.RegisterEventMappers(repo.eventstore) quota.RegisterEventMappers(repo.eventstore) + session.RegisterEventMappers(repo.eventstore) repo.userPasswordAlg = crypto.NewBCrypt(defaults.SecretGenerators.PasswordSaltCost) repo.machineKeySize = int(defaults.SecretGenerators.MachineKeySize) diff --git a/internal/command/main_test.go b/internal/command/main_test.go index 2f516133f4..10fc1c2f6c 100644 --- a/internal/command/main_test.go +++ b/internal/command/main_test.go @@ -10,6 +10,7 @@ import ( "golang.org/x/text/language" "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/errors" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/repository" @@ -19,6 +20,7 @@ import ( key_repo "github.com/zitadel/zitadel/internal/repository/keypair" "github.com/zitadel/zitadel/internal/repository/org" proj_repo "github.com/zitadel/zitadel/internal/repository/project" + "github.com/zitadel/zitadel/internal/repository/session" usr_repo "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/repository/usergrant" ) @@ -38,6 +40,7 @@ func eventstoreExpect(t *testing.T, expects ...expect) *eventstore.Eventstore { usergrant.RegisterEventMappers(es) key_repo.RegisterEventMappers(es) action_repo.RegisterEventMappers(es) + session.RegisterEventMappers(es) return es } @@ -125,6 +128,11 @@ func expectFilter(events ...*repository.Event) expect { m.ExpectFilterEvents(events...) } } +func expectFilterError(err error) expect { + return func(m *mock.MockRepository) { + m.ExpectFilterEventsError(err) + } +} func expectFilterOrgDomainNotFound() expect { return func(m *mock.MockRepository) { @@ -250,14 +258,14 @@ func (m *mockInstance) SecurityPolicyAllowedOrigins() []string { return nil } -func newMockPermissionCheckAllowed() permissionCheck { - return func(ctx context.Context, permission, orgID, resourceID string, allowSelf bool) (err error) { +func newMockPermissionCheckAllowed() domain.PermissionCheck { + return func(ctx context.Context, permission, orgID, resourceID string) (err error) { return nil } } -func newMockPermissionCheckNotAllowed() permissionCheck { - return func(ctx context.Context, permission, orgID, resourceID string, allowSelf bool) (err error) { +func newMockPermissionCheckNotAllowed() domain.PermissionCheck { + return func(ctx context.Context, permission, orgID, resourceID string) (err error) { return errors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied") } } diff --git a/internal/command/permission.go b/internal/command/permission.go deleted file mode 100644 index 138df0a44a..0000000000 --- a/internal/command/permission.go +++ /dev/null @@ -1,11 +0,0 @@ -package command - -import ( - "context" -) - -type permissionCheck func(ctx context.Context, permission, orgID, resourceID string, allowSelf bool) (err error) - -const ( - permissionUserWrite = "user.write" -) diff --git a/internal/command/session.go b/internal/command/session.go new file mode 100644 index 0000000000..cb652ea194 --- /dev/null +++ b/internal/command/session.go @@ -0,0 +1,225 @@ +package command + +import ( + "context" + "encoding/base64" + "fmt" + "time" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + caos_errs "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/id" + "github.com/zitadel/zitadel/internal/repository/session" + "github.com/zitadel/zitadel/internal/telemetry/tracing" +) + +type SessionCheck func(ctx context.Context, cmd *SessionChecks) error + +type SessionChecks struct { + checks []SessionCheck + + sessionWriteModel *SessionWriteModel + passwordWriteModel *HumanPasswordWriteModel + eventstore *eventstore.Eventstore + userPasswordAlg crypto.HashAlgorithm + createToken func(sessionID string) (id string, token string, err error) + now func() time.Time +} + +func (c *Commands) NewSessionChecks(checks []SessionCheck, session *SessionWriteModel) *SessionChecks { + return &SessionChecks{ + checks: checks, + sessionWriteModel: session, + eventstore: c.eventstore, + userPasswordAlg: c.userPasswordAlg, + createToken: c.sessionTokenCreator, + now: time.Now, + } +} + +// CheckUser defines a user check to be executed for a session update +func CheckUser(id string) SessionCheck { + return func(ctx context.Context, cmd *SessionChecks) error { + if cmd.sessionWriteModel.UserID != "" && id != "" && cmd.sessionWriteModel.UserID != id { + return caos_errs.ThrowInvalidArgument(nil, "", "user change not possible") + } + return cmd.sessionWriteModel.UserChecked(ctx, id, cmd.now()) + } +} + +// CheckPassword defines a password check to be executed for a session update +func CheckPassword(password string) SessionCheck { + return func(ctx context.Context, cmd *SessionChecks) error { + if cmd.sessionWriteModel.UserID == "" { + return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Sfw3f", "Errors.User.UserIDMissing") + } + cmd.passwordWriteModel = NewHumanPasswordWriteModel(cmd.sessionWriteModel.UserID, "") + err := cmd.eventstore.FilterToQueryReducer(ctx, cmd.passwordWriteModel) + if err != nil { + return err + } + if cmd.passwordWriteModel.UserState == domain.UserStateUnspecified || cmd.passwordWriteModel.UserState == domain.UserStateDeleted { + return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Df4b3", "Errors.User.NotFound") + } + + if cmd.passwordWriteModel.Secret == nil { + return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-WEf3t", "Errors.User.Password.NotSet") + } + ctx, spanPasswordComparison := tracing.NewNamedSpan(ctx, "crypto.CompareHash") + err = crypto.CompareHash(cmd.passwordWriteModel.Secret, []byte(password), cmd.userPasswordAlg) + spanPasswordComparison.EndWithError(err) + if err != nil { + //TODO: maybe we want to reset the session in the future https://github.com/zitadel/zitadel/issues/5807 + return caos_errs.ThrowInvalidArgument(err, "COMMAND-SAF3g", "Errors.User.Password.Invalid") + } + cmd.sessionWriteModel.PasswordChecked(ctx, cmd.now()) + return nil + } +} + +// Check will execute the checks specified and return an error on the first occurrence +func (s *SessionChecks) Check(ctx context.Context) error { + for _, check := range s.checks { + if err := check(ctx, s); err != nil { + return err + } + } + return nil +} + +func (s *SessionChecks) commands(ctx context.Context) (string, []eventstore.Command, error) { + if len(s.sessionWriteModel.commands) == 0 { + return "", nil, nil + } + + tokenID, token, err := s.createToken(s.sessionWriteModel.AggregateID) + if err != nil { + return "", nil, err + } + s.sessionWriteModel.SetToken(ctx, tokenID) + return token, s.sessionWriteModel.commands, nil +} + +func (c *Commands) CreateSession(ctx context.Context, checks []SessionCheck, metadata map[string][]byte) (set *SessionChanged, err error) { + sessionID, err := c.idGenerator.Next() + if err != nil { + return nil, err + } + sessionWriteModel := NewSessionWriteModel(sessionID, authz.GetCtxData(ctx).OrgID) + err = c.eventstore.FilterToQueryReducer(ctx, sessionWriteModel) + if err != nil { + return nil, err + } + cmd := c.NewSessionChecks(checks, sessionWriteModel) + cmd.sessionWriteModel.Start(ctx) + return c.updateSession(ctx, cmd, metadata) +} + +func (c *Commands) UpdateSession(ctx context.Context, sessionID, sessionToken string, checks []SessionCheck, metadata map[string][]byte) (set *SessionChanged, err error) { + sessionWriteModel := NewSessionWriteModel(sessionID, authz.GetCtxData(ctx).OrgID) + err = c.eventstore.FilterToQueryReducer(ctx, sessionWriteModel) + if err != nil { + return nil, err + } + if err := c.sessionPermission(ctx, sessionWriteModel, sessionToken, domain.PermissionSessionWrite); err != nil { + return nil, err + } + cmd := c.NewSessionChecks(checks, sessionWriteModel) + return c.updateSession(ctx, cmd, metadata) +} + +func (c *Commands) TerminateSession(ctx context.Context, sessionID, sessionToken string) (*domain.ObjectDetails, error) { + sessionWriteModel := NewSessionWriteModel(sessionID, "") + if err := c.eventstore.FilterToQueryReducer(ctx, sessionWriteModel); err != nil { + return nil, err + } + if err := c.sessionPermission(ctx, sessionWriteModel, sessionToken, domain.PermissionSessionDelete); err != nil { + return nil, err + } + if sessionWriteModel.State != domain.SessionStateActive { + return writeModelToObjectDetails(&sessionWriteModel.WriteModel), nil + } + terminate := session.NewTerminateEvent(ctx, &session.NewAggregate(sessionWriteModel.AggregateID, sessionWriteModel.ResourceOwner).Aggregate) + pushedEvents, err := c.eventstore.Push(ctx, terminate) + if err != nil { + return nil, err + } + err = AppendAndReduce(sessionWriteModel, pushedEvents...) + if err != nil { + return nil, err + } + return writeModelToObjectDetails(&sessionWriteModel.WriteModel), nil +} + +// updateSession execute the [SessionChecks] where new events will be created and as well as for metadata (changes) +func (c *Commands) updateSession(ctx context.Context, checks *SessionChecks, metadata map[string][]byte) (set *SessionChanged, err error) { + if checks.sessionWriteModel.State == domain.SessionStateTerminated { + return nil, caos_errs.ThrowPreconditionFailed(nil, "COMAND-SAjeh", "Errors.Session.Terminated") + } + if err := checks.Check(ctx); err != nil { + // TODO: how to handle failed checks (e.g. pw wrong) https://github.com/zitadel/zitadel/issues/5807 + return nil, err + } + checks.sessionWriteModel.ChangeMetadata(ctx, metadata) + sessionToken, cmds, err := checks.commands(ctx) + if err != nil { + return nil, err + } + if len(cmds) == 0 { + return sessionWriteModelToSessionChanged(checks.sessionWriteModel), nil + } + pushedEvents, err := c.eventstore.Push(ctx, cmds...) + if err != nil { + return nil, err + } + err = AppendAndReduce(checks.sessionWriteModel, pushedEvents...) + if err != nil { + return nil, err + } + changed := sessionWriteModelToSessionChanged(checks.sessionWriteModel) + changed.NewToken = sessionToken + return changed, nil +} + +// sessionPermission will check that the provided sessionToken is correct or +// if empty, check that the caller is granted the necessary permission +func (c *Commands) sessionPermission(ctx context.Context, sessionWriteModel *SessionWriteModel, sessionToken, permission string) (err error) { + if sessionToken == "" { + return c.checkPermission(ctx, permission, authz.GetCtxData(ctx).OrgID, sessionWriteModel.AggregateID) + } + return c.sessionTokenVerifier(ctx, sessionToken, sessionWriteModel.AggregateID, sessionWriteModel.TokenID) +} + +func sessionTokenCreator(idGenerator id.Generator, sessionAlg crypto.EncryptionAlgorithm) func(sessionID string) (id string, token string, err error) { + return func(sessionID string) (id string, token string, err error) { + id, err = idGenerator.Next() + if err != nil { + return "", "", err + } + encrypted, err := sessionAlg.Encrypt([]byte(fmt.Sprintf(authz.SessionTokenFormat, sessionID, id))) + if err != nil { + return "", "", err + } + return id, base64.RawURLEncoding.EncodeToString(encrypted), nil + } +} + +type SessionChanged struct { + *domain.ObjectDetails + ID string + NewToken string +} + +func sessionWriteModelToSessionChanged(wm *SessionWriteModel) *SessionChanged { + return &SessionChanged{ + ObjectDetails: &domain.ObjectDetails{ + Sequence: wm.ProcessedSequence, + EventDate: wm.ChangeDate, + ResourceOwner: wm.ResourceOwner, + }, + ID: wm.AggregateID, + } +} diff --git a/internal/command/session_model.go b/internal/command/session_model.go new file mode 100644 index 0000000000..e331693818 --- /dev/null +++ b/internal/command/session_model.go @@ -0,0 +1,139 @@ +package command + +import ( + "bytes" + "context" + "time" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/session" +) + +type SessionWriteModel struct { + eventstore.WriteModel + + TokenID string + UserID string + UserCheckedAt time.Time + PasswordCheckedAt time.Time + Metadata map[string][]byte + State domain.SessionState + + commands []eventstore.Command + aggregate *eventstore.Aggregate +} + +func NewSessionWriteModel(sessionID string, resourceOwner string) *SessionWriteModel { + return &SessionWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: sessionID, + ResourceOwner: resourceOwner, + }, + Metadata: make(map[string][]byte), + aggregate: &session.NewAggregate(sessionID, resourceOwner).Aggregate, + } +} + +func (wm *SessionWriteModel) Reduce() error { + for _, event := range wm.Events { + switch e := event.(type) { + case *session.AddedEvent: + wm.reduceAdded(e) + case *session.UserCheckedEvent: + wm.reduceUserChecked(e) + case *session.PasswordCheckedEvent: + wm.reducePasswordChecked(e) + case *session.TokenSetEvent: + wm.reduceTokenSet(e) + case *session.TerminateEvent: + wm.reduceTerminate() + } + } + return wm.WriteModel.Reduce() +} + +func (wm *SessionWriteModel) Query() *eventstore.SearchQueryBuilder { + query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + AddQuery(). + AggregateTypes(session.AggregateType). + AggregateIDs(wm.AggregateID). + EventTypes( + session.AddedType, + session.UserCheckedType, + session.PasswordCheckedType, + session.TokenSetType, + session.MetadataSetType, + session.TerminateType, + ). + Builder() + + if wm.ResourceOwner != "" { + query.ResourceOwner(wm.ResourceOwner) + } + return query +} + +func (wm *SessionWriteModel) reduceAdded(e *session.AddedEvent) { + wm.State = domain.SessionStateActive +} + +func (wm *SessionWriteModel) reduceUserChecked(e *session.UserCheckedEvent) { + wm.UserID = e.UserID + wm.UserCheckedAt = e.CheckedAt +} + +func (wm *SessionWriteModel) reducePasswordChecked(e *session.PasswordCheckedEvent) { + wm.PasswordCheckedAt = e.CheckedAt +} + +func (wm *SessionWriteModel) reduceTokenSet(e *session.TokenSetEvent) { + wm.TokenID = e.TokenID +} + +func (wm *SessionWriteModel) reduceTerminate() { + wm.State = domain.SessionStateTerminated +} + +func (wm *SessionWriteModel) Start(ctx context.Context) { + wm.commands = append(wm.commands, session.NewAddedEvent(ctx, wm.aggregate)) +} + +func (wm *SessionWriteModel) UserChecked(ctx context.Context, userID string, checkedAt time.Time) error { + wm.commands = append(wm.commands, session.NewUserCheckedEvent(ctx, wm.aggregate, userID, checkedAt)) + // set the userID so other checks can use it + wm.UserID = userID + return nil +} + +func (wm *SessionWriteModel) PasswordChecked(ctx context.Context, checkedAt time.Time) { + wm.commands = append(wm.commands, session.NewPasswordCheckedEvent(ctx, wm.aggregate, checkedAt)) +} + +func (wm *SessionWriteModel) SetToken(ctx context.Context, tokenID string) { + wm.commands = append(wm.commands, session.NewTokenSetEvent(ctx, wm.aggregate, tokenID)) +} + +func (wm *SessionWriteModel) ChangeMetadata(ctx context.Context, metadata map[string][]byte) { + var changed bool + for key, value := range metadata { + currentValue, exists := wm.Metadata[key] + + if len(value) != 0 { + // if a value is provided, and it's not equal, change it + if !bytes.Equal(currentValue, value) { + wm.Metadata[key] = value + changed = true + } + } else { + // if there's no / an empty value, we only need to remove it on existing entries + if exists { + delete(wm.Metadata, key) + changed = true + } + } + } + if changed { + wm.commands = append(wm.commands, session.NewMetadataSetEvent(ctx, wm.aggregate, wm.Metadata)) + } +} diff --git a/internal/command/session_test.go b/internal/command/session_test.go new file mode 100644 index 0000000000..eb080480fa --- /dev/null +++ b/internal/command/session_test.go @@ -0,0 +1,547 @@ +package command + +import ( + "context" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + caos_errs "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/id" + "github.com/zitadel/zitadel/internal/id/mock" + "github.com/zitadel/zitadel/internal/repository/session" + "github.com/zitadel/zitadel/internal/repository/user" +) + +func TestCommands_CreateSession(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + idGenerator id.Generator + tokenCreator func(sessionID string) (string, string, error) + } + type args struct { + ctx context.Context + checks []SessionCheck + metadata map[string][]byte + } + type res struct { + want *SessionChanged + err error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "id generator fails", + fields{ + idGenerator: mock.NewIDGeneratorExpectError(t, caos_errs.ThrowInternal(nil, "id", "generator failed")), + }, + args{ + ctx: context.Background(), + }, + res{ + err: caos_errs.ThrowInternal(nil, "id", "generator failed"), + }, + }, + { + "eventstore failed", + fields{ + idGenerator: mock.NewIDGeneratorExpectIDs(t, "sessionID"), + eventstore: eventstoreExpect(t, + expectFilterError(caos_errs.ThrowInternal(nil, "id", "filter failed")), + ), + }, + args{ + ctx: context.Background(), + }, + res{ + err: caos_errs.ThrowInternal(nil, "id", "filter failed"), + }, + }, + { + "empty session", + fields{ + idGenerator: mock.NewIDGeneratorExpectIDs(t, "sessionID"), + eventstore: eventstoreExpect(t, + expectFilter(), + expectPush( + eventPusherToEvents( + session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate), + session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, + "tokenID", + ), + ), + ), + ), + tokenCreator: func(sessionID string) (string, string, error) { + return "tokenID", + "token", + nil + }, + }, + args{ + ctx: authz.NewMockContext("", "org1", ""), + }, + res{ + want: &SessionChanged{ + ObjectDetails: &domain.ObjectDetails{ResourceOwner: "org1"}, + ID: "sessionID", + NewToken: "token", + }, + }, + }, + // the rest is tested in the Test_updateSession + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + idGenerator: tt.fields.idGenerator, + sessionTokenCreator: tt.fields.tokenCreator, + } + got, err := c.CreateSession(tt.args.ctx, tt.args.checks, tt.args.metadata) + require.ErrorIs(t, err, tt.res.err) + assert.Equal(t, tt.res.want, got) + }) + } +} + +func TestCommands_UpdateSession(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + tokenVerifier func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) + } + type args struct { + ctx context.Context + sessionID string + sessionToken string + checks []SessionCheck + metadata map[string][]byte + } + type res struct { + want *SessionChanged + err error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "eventstore failed", + fields{ + eventstore: eventstoreExpect(t, + expectFilterError(caos_errs.ThrowInternal(nil, "id", "filter failed")), + ), + }, + args{ + ctx: context.Background(), + }, + res{ + err: caos_errs.ThrowInternal(nil, "id", "filter failed"), + }, + }, + { + "invalid session token", + fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)), + eventFromEventPusher( + session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, + "tokenID")), + ), + ), + tokenVerifier: func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) { + return caos_errs.ThrowPermissionDenied(nil, "COMMAND-sGr42", "Errors.Session.Token.Invalid") + }, + }, + args{ + ctx: context.Background(), + sessionID: "sessionID", + sessionToken: "invalid", + }, + res{ + err: caos_errs.ThrowPermissionDenied(nil, "COMMAND-sGr42", "Errors.Session.Token.Invalid"), + }, + }, + { + "no change", + fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)), + eventFromEventPusher( + session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, + "tokenID")), + ), + ), + tokenVerifier: func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) { + return nil + }, + }, + args{ + ctx: context.Background(), + sessionID: "sessionID", + sessionToken: "token", + }, + res{ + want: &SessionChanged{ + ObjectDetails: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + ID: "sessionID", + NewToken: "", + }, + }, + }, + // the rest is tested in the Test_updateSession + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + sessionTokenVerifier: tt.fields.tokenVerifier, + } + got, err := c.UpdateSession(tt.args.ctx, tt.args.sessionID, tt.args.sessionToken, tt.args.checks, tt.args.metadata) + require.ErrorIs(t, err, tt.res.err) + assert.Equal(t, tt.res.want, got) + }) + } +} + +func TestCommands_updateSession(t *testing.T) { + testNow := time.Now() + type fields struct { + eventstore *eventstore.Eventstore + } + type args struct { + ctx context.Context + checks *SessionChecks + metadata map[string][]byte + } + type res struct { + want *SessionChanged + err error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "terminated", + fields{ + eventstore: eventstoreExpect(t), + }, + args{ + ctx: context.Background(), + checks: &SessionChecks{ + sessionWriteModel: &SessionWriteModel{State: domain.SessionStateTerminated}, + }, + }, + res{ + err: caos_errs.ThrowPreconditionFailed(nil, "COMAND-SAjeh", "Errors.Session.Terminated"), + }, + }, + { + "check failed", + fields{ + eventstore: eventstoreExpect(t), + }, + args{ + ctx: context.Background(), + checks: &SessionChecks{ + sessionWriteModel: NewSessionWriteModel("sessionID", "org1"), + checks: []SessionCheck{ + func(ctx context.Context, cmd *SessionChecks) error { + return caos_errs.ThrowInternal(nil, "id", "check failed") + }, + }, + }, + }, + res{ + err: caos_errs.ThrowInternal(nil, "id", "check failed"), + }, + }, + { + "no change", + fields{ + eventstore: eventstoreExpect(t), + }, + args{ + ctx: context.Background(), + checks: &SessionChecks{ + sessionWriteModel: NewSessionWriteModel("sessionID", "org1"), + checks: []SessionCheck{}, + }, + }, + res{ + want: &SessionChanged{ + ObjectDetails: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + ID: "sessionID", + NewToken: "", + }, + }, + }, + { + "set user, password, metadata and token", + fields{ + eventstore: eventstoreExpect(t, + expectPush( + eventPusherToEvents( + session.NewUserCheckedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, + "userID", testNow), + session.NewPasswordCheckedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, + testNow), + session.NewMetadataSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, + map[string][]byte{"key": []byte("value")}), + session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, + "tokenID"), + ), + ), + ), + }, + args{ + ctx: context.Background(), + checks: &SessionChecks{ + sessionWriteModel: NewSessionWriteModel("sessionID", "org1"), + checks: []SessionCheck{ + CheckUser("userID"), + CheckPassword("password"), + }, + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate, + "username", "", "", "", "", language.English, domain.GenderUnspecified, "", false), + ), + eventFromEventPusher( + user.NewHumanPasswordChangedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeHash, + Algorithm: "hash", + KeyID: "", + Crypted: []byte("password"), + }, false, ""), + ), + ), + ), + createToken: func(sessionID string) (string, string, error) { + return "tokenID", + "token", + nil + }, + userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), + now: func() time.Time { + return testNow + }, + }, + metadata: map[string][]byte{ + "key": []byte("value"), + }, + }, + res{ + want: &SessionChanged{ + ObjectDetails: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + ID: "sessionID", + NewToken: "token", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + } + got, err := c.updateSession(tt.args.ctx, tt.args.checks, tt.args.metadata) + require.ErrorIs(t, err, tt.res.err) + assert.Equal(t, tt.res.want, got) + }) + } +} + +func TestCommands_TerminateSession(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + tokenVerifier func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) + } + type args struct { + ctx context.Context + sessionID string + sessionToken string + } + type res struct { + want *domain.ObjectDetails + err error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "eventstore failed", + fields{ + eventstore: eventstoreExpect(t, + expectFilterError(caos_errs.ThrowInternal(nil, "id", "filter failed")), + ), + }, + args{ + ctx: context.Background(), + }, + res{ + err: caos_errs.ThrowInternal(nil, "id", "filter failed"), + }, + }, + { + "invalid session token", + fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)), + eventFromEventPusher( + session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, + "tokenID")), + ), + ), + tokenVerifier: func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) { + return caos_errs.ThrowPermissionDenied(nil, "COMMAND-sGr42", "Errors.Session.Token.Invalid") + }, + }, + args{ + ctx: context.Background(), + sessionID: "sessionID", + sessionToken: "invalid", + }, + res{ + err: caos_errs.ThrowPermissionDenied(nil, "COMMAND-sGr42", "Errors.Session.Token.Invalid"), + }, + }, + { + "not active", + fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)), + eventFromEventPusher( + session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, + "tokenID")), + eventFromEventPusher( + session.NewTerminateEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)), + ), + ), + tokenVerifier: func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) { + return nil + }, + }, + args{ + ctx: context.Background(), + sessionID: "sessionID", + sessionToken: "token", + }, + res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "push failed", + fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)), + eventFromEventPusher( + session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, + "tokenID"), + ), + ), + expectPushFailed( + caos_errs.ThrowInternal(nil, "id", "pushed failed"), + eventPusherToEvents( + session.NewTerminateEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)), + ), + ), + tokenVerifier: func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) { + return nil + }, + }, + args{ + ctx: context.Background(), + sessionID: "sessionID", + sessionToken: "token", + }, + res{ + err: caos_errs.ThrowInternal(nil, "id", "pushed failed"), + }, + }, + { + "terminate", + fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)), + eventFromEventPusher( + session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, + "tokenID"), + ), + ), + expectPush( + eventPusherToEvents( + session.NewTerminateEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)), + ), + ), + tokenVerifier: func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) { + return nil + }, + }, + args{ + ctx: context.Background(), + sessionID: "sessionID", + sessionToken: "token", + }, + res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + sessionTokenVerifier: tt.fields.tokenVerifier, + } + got, err := c.TerminateSession(tt.args.ctx, tt.args.sessionID, tt.args.sessionToken) + require.ErrorIs(t, err, tt.res.err) + assert.Equal(t, tt.res.want, got) + }) + } +} diff --git a/internal/command/user_v2_email.go b/internal/command/user_v2_email.go index d636b21a71..d00eb0f040 100644 --- a/internal/command/user_v2_email.go +++ b/internal/command/user_v2_email.go @@ -6,6 +6,7 @@ import ( "github.com/zitadel/logging" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" caos_errs "github.com/zitadel/zitadel/internal/errors" @@ -42,7 +43,7 @@ func (c *Commands) ChangeUserEmailVerified(ctx context.Context, userID, resource if err != nil { return nil, err } - if err = c.checkPermission(ctx, permissionUserWrite, cmd.aggregate.ResourceOwner, userID, false); err != nil { + if err = c.checkPermission(ctx, domain.PermissionUserWrite, cmd.aggregate.ResourceOwner, userID); err != nil { return nil, err } if err = cmd.Change(ctx, domain.EmailAddress(email)); err != nil { @@ -70,8 +71,10 @@ func (c *Commands) changeUserEmailWithGenerator(ctx context.Context, userID, res if err != nil { return nil, err } - if err = c.checkPermission(ctx, permissionUserWrite, cmd.aggregate.ResourceOwner, userID, true); err != nil { - return nil, err + if authz.GetCtxData(ctx).UserID != userID { + if err = c.checkPermission(ctx, domain.PermissionUserWrite, cmd.aggregate.ResourceOwner, userID); err != nil { + return nil, err + } } if err = cmd.Change(ctx, domain.EmailAddress(email)); err != nil { return nil, err diff --git a/internal/command/user_v2_email_test.go b/internal/command/user_v2_email_test.go index 9989229efd..af6478473d 100644 --- a/internal/command/user_v2_email_test.go +++ b/internal/command/user_v2_email_test.go @@ -24,7 +24,7 @@ import ( func TestCommands_ChangeUserEmail(t *testing.T) { type fields struct { eventstore *eventstore.Eventstore - checkPermission permissionCheck + checkPermission domain.PermissionCheck } type args struct { userID string @@ -174,7 +174,7 @@ func TestCommands_ChangeUserEmail(t *testing.T) { func TestCommands_ChangeUserEmailURLTemplate(t *testing.T) { type fields struct { eventstore *eventstore.Eventstore - checkPermission permissionCheck + checkPermission domain.PermissionCheck } type args struct { userID string @@ -300,7 +300,7 @@ func TestCommands_ChangeUserEmailURLTemplate(t *testing.T) { func TestCommands_ChangeUserEmailReturnCode(t *testing.T) { type fields struct { eventstore *eventstore.Eventstore - checkPermission permissionCheck + checkPermission domain.PermissionCheck } type args struct { userID string @@ -410,7 +410,7 @@ func TestCommands_ChangeUserEmailReturnCode(t *testing.T) { func TestCommands_ChangeUserEmailVerified(t *testing.T) { type fields struct { eventstore *eventstore.Eventstore - checkPermission permissionCheck + checkPermission domain.PermissionCheck } type args struct { userID string @@ -569,7 +569,7 @@ func TestCommands_ChangeUserEmailVerified(t *testing.T) { func TestCommands_changeUserEmailWithGenerator(t *testing.T) { type fields struct { eventstore *eventstore.Eventstore - checkPermission permissionCheck + checkPermission domain.PermissionCheck } type args struct { userID string diff --git a/internal/database/type.go b/internal/database/type.go index 5757529c8e..8360bc1f13 100644 --- a/internal/database/type.go +++ b/internal/database/type.go @@ -2,13 +2,14 @@ package database import ( "database/sql/driver" + "encoding/json" "github.com/jackc/pgtype" ) type StringArray []string -// Scan implements the `database/sql.Scanner` interface. +// Scan implements the [database/sql.Scanner] interface. func (s *StringArray) Scan(src any) error { array := new(pgtype.TextArray) if err := array.Scan(src); err != nil { @@ -20,7 +21,7 @@ func (s *StringArray) Scan(src any) error { return nil } -// Value implements the `database/sql/driver.Valuer` interface. +// Value implements the [database/sql/driver.Valuer] interface. func (s StringArray) Value() (driver.Value, error) { if len(s) == 0 { return nil, nil @@ -40,7 +41,7 @@ type enumField interface { type EnumArray[F enumField] []F -// Scan implements the `database/sql.Scanner` interface. +// Scan implements the [database/sql.Scanner] interface. func (s *EnumArray[F]) Scan(src any) error { array := new(pgtype.Int2Array) if err := array.Scan(src); err != nil { @@ -57,7 +58,7 @@ func (s *EnumArray[F]) Scan(src any) error { return nil } -// Value implements the `database/sql/driver.Valuer` interface. +// Value implements the [database/sql/driver.Valuer] interface. func (s EnumArray[F]) Value() (driver.Value, error) { if len(s) == 0 { return nil, nil @@ -70,3 +71,25 @@ func (s EnumArray[F]) Value() (driver.Value, error) { return array.Value() } + +type Map[V any] map[string]V + +// Scan implements the [database/sql.Scanner] interface. +func (m *Map[V]) Scan(src any) error { + bytea := new(pgtype.Bytea) + if err := bytea.Scan(src); err != nil { + return err + } + if len(bytea.Bytes) == 0 { + return nil + } + return json.Unmarshal(bytea.Bytes, &m) +} + +// Value implements the [database/sql/driver.Valuer] interface. +func (m Map[V]) Value() (driver.Value, error) { + if len(m) == 0 { + return nil, nil + } + return json.Marshal(m) +} diff --git a/internal/database/type_test.go b/internal/database/type_test.go new file mode 100644 index 0000000000..4586f1413f --- /dev/null +++ b/internal/database/type_test.go @@ -0,0 +1,119 @@ +package database + +import ( + "database/sql/driver" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMap_Scan(t *testing.T) { + type args struct { + src any + } + type res[V any] struct { + want Map[V] + err bool + } + type testCase[V any] struct { + name string + m Map[V] + args args + res[V] + } + tests := []testCase[string]{ + { + "null", + Map[string]{}, + args{src: "invalid"}, + res[string]{ + want: Map[string]{}, + err: true, + }, + }, + { + "null", + Map[string]{}, + args{src: nil}, + res[string]{ + want: Map[string]{}, + }, + }, + { + "empty", + Map[string]{}, + args{src: []byte(`{}`)}, + res[string]{ + want: Map[string]{}, + }, + }, + { + "set", + Map[string]{}, + args{src: []byte(`{"key": "value"}`)}, + res[string]{ + want: Map[string]{ + "key": "value", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := tt.m.Scan(tt.args.src); (err != nil) != tt.res.err { + t.Errorf("Scan() error = %v, wantErr %v", err, tt.res.err) + } + assert.Equal(t, tt.res.want, tt.m) + }) + } +} + +func TestMap_Value(t *testing.T) { + type res struct { + want driver.Value + err bool + } + type testCase[V any] struct { + name string + m Map[V] + res res + } + tests := []testCase[string]{ + { + "nil", + nil, + res{ + want: nil, + }, + }, + { + "empty", + Map[string]{}, + res{ + want: nil, + }, + }, + { + "set", + Map[string]{ + "key": "value", + }, + res{ + want: driver.Value([]byte(`{"key":"value"}`)), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.m.Value() + if tt.res.err { + assert.Error(t, err) + } + if !tt.res.err { + require.NoError(t, err) + assert.Equalf(t, tt.res.want, got, "Value()") + } + }) + } +} diff --git a/internal/domain/permission.go b/internal/domain/permission.go index a9a1df5f42..5a51d7bdb9 100644 --- a/internal/domain/permission.go +++ b/internal/domain/permission.go @@ -1,5 +1,7 @@ package domain +import "context" + type Permissions struct { Permissions []string } @@ -21,3 +23,12 @@ func (p *Permissions) appendPermission(ctxID, permission string) { } p.Permissions = append(p.Permissions, permission) } + +type PermissionCheck func(ctx context.Context, permission, orgID, resourceID string) (err error) + +const ( + PermissionUserWrite = "user.write" + PermissionSessionRead = "session.read" + PermissionSessionWrite = "session.write" + PermissionSessionDelete = "session.delete" +) diff --git a/internal/domain/session.go b/internal/domain/session.go new file mode 100644 index 0000000000..84a74a8f63 --- /dev/null +++ b/internal/domain/session.go @@ -0,0 +1,9 @@ +package domain + +type SessionState int32 + +const ( + SessionStateUnspecified SessionState = iota + SessionStateActive + SessionStateTerminated +) diff --git a/internal/id/mock/generator.mock.impl.go b/internal/id/mock/generator.mock.impl.go index e61f803982..f61a48b9de 100644 --- a/internal/id/mock/generator.mock.impl.go +++ b/internal/id/mock/generator.mock.impl.go @@ -25,3 +25,9 @@ func ExpectID(t *testing.T, id string) *MockGenerator { m.EXPECT().Next().Return(id, nil) return m } + +func NewIDGeneratorExpectError(t *testing.T, err error) *MockGenerator { + m := NewMockGenerator(gomock.NewController(t)) + m.EXPECT().Next().Return("", err) + return m +} diff --git a/internal/integration/assert.go b/internal/integration/assert.go new file mode 100644 index 0000000000..3361392987 --- /dev/null +++ b/internal/integration/assert.go @@ -0,0 +1,41 @@ +package integration + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha" +) + +type DetailsMsg interface { + GetDetails() *object.Details +} + +// AssertDetails asserts values in a message's object Details, +// if the object Details in expected is a non-nil value. +// It targets API v2 messages that have the `GetDetails()` method. +// +// Dynamically generated values are not compared with expected. +// Instead a sanity check is performed. +// For the sequence a non-zero value is expected. +// The change date has to be now, with a tollerance of 1 second. +// +// The resource owner is compared with expected and is +// therefore the only value that has to be set. +func AssertDetails[D DetailsMsg](t testing.TB, exptected, actual D) { + wantDetails, gotDetails := exptected.GetDetails(), actual.GetDetails() + if wantDetails == nil { + assert.Nil(t, gotDetails) + return + } + + assert.NotZero(t, gotDetails.GetSequence()) + + gotCD := gotDetails.GetChangeDate().AsTime() + now := time.Now() + assert.WithinRange(t, gotCD, now.Add(-time.Second), now.Add(time.Second)) + + assert.Equal(t, wantDetails.GetResourceOwner(), gotDetails.GetResourceOwner()) +} diff --git a/internal/integration/assert_test.go b/internal/integration/assert_test.go new file mode 100644 index 0000000000..49a6949007 --- /dev/null +++ b/internal/integration/assert_test.go @@ -0,0 +1,51 @@ +package integration + +import ( + "testing" + + "google.golang.org/protobuf/types/known/timestamppb" + + object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha" +) + +type myMsg struct { + details *object.Details +} + +func (m myMsg) GetDetails() *object.Details { + return m.details +} + +func TestAssertDetails(t *testing.T) { + tests := []struct { + name string + exptected myMsg + actual myMsg + }{ + { + name: "nil", + exptected: myMsg{}, + actual: myMsg{}, + }, + { + name: "values", + exptected: myMsg{ + details: &object.Details{ + ResourceOwner: "me", + }, + }, + actual: myMsg{ + details: &object.Details{ + Sequence: 123, + ChangeDate: timestamppb.Now(), + ResourceOwner: "me", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + AssertDetails(t, tt.exptected, tt.actual) + }) + } +} diff --git a/internal/integration/config/cockroach.yaml b/internal/integration/config/cockroach.yaml new file mode 100644 index 0000000000..920e3cd6ec --- /dev/null +++ b/internal/integration/config/cockroach.yaml @@ -0,0 +1,10 @@ +Database: + cockroach: + Host: localhost + Port: 26257 + Database: zitadel + Options: "" + User: + Username: zitadel + Admin: + Username: root diff --git a/internal/integration/config/docker-compose.yaml b/internal/integration/config/docker-compose.yaml new file mode 100644 index 0000000000..a43cecf335 --- /dev/null +++ b/internal/integration/config/docker-compose.yaml @@ -0,0 +1,24 @@ +version: '3.8' + +services: + cockroach: + extends: + file: '../../../e2e/config/localhost/docker-compose.yaml' + service: 'db' + + postgres: + restart: 'always' + image: 'postgres:15' + environment: + - POSTGRES_USER=zitadel + - PGUSER=zitadel + - POSTGRES_DB=zitadel + - POSTGRES_HOST_AUTH_METHOD=trust + healthcheck: + test: ["CMD-SHELL", "pg_isready"] + interval: '10s' + timeout: '30s' + retries: 5 + start_period: '20s' + ports: + - 5432:5432 diff --git a/internal/integration/config/postgres.yaml b/internal/integration/config/postgres.yaml new file mode 100644 index 0000000000..0ef4739e25 --- /dev/null +++ b/internal/integration/config/postgres.yaml @@ -0,0 +1,15 @@ +Database: + postgres: + Host: localhost + Port: 5432 + Database: zitadel + MaxOpenConns: 20 + MaxIdleConns: 10 + User: + Username: zitadel + SSL: + Mode: disable + Admin: + Username: zitadel + SSL: + Mode: disable diff --git a/internal/integration/config/zitadel.yaml b/internal/integration/config/zitadel.yaml new file mode 100644 index 0000000000..60389d6de0 --- /dev/null +++ b/internal/integration/config/zitadel.yaml @@ -0,0 +1,37 @@ +Log: + Level: debug + +TLS: + Enabled: false + +FirstInstance: + Org: + Human: + PasswordChangeRequired: false + +LogStore: + Access: + Database: + Enabled: true + Debounce: + MinFrequency: 0s + MaxBulkSize: 0 + Execution: + Database: + Enabled: true + Stdout: + Enabled: true + +Quotas: + Access: + ExhaustedCookieKey: "zitadel.quota.limiting" + ExhaustedCookieMaxAge: "60s" + +Projections: + Customizations: + NotificationsQuotas: + RequeueEvery: 1s + +DefaultInstance: + LoginPolicy: + MfaInitSkipLifetime: "0" diff --git a/internal/integration/integration.go b/internal/integration/integration.go new file mode 100644 index 0000000000..67ef9d60b2 --- /dev/null +++ b/internal/integration/integration.go @@ -0,0 +1,250 @@ +// Package integration provides helpers for integration testing. +package integration + +import ( + "bytes" + "context" + "database/sql" + _ "embed" + "errors" + "fmt" + "os" + "strings" + "sync" + "time" + + "github.com/spf13/viper" + "github.com/zitadel/logging" + "github.com/zitadel/oidc/v2/pkg/oidc" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" + + "github.com/zitadel/zitadel/cmd" + "github.com/zitadel/zitadel/cmd/start" + "github.com/zitadel/zitadel/internal/api/authz" + z_oidc "github.com/zitadel/zitadel/internal/api/oidc" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" + caos_errs "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/pkg/grpc/admin" +) + +var ( + //go:embed config/zitadel.yaml + zitadelYAML []byte + //go:embed config/cockroach.yaml + cockroachYAML []byte + //go:embed config/postgres.yaml + postgresYAML []byte +) + +// UserType provides constants that give +// a short explinanation with the purpose +// a serverice user. +// This allows to pre-create users with +// different permissions and reuse them. +type UserType int + +//go:generate stringer -type=UserType +const ( + Unspecified UserType = iota + OrgOwner +) + +// User information with a Personal Access Token. +type User struct { + *query.User + Token string +} + +// Tester is a Zitadel server and client with all resources available for testing. +type Tester struct { + *start.Server + + Instance authz.Instance + Organisation *query.Org + Users map[UserType]User + + GRPCClientConn *grpc.ClientConn + wg sync.WaitGroup // used for shutdown +} + +const commandLine = `start --masterkeyFromEnv` + +func (s *Tester) Host() string { + return fmt.Sprintf("%s:%d", s.Config.ExternalDomain, s.Config.Port) +} + +func (s *Tester) createClientConn(ctx context.Context) { + target := s.Host() + cc, err := grpc.DialContext(ctx, target, + grpc.WithBlock(), grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + s.Shutdown <- os.Interrupt + s.wg.Wait() + } + logging.OnError(err).Fatal("integration tester client dial") + logging.New().WithField("target", target).Info("finished dialing grpc client conn") + + s.GRPCClientConn = cc + err = s.pollHealth(ctx) + logging.OnError(err).Fatal("integration tester health") +} + +// pollHealth waits until a healthy status is reported. +// TODO: remove when we make the setup blocking on all +// projections completed. +func (s *Tester) pollHealth(ctx context.Context) (err error) { + client := admin.NewAdminServiceClient(s.GRPCClientConn) + + for { + err = func(ctx context.Context) error { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + _, err := client.Healthz(ctx, &admin.HealthzRequest{}) + return err + }(ctx) + if err == nil { + return nil + } + logging.WithError(err).Info("poll healthz") + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Second): + continue + } + } +} + +const ( + SystemUser = "integration" +) + +func (s *Tester) createSystemUser(ctx context.Context) { + var err error + + s.Instance, err = s.Queries.InstanceByHost(ctx, s.Host()) + logging.OnError(err).Fatal("query instance") + ctx = authz.WithInstance(ctx, s.Instance) + + s.Organisation, err = s.Queries.OrgByID(ctx, true, s.Instance.DefaultOrganisationID()) + logging.OnError(err).Fatal("query organisation") + + query, err := query.NewUserUsernameSearchQuery(SystemUser, query.TextEquals) + logging.OnError(err).Fatal("user query") + user, err := s.Queries.GetUser(ctx, true, true, query) + + if errors.Is(err, sql.ErrNoRows) { + _, err = s.Commands.AddMachine(ctx, &command.Machine{ + ObjectRoot: models.ObjectRoot{ + ResourceOwner: s.Organisation.ID, + }, + Username: SystemUser, + Name: SystemUser, + Description: "who cares?", + AccessTokenType: domain.OIDCTokenTypeJWT, + }) + logging.OnError(err).Fatal("add machine user") + user, err = s.Queries.GetUser(ctx, true, true, query) + + } + logging.OnError(err).Fatal("get user") + + _, err = s.Commands.AddOrgMember(ctx, s.Organisation.ID, user.ID, "ORG_OWNER") + target := new(caos_errs.AlreadyExistsError) + if !errors.As(err, &target) { + logging.OnError(err).Fatal("add org member") + } + + scopes := []string{oidc.ScopeOpenID, z_oidc.ScopeUserMetaData, z_oidc.ScopeResourceOwner} + pat := command.NewPersonalAccessToken(user.ResourceOwner, user.ID, time.Now().Add(time.Hour), scopes, domain.UserTypeMachine) + _, err = s.Commands.AddPersonalAccessToken(ctx, pat) + logging.OnError(err).Fatal("add pat") + + s.Users = map[UserType]User{ + OrgOwner: { + User: user, + Token: pat.Token, + }, + } +} + +func (s *Tester) WithSystemAuthorization(ctx context.Context, u UserType) context.Context { + return metadata.AppendToOutgoingContext(ctx, "Authorization", fmt.Sprintf("Bearer %s", s.Users[u].Token)) +} + +// Done send an interrupt signal to cleanly shutdown the server. +func (s *Tester) Done() { + err := s.GRPCClientConn.Close() + logging.OnError(err).Error("integration tester client close") + + s.Shutdown <- os.Interrupt + s.wg.Wait() +} + +// NewTester start a new Zitadel server by passing the default commandline. +// The server will listen on the configured port. +// The database configuration that will be used can be set by the +// INTEGRATION_DB_FLAVOR environment variable and can have the values "cockroach" +// or "postgres". Defaults to "cockroach". +// +// The deault Instance and Organisation are read from the DB and system +// users are created as needed. +// +// After the server is started, a [grpc.ClientConn] will be created and +// the server is polled for it's health status. +// +// Note: the database must already be setup and intialized before +// using NewTester. See the CONTRIBUTING.md document for details. +func NewTester(ctx context.Context) *Tester { + args := strings.Split(commandLine, " ") + + sc := make(chan *start.Server) + //nolint:contextcheck + cmd := cmd.New(os.Stdout, os.Stdin, args, sc) + cmd.SetArgs(args) + err := viper.MergeConfig(bytes.NewBuffer(zitadelYAML)) + logging.OnError(err).Fatal() + + flavor := os.Getenv("INTEGRATION_DB_FLAVOR") + switch flavor { + case "cockroach", "": + err = viper.MergeConfig(bytes.NewBuffer(cockroachYAML)) + case "postgres": + err = viper.MergeConfig(bytes.NewBuffer(postgresYAML)) + default: + logging.New().WithField("flavor", flavor).Fatal("unknown db flavor set in INTEGRATION_DB_FLAVOR") + } + logging.OnError(err).Fatal() + + tester := new(Tester) + tester.wg.Add(1) + go func(wg *sync.WaitGroup) { + logging.OnError(cmd.Execute()).Fatal() + wg.Done() + }(&tester.wg) + + select { + case tester.Server = <-sc: + case <-ctx.Done(): + logging.OnError(ctx.Err()).Fatal("waiting for integration tester server") + } + tester.createClientConn(ctx) + tester.createSystemUser(ctx) + + return tester +} + +func Contexts(timeout time.Duration) (ctx, errCtx context.Context, cancel context.CancelFunc) { + errCtx, cancel = context.WithCancel(context.Background()) + cancel() + ctx, cancel = context.WithTimeout(context.Background(), timeout) + return ctx, errCtx, cancel +} diff --git a/internal/integration/integration_test.go b/internal/integration/integration_test.go new file mode 100644 index 0000000000..416602ea25 --- /dev/null +++ b/internal/integration/integration_test.go @@ -0,0 +1,16 @@ +//go:build integration + +package integration + +import ( + "testing" + "time" +) + +func TestNewTester(t *testing.T) { + ctx, _, cancel := Contexts(time.Hour) + defer cancel() + + s := NewTester(ctx) + defer s.Done() +} diff --git a/internal/integration/usertype_string.go b/internal/integration/usertype_string.go new file mode 100644 index 0000000000..3f5db98d72 --- /dev/null +++ b/internal/integration/usertype_string.go @@ -0,0 +1,24 @@ +// Code generated by "stringer -type=UserType"; DO NOT EDIT. + +package integration + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[Unspecified-0] + _ = x[OrgOwner-1] +} + +const _UserType_name = "UnspecifiedOrgOwner" + +var _UserType_index = [...]uint8{0, 11, 19} + +func (i UserType) String() string { + if i < 0 || i >= UserType(len(_UserType_index)-1) { + return "UserType(" + strconv.FormatInt(int64(i), 10) + ")" + } + return _UserType_name[_UserType_index[i]:_UserType_index[i+1]] +} diff --git a/internal/protoc/protoc-gen-zitadel/main.go b/internal/protoc/protoc-gen-zitadel/main.go new file mode 100644 index 0000000000..bad5276362 --- /dev/null +++ b/internal/protoc/protoc-gen-zitadel/main.go @@ -0,0 +1,147 @@ +package main + +import ( + "bytes" + _ "embed" + "fmt" + "io" + "os" + "text/template" + + "google.golang.org/protobuf/compiler/protogen" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/descriptorpb" + "google.golang.org/protobuf/types/pluginpb" + + protoc_gen_zitadel "github.com/zitadel/zitadel/pkg/grpc/protoc/v2" +) + +var ( + //go:embed zitadel.pb.go.tmpl + zitadelTemplate []byte +) + +type authMethods struct { + GoPackageName string + ProtoPackageName string + ServiceName string + AuthOptions []authOption + AuthContext []authContext + CustomHTTPResponses []httpResponse +} + +type authOption struct { + Name string + Permission string + CheckFieldName string +} + +type authContext struct { + Name string + OrgMethod string +} + +type httpResponse struct { + Name string + Code int32 +} + +func main() { + input, _ := io.ReadAll(os.Stdin) + var req pluginpb.CodeGeneratorRequest + err := proto.Unmarshal(input, &req) + if err != nil { + panic(err) + } + + opts := protogen.Options{} + plugin, err := opts.New(&req) + if err != nil { + panic(err) + } + plugin.SupportedFeatures = uint64(pluginpb.CodeGeneratorResponse_FEATURE_PROTO3_OPTIONAL) + + tmpl := loadTemplate(zitadelTemplate) + + for _, file := range plugin.Files { + methods := new(authMethods) + for _, service := range file.Services { + methods.ServiceName = service.GoName + methods.GoPackageName = string(file.GoPackageName) + methods.ProtoPackageName = *file.Proto.Package + for _, method := range service.Methods { + options := method.Desc.Options().(*descriptorpb.MethodOptions) + if options == nil { + continue + } + ext := proto.GetExtension(options, protoc_gen_zitadel.E_Options).(*protoc_gen_zitadel.Options) + if ext == nil { + continue + } + if ext.AuthOption != nil { + generateAuthOption(methods, ext.AuthOption, method) + } + if ext.HttpResponse != nil { + methods.CustomHTTPResponses = append(methods.CustomHTTPResponses, httpResponse{Name: string(method.Output.Desc.Name()), Code: ext.HttpResponse.SuccessCode}) + } + } + } + if len(methods.AuthOptions) > 0 { + generateFile(tmpl, methods, file, plugin) + } + } + + // Generate a response from our plugin and marshall as protobuf + stdout := plugin.Response() + out, err := proto.Marshal(stdout) + if err != nil { + panic(err) + } + + // Write the response to stdout, to be picked up by protoc + _, err = fmt.Fprint(os.Stdout, string(out)) + if err != nil { + panic(err) + } +} + +func generateAuthOption(methods *authMethods, protoAuthOption *protoc_gen_zitadel.AuthOption, method *protogen.Method) { + methods.AuthOptions = append(methods.AuthOptions, authOption{Name: string(method.Desc.Name()), Permission: protoAuthOption.Permission}) + if protoAuthOption.OrgField == "" { + return + } + orgMethod := buildAuthContextField(method.Input.Fields, protoAuthOption.OrgField) + if orgMethod != "" { + methods.AuthContext = append(methods.AuthContext, authContext{Name: string(method.Input.Desc.Name()), OrgMethod: orgMethod}) + } +} + +func generateFile(tmpl *template.Template, methods *authMethods, protoFile *protogen.File, plugin *protogen.Plugin) { + var buffer bytes.Buffer + err := tmpl.Execute(&buffer, &methods) + if err != nil { + panic(err) + } + + filename := protoFile.GeneratedFilenamePrefix + ".pb.zitadel.go" + file := plugin.NewGeneratedFile(filename, ".") + + _, err = file.Write(buffer.Bytes()) + if err != nil { + panic(err) + } +} + +func loadTemplate(templateData []byte) *template.Template { + return template.Must(template.New(""). + Parse(string(templateData))) +} + +func buildAuthContextField(fields []*protogen.Field, fieldName string) string { + for _, field := range fields { + if string(field.Desc.Name()) == fieldName { + return ".Get" + field.GoName + "()" + } + } + return "" +} diff --git a/internal/protoc/protoc-gen-zitadel/zitadel.pb.go.tmpl b/internal/protoc/protoc-gen-zitadel/zitadel.pb.go.tmpl new file mode 100644 index 0000000000..4b321b3b37 --- /dev/null +++ b/internal/protoc/protoc-gen-zitadel/zitadel.pb.go.tmpl @@ -0,0 +1,29 @@ +// Code generated by protoc-gen-zitadel. DO NOT EDIT. + +package {{.GoPackageName}} + +import ( + "github.com/zitadel/zitadel/internal/api/authz" + {{if .AuthContext}}"github.com/zitadel/zitadel/pkg/grpc/object/v2alpha"{{end}} +) + +var {{.ServiceName}}_AuthMethods = authz.MethodMapping { + {{ range $m := .AuthOptions}} + {{$.ServiceName}}_{{$m.Name}}_FullMethodName: authz.Option{ + Permission: "{{$m.Permission}}", + CheckParam: "{{$m.CheckFieldName}}", + }, + {{ end}} +} + +{{ range $m := .AuthContext}} +func (r *{{ $m.Name }}) OrganisationFromRequest() *object.Organisation { + return r{{$m.OrgMethod}} +} +{{ end }} + +{{ range $resp := .CustomHTTPResponses}} +func (r *{{ $resp.Name }}) CustomHTTPCode() int { + return {{$resp.Code}} +} +{{ end }} diff --git a/internal/query/org.go b/internal/query/org.go index 26bd3cd670..d5c90f08f8 100644 --- a/internal/query/org.go +++ b/internal/query/org.go @@ -180,12 +180,20 @@ func (q *Queries) IsOrgUnique(ctx context.Context, name, domain string) (isUniqu return scan(row) } -func (q *Queries) ExistsOrg(ctx context.Context, id string) (err error) { +func (q *Queries) ExistsOrg(ctx context.Context, id, domain string) (verifiedID string, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - _, err = q.OrgByID(ctx, true, id) - return err + var org *Org + if id != "" { + org, err = q.OrgByID(ctx, true, id) + } else { + org, err = q.OrgByVerifiedDomain(ctx, domain) + } + if err != nil { + return "", err + } + return org.ID, nil } func (q *Queries) SearchOrgs(ctx context.Context, queries *OrgSearchQueries) (orgs *Orgs, err error) { diff --git a/internal/query/projection/projection.go b/internal/query/projection/projection.go index fb461927c2..45670b8bac 100644 --- a/internal/query/projection/projection.go +++ b/internal/query/projection/projection.go @@ -65,6 +65,7 @@ var ( NotificationsProjection interface{} NotificationsQuotaProjection interface{} DeviceAuthProjection *deviceAuthProjection + SessionProjection *sessionProjection ) type projection interface { @@ -141,6 +142,7 @@ func Create(ctx context.Context, sqlClient *database.DB, es *eventstore.Eventsto SecurityPolicyProjection = newSecurityPolicyProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["security_policies"])) NotificationPolicyProjection = newNotificationPolicyProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["notification_policies"])) DeviceAuthProjection = newDeviceAuthProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["device_auth"])) + SessionProjection = newSessionProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["sessions"])) newProjectionsList() return nil } @@ -237,5 +239,6 @@ func newProjectionsList() { SecurityPolicyProjection, NotificationPolicyProjection, DeviceAuthProjection, + SessionProjection, } } diff --git a/internal/query/projection/session.go b/internal/query/projection/session.go new file mode 100644 index 0000000000..9858de4739 --- /dev/null +++ b/internal/query/projection/session.go @@ -0,0 +1,221 @@ +package projection + +import ( + "context" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/handler" + "github.com/zitadel/zitadel/internal/eventstore/handler/crdb" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/session" +) + +const ( + SessionsProjectionTable = "projections.sessions" + + SessionColumnID = "id" + SessionColumnCreationDate = "creation_date" + SessionColumnChangeDate = "change_date" + SessionColumnSequence = "sequence" + SessionColumnState = "state" + SessionColumnResourceOwner = "resource_owner" + SessionColumnInstanceID = "instance_id" + SessionColumnCreator = "creator" + SessionColumnUserID = "user_id" + SessionColumnUserCheckedAt = "user_checked_at" + SessionColumnPasswordCheckedAt = "password_checked_at" + SessionColumnMetadata = "metadata" + SessionColumnTokenID = "token_id" +) + +type sessionProjection struct { + crdb.StatementHandler +} + +func newSessionProjection(ctx context.Context, config crdb.StatementHandlerConfig) *sessionProjection { + p := new(sessionProjection) + config.ProjectionName = SessionsProjectionTable + config.Reducers = p.reducers() + config.InitCheck = crdb.NewMultiTableCheck( + crdb.NewTable([]*crdb.Column{ + crdb.NewColumn(SessionColumnID, crdb.ColumnTypeText), + crdb.NewColumn(SessionColumnCreationDate, crdb.ColumnTypeTimestamp), + crdb.NewColumn(SessionColumnChangeDate, crdb.ColumnTypeTimestamp), + crdb.NewColumn(SessionColumnSequence, crdb.ColumnTypeInt64), + crdb.NewColumn(SessionColumnState, crdb.ColumnTypeEnum), + crdb.NewColumn(SessionColumnResourceOwner, crdb.ColumnTypeText), + crdb.NewColumn(SessionColumnInstanceID, crdb.ColumnTypeText), + crdb.NewColumn(SessionColumnCreator, crdb.ColumnTypeText), + crdb.NewColumn(SessionColumnUserID, crdb.ColumnTypeText, crdb.Nullable()), + crdb.NewColumn(SessionColumnUserCheckedAt, crdb.ColumnTypeTimestamp, crdb.Nullable()), + crdb.NewColumn(SessionColumnPasswordCheckedAt, crdb.ColumnTypeTimestamp, crdb.Nullable()), + crdb.NewColumn(SessionColumnMetadata, crdb.ColumnTypeJSONB, crdb.Nullable()), + crdb.NewColumn(SessionColumnTokenID, crdb.ColumnTypeText, crdb.Nullable()), + }, + crdb.NewPrimaryKey(SessionColumnInstanceID, SessionColumnID), + ), + ) + p.StatementHandler = crdb.NewStatementHandler(ctx, config) + return p +} + +func (p *sessionProjection) reducers() []handler.AggregateReducer { + return []handler.AggregateReducer{ + { + Aggregate: session.AggregateType, + EventRedusers: []handler.EventReducer{ + { + Event: session.AddedType, + Reduce: p.reduceSessionAdded, + }, + { + Event: session.UserCheckedType, + Reduce: p.reduceUserChecked, + }, + { + Event: session.PasswordCheckedType, + Reduce: p.reducePasswordChecked, + }, + { + Event: session.TokenSetType, + Reduce: p.reduceTokenSet, + }, + { + Event: session.MetadataSetType, + Reduce: p.reduceMetadataSet, + }, + { + Event: session.TerminateType, + Reduce: p.reduceSessionTerminated, + }, + }, + }, + { + Aggregate: instance.AggregateType, + EventRedusers: []handler.EventReducer{ + { + Event: instance.InstanceRemovedEventType, + Reduce: reduceInstanceRemovedHelper(SMSColumnInstanceID), + }, + }, + }, + } +} + +func (p *sessionProjection) reduceSessionAdded(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*session.AddedEvent) + if !ok { + return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-Sfrgf", "reduce.wrong.event.type %s", session.AddedType) + } + + return crdb.NewCreateStatement( + e, + []handler.Column{ + handler.NewCol(SessionColumnID, e.Aggregate().ID), + handler.NewCol(SessionColumnInstanceID, e.Aggregate().InstanceID), + handler.NewCol(SessionColumnCreationDate, e.CreationDate()), + handler.NewCol(SessionColumnChangeDate, e.CreationDate()), + handler.NewCol(SessionColumnResourceOwner, e.Aggregate().ResourceOwner), + handler.NewCol(SessionColumnState, domain.SessionStateActive), + handler.NewCol(SessionColumnSequence, e.Sequence()), + handler.NewCol(SessionColumnCreator, e.User), + }, + ), nil +} + +func (p *sessionProjection) reduceUserChecked(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*session.UserCheckedEvent) + if !ok { + return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-saDg5", "reduce.wrong.event.type %s", session.UserCheckedType) + } + return crdb.NewUpdateStatement( + e, + []handler.Column{ + handler.NewCol(SessionColumnChangeDate, e.CreationDate()), + handler.NewCol(SessionColumnSequence, e.Sequence()), + handler.NewCol(SessionColumnUserID, e.UserID), + handler.NewCol(SessionColumnUserCheckedAt, e.CheckedAt), + }, + []handler.Condition{ + handler.NewCond(SessionColumnID, e.Aggregate().ID), + handler.NewCond(SessionColumnInstanceID, e.Aggregate().InstanceID), + }, + ), nil +} + +func (p *sessionProjection) reducePasswordChecked(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*session.PasswordCheckedEvent) + if !ok { + return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-SDgrb", "reduce.wrong.event.type %s", session.PasswordCheckedType) + } + + return crdb.NewUpdateStatement( + e, + []handler.Column{ + handler.NewCol(SessionColumnChangeDate, e.CreationDate()), + handler.NewCol(SessionColumnSequence, e.Sequence()), + handler.NewCol(SessionColumnPasswordCheckedAt, e.CheckedAt), + }, + []handler.Condition{ + handler.NewCond(SessionColumnID, e.Aggregate().ID), + handler.NewCond(SessionColumnInstanceID, e.Aggregate().InstanceID), + }, + ), nil +} + +func (p *sessionProjection) reduceTokenSet(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*session.TokenSetEvent) + if !ok { + return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-SAfd3", "reduce.wrong.event.type %s", session.TokenSetType) + } + + return crdb.NewUpdateStatement( + e, + []handler.Column{ + handler.NewCol(SessionColumnChangeDate, e.CreationDate()), + handler.NewCol(SessionColumnSequence, e.Sequence()), + handler.NewCol(SessionColumnTokenID, e.TokenID), + }, + []handler.Condition{ + handler.NewCond(SessionColumnID, e.Aggregate().ID), + handler.NewCond(SessionColumnInstanceID, e.Aggregate().InstanceID), + }, + ), nil +} + +func (p *sessionProjection) reduceMetadataSet(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*session.MetadataSetEvent) + if !ok { + return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-SAfd3", "reduce.wrong.event.type %s", session.MetadataSetType) + } + + return crdb.NewUpdateStatement( + e, + []handler.Column{ + handler.NewCol(SessionColumnChangeDate, e.CreationDate()), + handler.NewCol(SessionColumnSequence, e.Sequence()), + handler.NewCol(SessionColumnMetadata, e.Metadata), + }, + []handler.Condition{ + handler.NewCond(SessionColumnID, e.Aggregate().ID), + handler.NewCond(SessionColumnInstanceID, e.Aggregate().InstanceID), + }, + ), nil +} + +func (p *sessionProjection) reduceSessionTerminated(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*session.TerminateEvent) + if !ok { + return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-SAftn", "reduce.wrong.event.type %s", session.TerminateType) + } + + return crdb.NewDeleteStatement( + e, + []handler.Condition{ + handler.NewCond(SessionColumnID, e.Aggregate().ID), + handler.NewCond(SessionColumnInstanceID, e.Aggregate().InstanceID), + }, + ), nil +} diff --git a/internal/query/projection/session_test.go b/internal/query/projection/session_test.go new file mode 100644 index 0000000000..6cd3ae2e59 --- /dev/null +++ b/internal/query/projection/session_test.go @@ -0,0 +1,260 @@ +package projection + +import ( + "testing" + "time" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/handler" + "github.com/zitadel/zitadel/internal/eventstore/repository" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/session" +) + +func TestSessionProjection_reduces(t *testing.T) { + type args struct { + event func(t *testing.T) eventstore.Event + } + tests := []struct { + name string + args args + reduce func(event eventstore.Event) (*handler.Statement, error) + want wantReduce + }{ + { + name: "instance reduceSessionAdded", + args: args{ + event: getEvent(testEvent( + session.AddedType, + session.AggregateType, + []byte(`{}`), + ), session.AddedEventMapper), + }, + reduce: (&sessionProjection{}).reduceSessionAdded, + want: wantReduce{ + aggregateType: eventstore.AggregateType("session"), + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "INSERT INTO projections.sessions (id, instance_id, creation_date, change_date, resource_owner, state, sequence, creator) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + expectedArgs: []interface{}{ + "agg-id", + "instance-id", + anyArg{}, + anyArg{}, + "ro-id", + domain.SessionStateActive, + uint64(15), + "editor-user", + }, + }, + }, + }, + }, + }, + { + name: "instance reduceUserChecked", + args: args{ + event: getEvent(testEvent( + session.AddedType, + session.AggregateType, + []byte(`{ + "userId": "user-id", + "checkedAt": "2023-05-04T00:00:00Z" + }`), + ), session.UserCheckedEventMapper), + }, + reduce: (&sessionProjection{}).reduceUserChecked, + want: wantReduce{ + aggregateType: eventstore.AggregateType("session"), + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE projections.sessions SET (change_date, sequence, user_id, user_checked_at) = ($1, $2, $3, $4) WHERE (id = $5) AND (instance_id = $6)", + expectedArgs: []interface{}{ + anyArg{}, + anyArg{}, + "user-id", + time.Date(2023, time.May, 4, 0, 0, 0, 0, time.UTC), + "agg-id", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "instance reducePasswordChecked", + args: args{ + event: getEvent(testEvent( + session.AddedType, + session.AggregateType, + []byte(`{ + "checkedAt": "2023-05-04T00:00:00Z" + }`), + ), session.PasswordCheckedEventMapper), + }, + reduce: (&sessionProjection{}).reducePasswordChecked, + want: wantReduce{ + aggregateType: eventstore.AggregateType("session"), + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE projections.sessions SET (change_date, sequence, password_checked_at) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", + expectedArgs: []interface{}{ + anyArg{}, + anyArg{}, + time.Date(2023, time.May, 4, 0, 0, 0, 0, time.UTC), + "agg-id", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "instance reduceTokenSet", + args: args{ + event: getEvent(testEvent( + session.TokenSetType, + session.AggregateType, + []byte(`{ + "tokenID": "tokenID" + }`), + ), session.TokenSetEventMapper), + }, + reduce: (&sessionProjection{}).reduceTokenSet, + want: wantReduce{ + aggregateType: eventstore.AggregateType("session"), + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE projections.sessions SET (change_date, sequence, token_id) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", + expectedArgs: []interface{}{ + anyArg{}, + anyArg{}, + "tokenID", + "agg-id", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "instance reduceMetadataSet", + args: args{ + event: getEvent(testEvent( + session.MetadataSetType, + session.AggregateType, + []byte(`{ + "metadata": { + "key": "dmFsdWU=" + } + }`), + ), session.MetadataSetEventMapper), + }, + reduce: (&sessionProjection{}).reduceMetadataSet, + want: wantReduce{ + aggregateType: eventstore.AggregateType("session"), + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE projections.sessions SET (change_date, sequence, metadata) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", + expectedArgs: []interface{}{ + anyArg{}, + anyArg{}, + map[string][]byte{ + "key": []byte("value"), + }, + "agg-id", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "instance reduceSessionTerminated", + args: args{ + event: getEvent(testEvent( + session.TerminateType, + session.AggregateType, + []byte(`{}`), + ), session.TerminateEventMapper), + }, + reduce: (&sessionProjection{}).reduceSessionTerminated, + want: wantReduce{ + aggregateType: eventstore.AggregateType("session"), + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.sessions WHERE (id = $1) AND (instance_id = $2)", + expectedArgs: []interface{}{ + "agg-id", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "instance reduceInstanceRemoved", + args: args{ + event: getEvent(testEvent( + repository.EventType(instance.InstanceRemovedEventType), + instance.AggregateType, + nil, + ), instance.InstanceRemovedEventMapper), + }, + reduce: reduceInstanceRemovedHelper(SessionColumnInstanceID), + want: wantReduce{ + aggregateType: eventstore.AggregateType("instance"), + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.sessions WHERE (instance_id = $1)", + expectedArgs: []interface{}{ + "agg-id", + }, + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event := baseEvent(t) + got, err := tt.reduce(event) + if !errors.IsErrorInvalidArgument(err) { + t.Errorf("no wrong event mapping: %v, got: %v", err, got) + } + + event = tt.args.event(t) + got, err = tt.reduce(event) + assertReduce(t, got, err, SessionsProjectionTable, tt.want) + }) + } +} diff --git a/internal/query/query.go b/internal/query/query.go index 0fc324f843..7e3f10dcdc 100644 --- a/internal/query/query.go +++ b/internal/query/query.go @@ -22,6 +22,7 @@ import ( "github.com/zitadel/zitadel/internal/repository/keypair" "github.com/zitadel/zitadel/internal/repository/org" "github.com/zitadel/zitadel/internal/repository/project" + "github.com/zitadel/zitadel/internal/repository/session" usr_repo "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/repository/usergrant" ) @@ -30,7 +31,8 @@ type Queries struct { eventstore *eventstore.Eventstore client *database.DB - idpConfigEncryption crypto.EncryptionAlgorithm + idpConfigEncryption crypto.EncryptionAlgorithm + sessionTokenVerifier func(ctx context.Context, sessionToken string, sessionID string, tokenID string) (err error) DefaultLanguage language.Tag LoginDir http.FileSystem @@ -43,7 +45,16 @@ type Queries struct { multifactors domain.MultifactorConfigs } -func StartQueries(ctx context.Context, es *eventstore.Eventstore, sqlClient *database.DB, projections projection.Config, defaults sd.SystemDefaults, idpConfigEncryption, otpEncryption, keyEncryptionAlgorithm crypto.EncryptionAlgorithm, certEncryptionAlgorithm crypto.EncryptionAlgorithm, zitadelRoles []authz.RoleMapping) (repo *Queries, err error) { +func StartQueries( + ctx context.Context, + es *eventstore.Eventstore, + sqlClient *database.DB, + projections projection.Config, + defaults sd.SystemDefaults, + idpConfigEncryption, otpEncryption, keyEncryptionAlgorithm, certEncryptionAlgorithm crypto.EncryptionAlgorithm, + zitadelRoles []authz.RoleMapping, + sessionTokenVerifier func(ctx context.Context, sessionToken string, sessionID string, tokenID string) (err error), +) (repo *Queries, err error) { statikLoginFS, err := fs.NewWithNamespace("login") if err != nil { return nil, fmt.Errorf("unable to start login statik dir") @@ -63,6 +74,7 @@ func StartQueries(ctx context.Context, es *eventstore.Eventstore, sqlClient *dat LoginTranslationFileContents: make(map[string][]byte), NotificationTranslationFileContents: make(map[string][]byte), zitadelRoles: zitadelRoles, + sessionTokenVerifier: sessionTokenVerifier, } iam_repo.RegisterEventMappers(repo.eventstore) usr_repo.RegisterEventMappers(repo.eventstore) @@ -71,6 +83,7 @@ func StartQueries(ctx context.Context, es *eventstore.Eventstore, sqlClient *dat action.RegisterEventMappers(repo.eventstore) keypair.RegisterEventMappers(repo.eventstore) usergrant.RegisterEventMappers(repo.eventstore) + session.RegisterEventMappers(repo.eventstore) repo.idpConfigEncryption = idpConfigEncryption repo.multifactors = domain.MultifactorConfigs{ diff --git a/internal/query/session.go b/internal/query/session.go new file mode 100644 index 0000000000..ba30c64da6 --- /dev/null +++ b/internal/query/session.go @@ -0,0 +1,320 @@ +package query + +import ( + "context" + "database/sql" + errs "errors" + "time" + + sq "github.com/Masterminds/squirrel" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/call" + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/query/projection" + "github.com/zitadel/zitadel/internal/telemetry/tracing" +) + +type Sessions struct { + SearchResponse + Sessions []*Session +} + +type Session struct { + ID string + CreationDate time.Time + ChangeDate time.Time + Sequence uint64 + State domain.SessionState + ResourceOwner string + Creator string + UserFactor SessionUserFactor + PasswordFactor SessionPasswordFactor + Metadata map[string][]byte +} + +type SessionUserFactor struct { + UserID string + UserCheckedAt time.Time + LoginName string + DisplayName string +} + +type SessionPasswordFactor struct { + PasswordCheckedAt time.Time +} + +type SessionsSearchQueries struct { + SearchRequest + Queries []SearchQuery +} + +func (q *SessionsSearchQueries) toQuery(query sq.SelectBuilder) sq.SelectBuilder { + query = q.SearchRequest.toQuery(query) + for _, q := range q.Queries { + query = q.toQuery(query) + } + return query +} + +var ( + sessionsTable = table{ + name: projection.SessionsProjectionTable, + instanceIDCol: projection.SessionColumnInstanceID, + } + SessionColumnID = Column{ + name: projection.SessionColumnID, + table: sessionsTable, + } + SessionColumnCreationDate = Column{ + name: projection.SessionColumnCreationDate, + table: sessionsTable, + } + SessionColumnChangeDate = Column{ + name: projection.SessionColumnChangeDate, + table: sessionsTable, + } + SessionColumnSequence = Column{ + name: projection.SessionColumnSequence, + table: sessionsTable, + } + SessionColumnState = Column{ + name: projection.SessionColumnState, + table: sessionsTable, + } + SessionColumnResourceOwner = Column{ + name: projection.SessionColumnResourceOwner, + table: sessionsTable, + } + SessionColumnInstanceID = Column{ + name: projection.SessionColumnInstanceID, + table: sessionsTable, + } + SessionColumnCreator = Column{ + name: projection.SessionColumnCreator, + table: sessionsTable, + } + SessionColumnUserID = Column{ + name: projection.SessionColumnUserID, + table: sessionsTable, + } + SessionColumnUserCheckedAt = Column{ + name: projection.SessionColumnUserCheckedAt, + table: sessionsTable, + } + SessionColumnPasswordCheckedAt = Column{ + name: projection.SessionColumnPasswordCheckedAt, + table: sessionsTable, + } + SessionColumnMetadata = Column{ + name: projection.SessionColumnMetadata, + table: sessionsTable, + } + SessionColumnToken = Column{ + name: projection.SessionColumnTokenID, + table: sessionsTable, + } +) + +func (q *Queries) SessionByID(ctx context.Context, id, sessionToken string) (_ *Session, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + query, scan := prepareSessionQuery(ctx, q.client) + stmt, args, err := query.Where( + sq.Eq{ + SessionColumnID.identifier(): id, + SessionColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), + }, + ).ToSql() + if err != nil { + return nil, errors.ThrowInternal(err, "QUERY-dn9JW", "Errors.Query.SQLStatement") + } + + row := q.client.QueryRowContext(ctx, stmt, args...) + session, tokenID, err := scan(row) + if err != nil { + return nil, err + } + if sessionToken == "" { + return session, nil + } + if err := q.sessionTokenVerifier(ctx, sessionToken, session.ID, tokenID); err != nil { + return nil, errors.ThrowPermissionDenied(nil, "QUERY-dsfr3", "Errors.PermissionDenied") + } + return session, nil +} + +func (q *Queries) SearchSessions(ctx context.Context, queries *SessionsSearchQueries) (_ *Sessions, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + query, scan := prepareSessionsQuery(ctx, q.client) + stmt, args, err := queries.toQuery(query). + Where(sq.Eq{ + SessionColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), + }).ToSql() + if err != nil { + return nil, errors.ThrowInvalidArgument(err, "QUERY-sn9Jf", "Errors.Query.InvalidRequest") + } + + rows, err := q.client.QueryContext(ctx, stmt, args...) + if err != nil || rows.Err() != nil { + return nil, errors.ThrowInternal(err, "QUERY-Sfg42", "Errors.Internal") + } + sessions, err := scan(rows) + if err != nil { + return nil, err + } + sessions.LatestSequence, err = q.latestSequence(ctx, sessionsTable) + return sessions, err +} + +func NewSessionIDsSearchQuery(ids []string) (SearchQuery, error) { + list := make([]interface{}, len(ids)) + for i, value := range ids { + list[i] = value + } + return NewListQuery(SessionColumnID, list, ListIn) +} + +func NewSessionCreatorSearchQuery(creator string) (SearchQuery, error) { + return NewTextQuery(SessionColumnCreator, creator, TextEquals) +} + +func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (*Session, string, error)) { + return sq.Select( + SessionColumnID.identifier(), + SessionColumnCreationDate.identifier(), + SessionColumnChangeDate.identifier(), + SessionColumnSequence.identifier(), + SessionColumnState.identifier(), + SessionColumnResourceOwner.identifier(), + SessionColumnCreator.identifier(), + SessionColumnUserID.identifier(), + SessionColumnUserCheckedAt.identifier(), + LoginNameNameCol.identifier(), + HumanDisplayNameCol.identifier(), + SessionColumnPasswordCheckedAt.identifier(), + SessionColumnMetadata.identifier(), + SessionColumnToken.identifier(), + ).From(sessionsTable.identifier()). + LeftJoin(join(LoginNameUserIDCol, SessionColumnUserID)). + LeftJoin(join(HumanUserIDCol, SessionColumnUserID) + db.Timetravel(call.Took(ctx))). + PlaceholderFormat(sq.Dollar), func(row *sql.Row) (*Session, string, error) { + session := new(Session) + + var ( + userID sql.NullString + userCheckedAt sql.NullTime + loginName sql.NullString + displayName sql.NullString + passwordCheckedAt sql.NullTime + metadata database.Map[[]byte] + token sql.NullString + ) + + err := row.Scan( + &session.ID, + &session.CreationDate, + &session.ChangeDate, + &session.Sequence, + &session.State, + &session.ResourceOwner, + &session.Creator, + &userID, + &userCheckedAt, + &loginName, + &displayName, + &passwordCheckedAt, + &metadata, + &token, + ) + + if err != nil { + if errs.Is(err, sql.ErrNoRows) { + return nil, "", errors.ThrowNotFound(err, "QUERY-SFeaa", "Errors.Session.NotExisting") + } + return nil, "", errors.ThrowInternal(err, "QUERY-SAder", "Errors.Internal") + } + + session.UserFactor.UserID = userID.String + session.UserFactor.UserCheckedAt = userCheckedAt.Time + session.UserFactor.LoginName = loginName.String + session.UserFactor.DisplayName = displayName.String + session.PasswordFactor.PasswordCheckedAt = passwordCheckedAt.Time + session.Metadata = metadata + + return session, token.String, nil + } +} + +func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Rows) (*Sessions, error)) { + return sq.Select( + SessionColumnID.identifier(), + SessionColumnCreationDate.identifier(), + SessionColumnChangeDate.identifier(), + SessionColumnSequence.identifier(), + SessionColumnState.identifier(), + SessionColumnResourceOwner.identifier(), + SessionColumnCreator.identifier(), + SessionColumnUserID.identifier(), + SessionColumnUserCheckedAt.identifier(), + LoginNameNameCol.identifier(), + HumanDisplayNameCol.identifier(), + SessionColumnPasswordCheckedAt.identifier(), + SessionColumnMetadata.identifier(), + countColumn.identifier(), + ).From(sessionsTable.identifier()). + LeftJoin(join(LoginNameUserIDCol, SessionColumnUserID)). + LeftJoin(join(HumanUserIDCol, SessionColumnUserID) + db.Timetravel(call.Took(ctx))). + PlaceholderFormat(sq.Dollar), func(rows *sql.Rows) (*Sessions, error) { + sessions := &Sessions{Sessions: []*Session{}} + + for rows.Next() { + session := new(Session) + + var ( + userID sql.NullString + userCheckedAt sql.NullTime + loginName sql.NullString + displayName sql.NullString + passwordCheckedAt sql.NullTime + metadata database.Map[[]byte] + ) + + err := rows.Scan( + &session.ID, + &session.CreationDate, + &session.ChangeDate, + &session.Sequence, + &session.State, + &session.ResourceOwner, + &session.Creator, + &userID, + &userCheckedAt, + &loginName, + &displayName, + &passwordCheckedAt, + &metadata, + &sessions.Count, + ) + + if err != nil { + return nil, errors.ThrowInternal(err, "QUERY-SAfeg", "Errors.Internal") + } + session.UserFactor.UserID = userID.String + session.UserFactor.UserCheckedAt = userCheckedAt.Time + session.UserFactor.LoginName = loginName.String + session.UserFactor.DisplayName = displayName.String + session.PasswordFactor.PasswordCheckedAt = passwordCheckedAt.Time + session.Metadata = metadata + + sessions.Sessions = append(sessions.Sessions, session) + } + + return sessions, nil + } +} diff --git a/internal/query/sessions_test.go b/internal/query/sessions_test.go new file mode 100644 index 0000000000..eeff881d80 --- /dev/null +++ b/internal/query/sessions_test.go @@ -0,0 +1,396 @@ +package query + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "fmt" + "regexp" + "testing" + + sq "github.com/Masterminds/squirrel" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/domain" + errs "github.com/zitadel/zitadel/internal/errors" +) + +var ( + expectedSessionQuery = regexp.QuoteMeta(`SELECT projections.sessions.id,` + + ` projections.sessions.creation_date,` + + ` projections.sessions.change_date,` + + ` projections.sessions.sequence,` + + ` projections.sessions.state,` + + ` projections.sessions.resource_owner,` + + ` projections.sessions.creator,` + + ` projections.sessions.user_id,` + + ` projections.sessions.user_checked_at,` + + ` projections.login_names2.login_name,` + + ` projections.users8_humans.display_name,` + + ` projections.sessions.password_checked_at,` + + ` projections.sessions.metadata,` + + ` projections.sessions.token_id` + + ` FROM projections.sessions` + + ` LEFT JOIN projections.login_names2 ON projections.sessions.user_id = projections.login_names2.user_id AND projections.sessions.instance_id = projections.login_names2.instance_id` + + ` LEFT JOIN projections.users8_humans ON projections.sessions.user_id = projections.users8_humans.user_id AND projections.sessions.instance_id = projections.users8_humans.instance_id` + + ` AS OF SYSTEM TIME '-1 ms'`) + expectedSessionsQuery = regexp.QuoteMeta(`SELECT projections.sessions.id,` + + ` projections.sessions.creation_date,` + + ` projections.sessions.change_date,` + + ` projections.sessions.sequence,` + + ` projections.sessions.state,` + + ` projections.sessions.resource_owner,` + + ` projections.sessions.creator,` + + ` projections.sessions.user_id,` + + ` projections.sessions.user_checked_at,` + + ` projections.login_names2.login_name,` + + ` projections.users8_humans.display_name,` + + ` projections.sessions.password_checked_at,` + + ` projections.sessions.metadata,` + + ` COUNT(*) OVER ()` + + ` FROM projections.sessions` + + ` LEFT JOIN projections.login_names2 ON projections.sessions.user_id = projections.login_names2.user_id AND projections.sessions.instance_id = projections.login_names2.instance_id` + + ` LEFT JOIN projections.users8_humans ON projections.sessions.user_id = projections.users8_humans.user_id AND projections.sessions.instance_id = projections.users8_humans.instance_id` + + ` AS OF SYSTEM TIME '-1 ms'`) + + sessionCols = []string{ + "id", + "creation_date", + "change_date", + "sequence", + "state", + "resource_owner", + "creator", + "user_id", + "user_checked_at", + "login_name", + "display_name", + "password_checked_at", + "metadata", + "token", + } + + sessionsCols = []string{ + "id", + "creation_date", + "change_date", + "sequence", + "state", + "resource_owner", + "creator", + "user_id", + "user_checked_at", + "login_name", + "display_name", + "password_checked_at", + "metadata", + "count", + } +) + +func Test_SessionsPrepare(t *testing.T) { + type want struct { + sqlExpectations sqlExpectation + err checkErr + } + tests := []struct { + name string + prepare interface{} + want want + object interface{} + }{ + { + name: "prepareSessionsQuery no result", + prepare: prepareSessionsQuery, + want: want{ + sqlExpectations: mockQueries( + expectedSessionsQuery, + nil, + nil, + ), + }, + object: &Sessions{Sessions: []*Session{}}, + }, + { + name: "prepareSessionQuery", + prepare: prepareSessionsQuery, + want: want{ + sqlExpectations: mockQueries( + expectedSessionsQuery, + sessionsCols, + [][]driver.Value{ + { + "session-id", + testNow, + testNow, + uint64(20211109), + domain.SessionStateActive, + "ro", + "creator", + "user-id", + testNow, + "login-name", + "display-name", + testNow, + []byte(`{"key": "dmFsdWU="}`), + }, + }, + ), + }, + object: &Sessions{ + SearchResponse: SearchResponse{ + Count: 1, + }, + Sessions: []*Session{ + { + ID: "session-id", + CreationDate: testNow, + ChangeDate: testNow, + Sequence: 20211109, + State: domain.SessionStateActive, + ResourceOwner: "ro", + Creator: "creator", + UserFactor: SessionUserFactor{ + UserID: "user-id", + UserCheckedAt: testNow, + LoginName: "login-name", + DisplayName: "display-name", + }, + PasswordFactor: SessionPasswordFactor{ + PasswordCheckedAt: testNow, + }, + Metadata: map[string][]byte{ + "key": []byte("value"), + }, + }, + }, + }, + }, + { + name: "prepareSessionsQuery multiple result", + prepare: prepareSessionsQuery, + want: want{ + sqlExpectations: mockQueries( + expectedSessionsQuery, + sessionsCols, + [][]driver.Value{ + { + "session-id", + testNow, + testNow, + uint64(20211109), + domain.SessionStateActive, + "ro", + "creator", + "user-id", + testNow, + "login-name", + "display-name", + testNow, + []byte(`{"key": "dmFsdWU="}`), + }, + { + "session-id2", + testNow, + testNow, + uint64(20211109), + domain.SessionStateActive, + "ro", + "creator2", + "user-id2", + testNow, + "login-name2", + "display-name2", + testNow, + []byte(`{"key": "dmFsdWU="}`), + }, + }, + ), + }, + object: &Sessions{ + SearchResponse: SearchResponse{ + Count: 2, + }, + Sessions: []*Session{ + { + ID: "session-id", + CreationDate: testNow, + ChangeDate: testNow, + Sequence: 20211109, + State: domain.SessionStateActive, + ResourceOwner: "ro", + Creator: "creator", + UserFactor: SessionUserFactor{ + UserID: "user-id", + UserCheckedAt: testNow, + LoginName: "login-name", + DisplayName: "display-name", + }, + PasswordFactor: SessionPasswordFactor{ + PasswordCheckedAt: testNow, + }, + Metadata: map[string][]byte{ + "key": []byte("value"), + }, + }, + { + ID: "session-id2", + CreationDate: testNow, + ChangeDate: testNow, + Sequence: 20211109, + State: domain.SessionStateActive, + ResourceOwner: "ro", + Creator: "creator2", + UserFactor: SessionUserFactor{ + UserID: "user-id2", + UserCheckedAt: testNow, + LoginName: "login-name2", + DisplayName: "display-name2", + }, + PasswordFactor: SessionPasswordFactor{ + PasswordCheckedAt: testNow, + }, + Metadata: map[string][]byte{ + "key": []byte("value"), + }, + }, + }, + }, + }, + { + name: "prepareSessionsQuery sql err", + prepare: prepareSessionsQuery, + want: want{ + sqlExpectations: mockQueryErr( + expectedSessionsQuery, + sql.ErrConnDone, + ), + err: func(err error) (error, bool) { + if !errors.Is(err, sql.ErrConnDone) { + return fmt.Errorf("err should be sql.ErrConnDone got: %w", err), false + } + return nil, true + }, + }, + object: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertPrepare(t, tt.prepare, tt.object, tt.want.sqlExpectations, tt.want.err, defaultPrepareArgs...) + }) + } +} + +func Test_SessionPrepare(t *testing.T) { + type want struct { + sqlExpectations sqlExpectation + err checkErr + } + tests := []struct { + name string + prepare interface{} + want want + object interface{} + }{ + { + name: "prepareSessionQuery no result", + prepare: prepareSessionQueryTesting(t, ""), + want: want{ + sqlExpectations: mockQueries( + expectedSessionQuery, + nil, + nil, + ), + err: func(err error) (error, bool) { + if !errs.IsNotFound(err) { + return fmt.Errorf("err should be zitadel.NotFoundError got: %w", err), false + } + return nil, true + }, + }, + object: (*Session)(nil), + }, + { + name: "prepareSessionQuery found", + prepare: prepareSessionQueryTesting(t, "tokenID"), + want: want{ + sqlExpectations: mockQuery( + expectedSessionQuery, + sessionCols, + []driver.Value{ + "session-id", + testNow, + testNow, + uint64(20211109), + domain.SessionStateActive, + "ro", + "creator", + "user-id", + testNow, + "login-name", + "display-name", + testNow, + []byte(`{"key": "dmFsdWU="}`), + "tokenID", + }, + ), + }, + object: &Session{ + ID: "session-id", + CreationDate: testNow, + ChangeDate: testNow, + Sequence: 20211109, + State: domain.SessionStateActive, + ResourceOwner: "ro", + Creator: "creator", + UserFactor: SessionUserFactor{ + UserID: "user-id", + UserCheckedAt: testNow, + LoginName: "login-name", + DisplayName: "display-name", + }, + PasswordFactor: SessionPasswordFactor{ + PasswordCheckedAt: testNow, + }, + Metadata: map[string][]byte{ + "key": []byte("value"), + }, + }, + }, + { + name: "prepareSessionQuery sql err", + prepare: prepareSessionQueryTesting(t, ""), + want: want{ + sqlExpectations: mockQueryErr( + expectedSessionQuery, + sql.ErrConnDone, + ), + err: func(err error) (error, bool) { + if !errors.Is(err, sql.ErrConnDone) { + return fmt.Errorf("err should be sql.ErrConnDone got: %w", err), false + } + return nil, true + }, + }, + object: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertPrepare(t, tt.prepare, tt.object, tt.want.sqlExpectations, tt.want.err, defaultPrepareArgs...) + }) + } +} + +func prepareSessionQueryTesting(t *testing.T, token string) func(context.Context, prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (*Session, error)) { + return func(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (*Session, error)) { + builder, scan := prepareSessionQuery(ctx, db) + return builder, func(row *sql.Row) (*Session, error) { + session, tokenID, err := scan(row) + require.Equal(t, tokenID, token) + return session, err + } + } +} diff --git a/internal/repository/session/aggregate.go b/internal/repository/session/aggregate.go new file mode 100644 index 0000000000..58c479398f --- /dev/null +++ b/internal/repository/session/aggregate.go @@ -0,0 +1,25 @@ +package session + +import ( + "github.com/zitadel/zitadel/internal/eventstore" +) + +const ( + AggregateType = "session" + AggregateVersion = "v1" +) + +type Aggregate struct { + eventstore.Aggregate +} + +func NewAggregate(id, resourceOwner string) *Aggregate { + return &Aggregate{ + Aggregate: eventstore.Aggregate{ + Type: AggregateType, + Version: AggregateVersion, + ID: id, + ResourceOwner: resourceOwner, + }, + } +} diff --git a/internal/repository/session/eventstore.go b/internal/repository/session/eventstore.go new file mode 100644 index 0000000000..3dd1875da7 --- /dev/null +++ b/internal/repository/session/eventstore.go @@ -0,0 +1,12 @@ +package session + +import "github.com/zitadel/zitadel/internal/eventstore" + +func RegisterEventMappers(es *eventstore.Eventstore) { + es.RegisterFilterEventMapper(AggregateType, AddedType, AddedEventMapper). + RegisterFilterEventMapper(AggregateType, UserCheckedType, UserCheckedEventMapper). + RegisterFilterEventMapper(AggregateType, PasswordCheckedType, PasswordCheckedEventMapper). + RegisterFilterEventMapper(AggregateType, TokenSetType, TokenSetEventMapper). + RegisterFilterEventMapper(AggregateType, MetadataSetType, MetadataSetEventMapper). + RegisterFilterEventMapper(AggregateType, TerminateType, TerminateEventMapper) +} diff --git a/internal/repository/session/session.go b/internal/repository/session/session.go new file mode 100644 index 0000000000..6883834426 --- /dev/null +++ b/internal/repository/session/session.go @@ -0,0 +1,255 @@ +package session + +import ( + "context" + "encoding/json" + "time" + + "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/repository" +) + +const ( + sessionEventPrefix = "session." + AddedType = sessionEventPrefix + "added" + UserCheckedType = sessionEventPrefix + "user.checked" + PasswordCheckedType = sessionEventPrefix + "password.checked" + TokenSetType = sessionEventPrefix + "token.set" + MetadataSetType = sessionEventPrefix + "metadata.set" + TerminateType = sessionEventPrefix + "terminated" +) + +type AddedEvent struct { + eventstore.BaseEvent `json:"-"` +} + +func (e *AddedEvent) Data() interface{} { + return e +} + +func (e *AddedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func NewAddedEvent(ctx context.Context, + aggregate *eventstore.Aggregate, +) *AddedEvent { + return &AddedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + AddedType, + ), + } +} + +func AddedEventMapper(event *repository.Event) (eventstore.Event, error) { + added := &AddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + } + err := json.Unmarshal(event.Data, added) + if err != nil { + return nil, errors.ThrowInternal(err, "SESSION-DG4gn", "unable to unmarshal session added") + } + + return added, nil +} + +type UserCheckedEvent struct { + eventstore.BaseEvent `json:"-"` + + UserID string `json:"userID"` + CheckedAt time.Time `json:"checkedAt"` +} + +func (e *UserCheckedEvent) Data() interface{} { + return e +} + +func (e *UserCheckedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func NewUserCheckedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + userID string, + checkedAt time.Time, +) *UserCheckedEvent { + return &UserCheckedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + UserCheckedType, + ), + UserID: userID, + CheckedAt: checkedAt, + } +} + +func UserCheckedEventMapper(event *repository.Event) (eventstore.Event, error) { + added := &UserCheckedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + } + err := json.Unmarshal(event.Data, added) + if err != nil { + return nil, errors.ThrowInternal(err, "SESSION-DSGn5", "unable to unmarshal user checked") + } + + return added, nil +} + +type PasswordCheckedEvent struct { + eventstore.BaseEvent `json:"-"` + + CheckedAt time.Time `json:"checkedAt"` +} + +func (e *PasswordCheckedEvent) Data() interface{} { + return e +} + +func (e *PasswordCheckedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func NewPasswordCheckedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + checkedAt time.Time, +) *PasswordCheckedEvent { + return &PasswordCheckedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + PasswordCheckedType, + ), + CheckedAt: checkedAt, + } +} + +func PasswordCheckedEventMapper(event *repository.Event) (eventstore.Event, error) { + added := &PasswordCheckedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + } + err := json.Unmarshal(event.Data, added) + if err != nil { + return nil, errors.ThrowInternal(err, "SESSION-DGt21", "unable to unmarshal password checked") + } + + return added, nil +} + +type TokenSetEvent struct { + eventstore.BaseEvent `json:"-"` + + TokenID string `json:"tokenID"` +} + +func (e *TokenSetEvent) Data() interface{} { + return e +} + +func (e *TokenSetEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func NewTokenSetEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + tokenID string, +) *TokenSetEvent { + return &TokenSetEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + TokenSetType, + ), + TokenID: tokenID, + } +} + +func TokenSetEventMapper(event *repository.Event) (eventstore.Event, error) { + added := &TokenSetEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + } + err := json.Unmarshal(event.Data, added) + if err != nil { + return nil, errors.ThrowInternal(err, "SESSION-Sf3va", "unable to unmarshal token set") + } + + return added, nil +} + +type MetadataSetEvent struct { + eventstore.BaseEvent `json:"-"` + + Metadata map[string][]byte `json:"metadata"` +} + +func (e *MetadataSetEvent) Data() interface{} { + return e +} + +func (e *MetadataSetEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func NewMetadataSetEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + metadata map[string][]byte, +) *MetadataSetEvent { + return &MetadataSetEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + MetadataSetType, + ), + Metadata: metadata, + } +} + +func MetadataSetEventMapper(event *repository.Event) (eventstore.Event, error) { + added := &MetadataSetEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + } + err := json.Unmarshal(event.Data, added) + if err != nil { + return nil, errors.ThrowInternal(err, "SESSION-BD21d", "unable to unmarshal metadata set") + } + + return added, nil +} + +type TerminateEvent struct { + eventstore.BaseEvent `json:"-"` +} + +func (e *TerminateEvent) Data() interface{} { + return e +} + +func (e *TerminateEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func NewTerminateEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, +) *TerminateEvent { + return &TerminateEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + TerminateType, + ), + } +} + +func TerminateEventMapper(event *repository.Event) (eventstore.Event, error) { + return &TerminateEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + }, nil +} diff --git a/internal/static/i18n/de.yaml b/internal/static/i18n/de.yaml index 9cf6f96f40..9e7e9c5c82 100644 --- a/internal/static/i18n/de.yaml +++ b/internal/static/i18n/de.yaml @@ -470,6 +470,11 @@ Errors: Execution: StorageFailed: Das Speichern des Action Logs in der Datenbank ist fehlgeschlagen ScanFailed: Das Abfragen der verbrauchten Actions Sekunden ist fehlgeschlagen + Session: + NotExisting: Session existiert nicht + Terminated: Session bereits beendet + Token: + Invalid: Session Token ist ungültig AggregateTypes: action: Action diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index c5d3c36b2c..9362eaf2bd 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -470,6 +470,11 @@ Errors: Execution: StorageFailed: Storing action execution log to database failed ScanFailed: Querying usage for action execution seconds failed + Session: + NotExisting: Session does not exist + Terminated: Session already terminated + Token: + Invalid: Session Token is invalid AggregateTypes: action: Action diff --git a/internal/static/i18n/es.yaml b/internal/static/i18n/es.yaml index 63ffb3981c..c87d9f20cd 100644 --- a/internal/static/i18n/es.yaml +++ b/internal/static/i18n/es.yaml @@ -470,6 +470,11 @@ Errors: Execution: StorageFailed: Ha fallado el almacenaje del registro de ejecución de acciones en la base de datos ScanFailed: La consulta de uso de los segundos de ejecuciónde acciones ha fallado + Session: + NotExisting: La sesión no existe + Terminated: Sesión ya terminada + Token: + Invalid: El identificador de sesión no es válido AggregateTypes: action: Acción diff --git a/internal/static/i18n/fr.yaml b/internal/static/i18n/fr.yaml index 184cd83f06..bc2f111da7 100644 --- a/internal/static/i18n/fr.yaml +++ b/internal/static/i18n/fr.yaml @@ -470,6 +470,11 @@ Errors: Execution: StorageFailed: L'enregistrement du journal d'action dans la base de données a échoué ScanFailed: L'interrogation des secondes d'action consommées a échoué + Session: + NotExisting: La session n'existe pas + Terminated: La session est déjà terminée + Token: + Invalid: Le jeton de session n'est pas valide AggregateTypes: action: Action diff --git a/internal/static/i18n/it.yaml b/internal/static/i18n/it.yaml index 1f9056cf5f..f270a5f690 100644 --- a/internal/static/i18n/it.yaml +++ b/internal/static/i18n/it.yaml @@ -470,6 +470,11 @@ Errors: Execution: StorageFailed: Il salvataggio del registro delle azioni nel database non è riuscito ScanFailed: La query dei secondi delle azioni utilizzate non è riuscita + Session: + NotExisting: La sessione non esiste + Terminated: Sessione già terminata + Token: + Invalid: Il token della sessione non è valido AggregateTypes: action: Azione diff --git a/internal/static/i18n/ja.yaml b/internal/static/i18n/ja.yaml index 8f347b02d1..284c0412a8 100644 --- a/internal/static/i18n/ja.yaml +++ b/internal/static/i18n/ja.yaml @@ -459,6 +459,11 @@ Errors: Execution: StorageFailed: アクション実行ログのデータベースへの保存に失敗しました ScanFailed: アクション実行時間を取得する使用状況クエリに失敗しました + Session: + NotExisting: セッションが存在しない + Terminated: セッションはすでに終了しています + Token: + Invalid: セッショントークンが無効です AggregateTypes: action: アクション diff --git a/internal/static/i18n/pl.yaml b/internal/static/i18n/pl.yaml index 0ef614bff8..9fac79a257 100644 --- a/internal/static/i18n/pl.yaml +++ b/internal/static/i18n/pl.yaml @@ -470,6 +470,11 @@ Errors: Execution: StorageFailed: Zapisywanie dziennika wykonania akcji do bazy danych nie powiodło się ScanFailed: Zapytanie o użycie dla sekund wykonania akcji nie powiodło się + Session: + NotExisting: Sesja nie istnieje + Terminated: Sesja już zakończona + Token: + Invalid: Token sesji jest nieprawidłowy AggregateTypes: action: Działanie diff --git a/internal/static/i18n/zh.yaml b/internal/static/i18n/zh.yaml index 31c679ea21..c36cbcd111 100644 --- a/internal/static/i18n/zh.yaml +++ b/internal/static/i18n/zh.yaml @@ -470,6 +470,11 @@ Errors: Execution: StorageFailed: 将行动执行日志存储到数据库失败 ScanFailed: Q查询动作执行秒数的使用情况失败 + Session: + NotExisting: 会话不存在 + Terminated: 会话已经终止 + Token: + Invalid: 会话令牌是无效的 AggregateTypes: action: 动作 diff --git a/main.go b/main.go index 0f3264080e..97689bbe41 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,6 @@ import ( func main() { args := os.Args[1:] - rootCmd := cmd.New(os.Stdout, os.Stdin /*, int(os.Stdin.Fd())*/, args) + rootCmd := cmd.New(os.Stdout, os.Stdin, args, nil) cobra.CheckErr(rootCmd.Execute()) } diff --git a/pkg/grpc/protoc/v2/doc.go b/pkg/grpc/protoc/v2/doc.go new file mode 100644 index 0000000000..bdf6bd92c0 --- /dev/null +++ b/pkg/grpc/protoc/v2/doc.go @@ -0,0 +1,3 @@ +// Package protoc contains the generated protobuf structs +// the folder will be empty until the files are generated +package protoc diff --git a/pkg/grpc/user/v2alpha/user.go b/pkg/grpc/user/v2alpha/user.go deleted file mode 100644 index f419594dfb..0000000000 --- a/pkg/grpc/user/v2alpha/user.go +++ /dev/null @@ -1,5 +0,0 @@ -package user - -func (r *AddHumanUserRequest) AuthContext() string { - return r.GetOrganisation().GetOrgId() -} diff --git a/proto/zitadel/object/v2alpha/object.proto b/proto/zitadel/object/v2alpha/object.proto index 3a209b6371..27c6dbb395 100644 --- a/proto/zitadel/object/v2alpha/object.proto +++ b/proto/zitadel/object/v2alpha/object.proto @@ -11,9 +11,35 @@ import "validate/validate.proto"; message Organisation { oneof org { string org_id = 1; + string org_domain = 2; } } +message ListQuery { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { + json_schema: { + title: "General List Query" + description: "Object unspecific list filters like offset, limit and asc/desc." + } + }; + uint64 offset = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"0\""; + } + ]; + uint32 limit = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "100"; + description: "Maximum amount of events returned. The default is set to 1000 in https://github.com/zitadel/zitadel/blob/new-eventstore/cmd/zitadel/startup.yaml. If the limit exceeds the maximum configured ZITADEL will throw an error. If no limit is present the default is taken."; + } + ]; + bool asc = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "default is descending" + } + ]; +} + message Details { //sequence represents the order of events. It's always counting // @@ -38,3 +64,21 @@ message Details { } ]; } + +message ListDetails { + uint64 total_result = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2\""; + } + ]; + uint64 processed_sequence = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"267831\""; + } + ]; + google.protobuf.Timestamp timestamp = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "the last time the projection got updated" + } + ]; +} diff --git a/proto/zitadel/protoc_gen_zitadel/v2/options.proto b/proto/zitadel/protoc_gen_zitadel/v2/options.proto new file mode 100644 index 0000000000..6ab648e74c --- /dev/null +++ b/proto/zitadel/protoc_gen_zitadel/v2/options.proto @@ -0,0 +1,26 @@ +syntax = "proto3"; + +package zitadel.protoc_gen_zitadel.v2; + +import "google/protobuf/descriptor.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/protoc/v2;protoc"; + +extend google.protobuf.MethodOptions { + Options options = 50001; +} + +message Options { + AuthOption auth_option = 1; + CustomHTTPResponse http_response = 2; +} + +message AuthOption { + reserved 2; + string permission = 1; + string org_field = 3; +} + +message CustomHTTPResponse { + int32 success_code = 1; +} diff --git a/proto/zitadel/session/v2alpha/session.proto b/proto/zitadel/session/v2alpha/session.proto index 8d4c168b17..279863d14b 100644 --- a/proto/zitadel/session/v2alpha/session.proto +++ b/proto/zitadel/session/v2alpha/session.proto @@ -2,11 +2,90 @@ syntax = "proto3"; package zitadel.session.v2alpha; -import "zitadel/user/v2alpha/user.proto"; +import "google/api/field_behavior.proto"; +import "google/protobuf/timestamp.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/session/v2alpha;session"; message Session { - string id = 1; - zitadel.user.v2alpha.User user = 2; + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"id of the session\""; + } + ]; + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"time when the session was created\""; + } + ]; + google.protobuf.Timestamp change_date = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"time when the session was last updated\""; + } + ]; + uint64 sequence = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"sequence of the session\""; + } + ]; + Factors factors = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"checked factors of the session, e.g. the user, password and more\""; + } + ]; + map metadata = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"custom key value list\""; + } + ]; +} + +message Factors { + UserFactor user = 1; + PasswordFactor password = 2; +} + +message UserFactor { + google.protobuf.Timestamp verified_at = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"time when the user was last checked\""; + } + ]; + string id = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"id of the checked user\""; + } + ]; + string login_name = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"login name of the checked user\""; + } + ]; + string display_name = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"display name of the checked user\""; + } + ]; +} + +message PasswordFactor { + google.protobuf.Timestamp verified_at = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"time when the password was last checked\""; + } + ]; +} + +message SearchQuery { + oneof query { + option (validate.required) = true; + + IDsQuery ids_query = 1; + } +} + +message IDsQuery { + repeated string ids = 1; } diff --git a/proto/zitadel/session/v2alpha/session_service.proto b/proto/zitadel/session/v2alpha/session_service.proto index aefd47e21b..e564621074 100644 --- a/proto/zitadel/session/v2alpha/session_service.proto +++ b/proto/zitadel/session/v2alpha/session_service.proto @@ -2,32 +2,364 @@ syntax = "proto3"; package zitadel.session.v2alpha; -import "zitadel/options.proto"; + +import "zitadel/object/v2alpha/object.proto"; +import "zitadel/protoc_gen_zitadel/v2/options.proto"; import "zitadel/session/v2alpha/session.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/session/v2alpha;session"; +option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { + info: { + title: "Session Service"; + version: "2.0-alpha"; + description: "This API is intended to manage sessions 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 SessionService { - // GetSession is to demonstrate an authenticated request, where the authenticated user (usage of another grpc package) is returned - // - // this request is subject to change and currently used for demonstration only - rpc GetSession (GetSessionRequest) returns (GetSessionResponse) { + // Search sessions + rpc ListSessions (ListSessionsRequest) returns (ListSessionsResponse) { option (google.api.http) = { - get: "/v2alpha/sessions/{id}" + post: "/v2alpha/sessions/_search" + body: "*" }; - option (zitadel.v1.auth_option) = { - permission: "authenticated" + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Search sessions"; + description: "Search for sessions" + responses: { + key: "200" + value: { + description: "OK"; + } + }; + responses: { + key: "400"; + value: { + description: "invalid list query"; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + }; + }; + }; + } + + // GetSession a session + rpc GetSession (GetSessionRequest) returns (GetSessionResponse) { + option (google.api.http) = { + get: "/v2alpha/sessions/{session_id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Get a session"; + description: "Get a session and all its information like the time of the user or password verification" + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + // Create a new session + rpc CreateSession (CreateSessionRequest) returns (CreateSessionResponse) { + option (google.api.http) = { + post: "/v2alpha/sessions" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + http_response: { + success_code: 201 + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Create a new session"; + description: "Create a new session. A token will be returned, which is required for further updates of the session." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + // Update a session + rpc SetSession (SetSessionRequest) returns (SetSessionResponse) { + option (google.api.http) = { + patch: "/v2alpha/sessions/{session_id}" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Update an existing session"; + description: "Update an existing session with new information." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + // Terminate a session + rpc DeleteSession (DeleteSessionRequest) returns (DeleteSessionResponse) { + option (google.api.http) = { + delete: "/v2alpha/sessions/{session_id}" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Terminate an existing session"; + description: "Terminate your own session or if granted any other session." + responses: { + key: "200" + value: { + description: "OK"; + } + }; }; } } +message ListSessionsRequest{ + zitadel.object.v2alpha.ListQuery query = 1; + repeated SearchQuery queries = 2; +} + +message ListSessionsResponse{ + zitadel.object.v2alpha.ListDetails details = 1; + repeated Session sessions = 2; +} + message GetSessionRequest{ - string id = 1; + string session_id = 1; + optional string session_token = 2; } message GetSessionResponse{ Session session = 1; } + +message CreateSessionRequest{ + Checks checks = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"Check for user and password. Successful checks will be stated as factors on the session.\""; + } + ]; + map metadata = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"custom key value list to be stored on the session\""; + } + ]; +} + +message CreateSessionResponse{ + zitadel.object.v2alpha.Details details = 1; + string session_id = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"id of the session\""; + example: "\"d654e6ba-70a3-48ef-a95d-37c8d8a7901a\""; + } + ]; + string session_token = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"token of the session, which is required for further updates of the session or the request other resources\""; + } + ]; +} + +message SetSessionRequest{ + string session_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + description: "\"id of the session to update\""; + example: "\"d654e6ba-70a3-48ef-a95d-37c8d8a7901a\""; + } + ]; + string session_token = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + description: "\"token of the session, previously returned on the create / update request\""; + } + ]; + Checks checks = 3[ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"Check for user and password. Successful checks will be stated as factors on the session.\""; + } + ]; + map metadata = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"custom key value list to be stored on the session\""; + } + ]; +} + +message SetSessionResponse{ + zitadel.object.v2alpha.Details details = 1; + string session_token = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"token of the session, which is required for further updates of the session or the request other resources\""; + } + ]; +} + +message DeleteSessionRequest{ + string session_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + description: "\"id of the session to terminate\""; + example: "\"d654e6ba-70a3-48ef-a95d-37c8d8a7901a\""; + } + ]; + optional string session_token = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"token of the session, previously returned on the create / update request\""; + } + ]; +} + +message DeleteSessionResponse{ + zitadel.object.v2alpha.Details details = 1; +} + +message Checks { + optional CheckUser user = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"checks the user and updates the session on success\""; + } + ]; + optional CheckPassword password = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"Checks the password and updates the session on success. Requires that the user is already checked, either in the previous or the same request.\""; + } + ]; +} + +message CheckUser { + oneof search { + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"d654e6ba-70a3-48ef-a95d-37c8d8a7901a\""; + } + ]; + string login_name = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"mini@mouse.com\""; + } + ]; + } +} + +message CheckPassword { + string password = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"V3ryS3cure!\""; + } + ]; +} diff --git a/proto/zitadel/user/v2alpha/user_service.proto b/proto/zitadel/user/v2alpha/user_service.proto index 2a4629779e..f899d91f7a 100644 --- a/proto/zitadel/user/v2alpha/user_service.proto +++ b/proto/zitadel/user/v2alpha/user_service.proto @@ -2,8 +2,8 @@ syntax = "proto3"; package zitadel.user.v2alpha; -import "zitadel/options.proto"; import "zitadel/object/v2alpha/object.proto"; +import "zitadel/protoc_gen_zitadel/v2/options.proto"; import "zitadel/user/v2alpha/email.proto"; import "zitadel/user/v2alpha/password.proto"; import "zitadel/user/v2alpha/user.proto"; @@ -82,8 +82,14 @@ service UserService { body: "*" }; - option (zitadel.v1.auth_option) = { - permission: "user.write" + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "user.write" + org_field: "organisation" + } + http_response: { + success_code: 201 + } }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { @@ -105,8 +111,10 @@ service UserService { body: "*" }; - option (zitadel.v1.auth_option) = { - permission: "authenticated" + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { @@ -128,8 +136,10 @@ service UserService { body: "*" }; - option (zitadel.v1.auth_option) = { - permission: "authenticated" + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {