mirror of
https://github.com/zitadel/zitadel.git
synced 2025-03-04 02:45:19 +00:00
Merge branch 'main' into grcp-server-reflect
This commit is contained in:
commit
1461d9ec6d
3
.github/pull_request_template.md
vendored
3
.github/pull_request_template.md
vendored
@ -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.
|
||||
```
|
||||
|
51
.github/workflows/integration.yml
vendored
Normal file
51
.github/workflows/integration.yml
vendored
Normal file
@ -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
|
2
.github/workflows/test-code.yml
vendored
2
.github/workflows/test-code.yml
vendored
@ -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
|
||||
|
@ -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(<scope>): <short summary>` as title for the semantic release.
|
||||
|
@ -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
|
||||
|
||||
|
@ -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(),
|
||||
)
|
||||
|
||||
|
@ -77,6 +77,7 @@ func (mig *FirstInstance) Execute(ctx context.Context) error {
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
|
@ -52,6 +52,7 @@ func (mig *externalConfigChange) Execute(ctx context.Context) error {
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
|
@ -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)
|
||||
|
@ -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")
|
||||
},
|
||||
}
|
||||
|
@ -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")
|
||||
},
|
||||
}
|
||||
|
@ -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(),
|
||||
)
|
||||
|
||||
|
@ -21,6 +21,7 @@
|
||||
<meta name="twitter:title" content="ZITADEL Console" />
|
||||
<meta name="twitter:description" content="Management Platform for ZITADEL IAM" />
|
||||
<meta name="twitter:image" content="https://www.zitadel.com/images/preview.png" />
|
||||
<meta name="robots" content="none" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
@ -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 |
|
64
docs/docs/concepts/features/audit-trail.md
Normal file
64
docs/docs/concepts/features/audit-trail.md
Normal file
@ -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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
### 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 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
|
@ -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).
|
||||
|
||||

|
||||
|
||||
## Multiple Virtual Instances
|
||||
|
||||
ZITADEL has the concept of virtual instances.
|
||||
|
@ -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.
|
||||
|
||||
|
@ -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.
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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).
|
@ -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
|
||||
|
26
docs/docs/support/advisory/a10000.md
Normal file
26
docs/docs/support/advisory/a10000.md
Normal file
@ -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.
|
39
docs/docs/support/technical_advisory.mdx
Normal file
39
docs/docs/support/technical_advisory.mdx
Normal file
@ -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.
|
||||
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>Advisory</th>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Summary</th>
|
||||
<th>Affected versions</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="./advisory/a10000">A-10000</a></td>
|
||||
<td>Reusing user session</td>
|
||||
<td>Breaking Behaviour Change</td>
|
||||
<td>The 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.</td>
|
||||
<td>TBD</td>
|
||||
<td>TBD</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 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.
|
||||
|
@ -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",
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -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",
|
||||
|
BIN
docs/static/img/concepts/audit-trail/audit-log-events.png
vendored
Normal file
BIN
docs/static/img/concepts/audit-trail/audit-log-events.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 410 KiB |
BIN
docs/static/img/concepts/audit-trail/event-viewer.png
vendored
Normal file
BIN
docs/static/img/concepts/audit-trail/event-viewer.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 583 KiB |
BIN
docs/static/img/concepts/objects/instances.png
vendored
Normal file
BIN
docs/static/img/concepts/objects/instances.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 324 KiB |
2
go.mod
2
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
|
||||
|
@ -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(
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
36
internal/api/grpc/admin/information_integration_test.go
Normal file
36
internal/api/grpc/admin/information_integration_test.go
Normal file
@ -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)
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
379
internal/api/grpc/session/v2/session_test.go
Normal file
379
internal/api/grpc/session/v2/session_test.go
Normal file
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
@ -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:
|
||||
|
210
internal/api/grpc/user/v2/email_integration_test.go
Normal file
210
internal/api/grpc/user/v2/email_integration_test.go
Normal file
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
@ -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
|
||||
|
317
internal/api/grpc/user/v2/user_integration_test.go
Normal file
317
internal/api/grpc/user/v2/user_integration_test.go
Normal file
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
@ -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"
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
||||
|
14
internal/api/http/middleware/robots_tag_interceptor.go
Normal file
14
internal/api/http/middleware/robots_tag_interceptor.go
Normal file
@ -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)
|
||||
})
|
||||
}
|
24
internal/api/http/middleware/robots_tag_interceptor_test.go
Normal file
24
internal/api/http/middleware/robots_tag_interceptor_test.go
Normal file
@ -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()
|
||||
}
|
19
internal/api/robots_txt/robots_txt.go
Normal file
19
internal/api/robots_txt/robots_txt.go
Normal file
@ -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
|
||||
}
|
28
internal/api/robots_txt/robots_txt_test.go
Normal file
28
internal/api/robots_txt/robots_txt_test.go
Normal file
@ -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()
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -23,6 +23,7 @@
|
||||
|
||||
<title>{{ .Title }}</title>
|
||||
<meta name="description" content="{{ .Description }}"/>
|
||||
<meta name="robots" content="none" />
|
||||
|
||||
<script src="{{ resourceUrl "scripts/theme.js" }}"></script>
|
||||
</head>
|
||||
|
@ -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)
|
||||
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
)
|
225
internal/command/session.go
Normal file
225
internal/command/session.go
Normal file
@ -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,
|
||||
}
|
||||
}
|
139
internal/command/session_model.go
Normal file
139
internal/command/session_model.go
Normal file
@ -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))
|
||||
}
|
||||
}
|
547
internal/command/session_test.go
Normal file
547
internal/command/session_test.go
Normal file
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
|
119
internal/database/type_test.go
Normal file
119
internal/database/type_test.go
Normal file
@ -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()")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -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"
|
||||
)
|
||||
|
9
internal/domain/session.go
Normal file
9
internal/domain/session.go
Normal file
@ -0,0 +1,9 @@
|
||||
package domain
|
||||
|
||||
type SessionState int32
|
||||
|
||||
const (
|
||||
SessionStateUnspecified SessionState = iota
|
||||
SessionStateActive
|
||||
SessionStateTerminated
|
||||
)
|
@ -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
|
||||
}
|
||||
|
41
internal/integration/assert.go
Normal file
41
internal/integration/assert.go
Normal file
@ -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())
|
||||
}
|
51
internal/integration/assert_test.go
Normal file
51
internal/integration/assert_test.go
Normal file
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
10
internal/integration/config/cockroach.yaml
Normal file
10
internal/integration/config/cockroach.yaml
Normal file
@ -0,0 +1,10 @@
|
||||
Database:
|
||||
cockroach:
|
||||
Host: localhost
|
||||
Port: 26257
|
||||
Database: zitadel
|
||||
Options: ""
|
||||
User:
|
||||
Username: zitadel
|
||||
Admin:
|
||||
Username: root
|
24
internal/integration/config/docker-compose.yaml
Normal file
24
internal/integration/config/docker-compose.yaml
Normal file
@ -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
|
15
internal/integration/config/postgres.yaml
Normal file
15
internal/integration/config/postgres.yaml
Normal file
@ -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
|
37
internal/integration/config/zitadel.yaml
Normal file
37
internal/integration/config/zitadel.yaml
Normal file
@ -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"
|
250
internal/integration/integration.go
Normal file
250
internal/integration/integration.go
Normal file
@ -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
|
||||
}
|
16
internal/integration/integration_test.go
Normal file
16
internal/integration/integration_test.go
Normal file
@ -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()
|
||||
}
|
24
internal/integration/usertype_string.go
Normal file
24
internal/integration/usertype_string.go
Normal file
@ -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]]
|
||||
}
|
147
internal/protoc/protoc-gen-zitadel/main.go
Normal file
147
internal/protoc/protoc-gen-zitadel/main.go
Normal file
@ -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 ""
|
||||
}
|
29
internal/protoc/protoc-gen-zitadel/zitadel.pb.go.tmpl
Normal file
29
internal/protoc/protoc-gen-zitadel/zitadel.pb.go.tmpl
Normal file
@ -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 }}
|
@ -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) {
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
221
internal/query/projection/session.go
Normal file
221
internal/query/projection/session.go
Normal file
@ -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
|
||||
}
|
260
internal/query/projection/session_test.go
Normal file
260
internal/query/projection/session_test.go
Normal file
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
@ -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{
|
||||
|
320
internal/query/session.go
Normal file
320
internal/query/session.go
Normal file
@ -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
|
||||
}
|
||||
}
|
396
internal/query/sessions_test.go
Normal file
396
internal/query/sessions_test.go
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
}
|
25
internal/repository/session/aggregate.go
Normal file
25
internal/repository/session/aggregate.go
Normal file
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
12
internal/repository/session/eventstore.go
Normal file
12
internal/repository/session/eventstore.go
Normal file
@ -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)
|
||||
}
|
255
internal/repository/session/session.go
Normal file
255
internal/repository/session/session.go
Normal file
@ -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
|
||||
}
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -459,6 +459,11 @@ Errors:
|
||||
Execution:
|
||||
StorageFailed: アクション実行ログのデータベースへの保存に失敗しました
|
||||
ScanFailed: アクション実行時間を取得する使用状況クエリに失敗しました
|
||||
Session:
|
||||
NotExisting: セッションが存在しない
|
||||
Terminated: セッションはすでに終了しています
|
||||
Token:
|
||||
Invalid: セッショントークンが無効です
|
||||
|
||||
AggregateTypes:
|
||||
action: アクション
|
||||
|
@ -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
|
||||
|
@ -470,6 +470,11 @@ Errors:
|
||||
Execution:
|
||||
StorageFailed: 将行动执行日志存储到数据库失败
|
||||
ScanFailed: Q查询动作执行秒数的使用情况失败
|
||||
Session:
|
||||
NotExisting: 会话不存在
|
||||
Terminated: 会话已经终止
|
||||
Token:
|
||||
Invalid: 会话令牌是无效的
|
||||
|
||||
AggregateTypes:
|
||||
action: 动作
|
||||
|
2
main.go
2
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())
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user