mirror of
https://github.com/zitadel/zitadel.git
synced 2025-04-22 14:51:31 +00:00
chore: merge main to next (#5871)
### Definition of Ready - [ ] I am happy with the code - [ ] Short description of the feature/issue is added in the pr description - [ ] PR is linked to the corresponding user story - [ ] Acceptance criteria are met - [ ] All open todos and follow ups are defined in a new ticket and justified - [ ] Deviations from the acceptance criteria and design are agreed with the PO and documented. - [ ] No debug or dead code - [ ] Critical parts are tested automatically - [ ] Where possible E2E tests are implemented - [ ] Documentation/examples are up-to-date - [ ] All non-functional requirements are met - [ ] Functionality of the acceptance criteria is checked manually on the dev system.
This commit is contained in:
commit
4982af898a
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,8 +92,20 @@ 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
|
||||
|
||||
protoc \
|
||||
-I=/proto/include \
|
||||
--grpc-gateway_out ${GOPATH}/src \
|
||||
--grpc-gateway_opt logtostderr=true \
|
||||
--grpc-gateway_opt allow_delete_body=true \
|
||||
--openapiv2_out ${OPENAPI_PATH} \
|
||||
--openapiv2_opt logtostderr=true \
|
||||
--openapiv2_opt allow_delete_body=true \
|
||||
--zitadel_out=${GOPATH}/src \
|
||||
--validate_out=lang=go:${GOPATH}/src \
|
||||
${PROTO_PATH}/settings/v2alpha/settings_service.proto
|
||||
|
||||
echo "done generating grpc"
|
||||
|
@ -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(),
|
||||
)
|
||||
|
||||
|
@ -267,6 +267,7 @@ Console:
|
||||
LongCache:
|
||||
MaxAge: 12h
|
||||
SharedMaxAge: 168h #7d
|
||||
InstanceManagementURL: ""
|
||||
|
||||
Notification:
|
||||
Repository:
|
||||
|
@ -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 {
|
||||
|
@ -5,7 +5,7 @@ import (
|
||||
"crypto/tls"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"net"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
@ -33,11 +33,13 @@ import (
|
||||
"github.com/zitadel/zitadel/internal/api/grpc/auth"
|
||||
"github.com/zitadel/zitadel/internal/api/grpc/management"
|
||||
"github.com/zitadel/zitadel/internal/api/grpc/session/v2"
|
||||
"github.com/zitadel/zitadel/internal/api/grpc/settings/v2"
|
||||
"github.com/zitadel/zitadel/internal/api/grpc/system"
|
||||
"github.com/zitadel/zitadel/internal/api/grpc/user/v2"
|
||||
http_util "github.com/zitadel/zitadel/internal/api/http"
|
||||
"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
|
||||
@ -224,8 +301,14 @@ func startAPIs(
|
||||
if accessSvc.Enabled() {
|
||||
logging.Warn("access logs are currently in beta")
|
||||
}
|
||||
accessInterceptor := middleware.NewAccessInterceptor(accessSvc, config.Quotas.Access)
|
||||
apis, err := api.New(ctx, config.Port, router, queries, verifier, config.InternalAuthZ, tlsConfig, config.HTTP2HostHeader, config.HTTP1HostHeader, accessSvc)
|
||||
exhaustedCookieHandler := http_util.NewCookieHandler(
|
||||
http_util.WithUnsecure(),
|
||||
http_util.WithNonHttpOnly(),
|
||||
http_util.WithMaxAge(int(math.Floor(config.Quotas.Access.ExhaustedCookieMaxAge.Seconds()))),
|
||||
)
|
||||
limitingAccessInterceptor := middleware.NewAccessInterceptor(accessSvc, exhaustedCookieHandler, config.Quotas.Access, false)
|
||||
nonLimitingAccessInterceptor := middleware.NewAccessInterceptor(accessSvc, nil, config.Quotas.Access, true)
|
||||
apis, err := api.New(ctx, config.Port, router, queries, verifier, config.InternalAuthZ, tlsConfig, config.HTTP2HostHeader, config.HTTP1HostHeader, accessSvc, exhaustedCookieHandler, config.Quotas.Access)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating api %w", err)
|
||||
}
|
||||
@ -252,18 +335,28 @@ 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
|
||||
}
|
||||
if err := apis.RegisterService(ctx, settings.CreateServer(commands, queries, config.ExternalSecure)); err != nil {
|
||||
return err
|
||||
}
|
||||
instanceInterceptor := middleware.InstanceInterceptor(queries, config.HTTP1HostHeader, login.IgnoreInstanceEndpoints...)
|
||||
assetsCache := middleware.AssetsCacheInterceptor(config.AssetStorage.Cache.MaxAge, config.AssetStorage.Cache.SharedMaxAge)
|
||||
apis.RegisterHandlerOnPrefix(assets.HandlerPrefix, assets.NewHandler(commands, verifier, config.InternalAuthZ, id.SonyFlakeGenerator(), store, queries, middleware.CallDurationHandler, instanceInterceptor.Handler, assetsCache.Handler, accessInterceptor.Handle))
|
||||
apis.RegisterHandlerOnPrefix(assets.HandlerPrefix, assets.NewHandler(commands, verifier, config.InternalAuthZ, id.SonyFlakeGenerator(), store, queries, middleware.CallDurationHandler, instanceInterceptor.Handler, assetsCache.Handler, limitingAccessInterceptor.Handle))
|
||||
|
||||
userAgentInterceptor, err := middleware.NewUserAgentHandler(config.UserAgentCookie, keys.UserAgentCookieKey, id.SonyFlakeGenerator(), config.ExternalSecure, login.EndpointResources)
|
||||
if err != nil {
|
||||
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 {
|
||||
@ -271,25 +364,25 @@ func startAPIs(
|
||||
}
|
||||
apis.RegisterHandlerOnPrefix(openapi.HandlerPrefix, openAPIHandler)
|
||||
|
||||
oidcProvider, err := oidc.NewProvider(config.OIDC, login.DefaultLoggedOutPath, config.ExternalSecure, commands, queries, authRepo, keys.OIDC, keys.OIDCKey, eventstore, dbClient, userAgentInterceptor, instanceInterceptor.Handler, accessInterceptor.Handle)
|
||||
oidcProvider, err := oidc.NewProvider(config.OIDC, login.DefaultLoggedOutPath, config.ExternalSecure, commands, queries, authRepo, keys.OIDC, keys.OIDCKey, eventstore, dbClient, userAgentInterceptor, instanceInterceptor.Handler, limitingAccessInterceptor.Handle)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to start oidc provider: %w", err)
|
||||
}
|
||||
apis.RegisterHandlerPrefixes(oidcProvider.HttpHandler(), "/.well-known/openid-configuration", "/oidc/v1", "/oauth/v2")
|
||||
|
||||
samlProvider, err := saml.NewProvider(config.SAML, config.ExternalSecure, commands, queries, authRepo, keys.OIDC, keys.SAML, eventstore, dbClient, instanceInterceptor.Handler, userAgentInterceptor, accessInterceptor.Handle)
|
||||
samlProvider, err := saml.NewProvider(config.SAML, config.ExternalSecure, commands, queries, authRepo, keys.OIDC, keys.SAML, eventstore, dbClient, instanceInterceptor.Handler, userAgentInterceptor, limitingAccessInterceptor.Handle)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to start saml provider: %w", err)
|
||||
}
|
||||
apis.RegisterHandlerOnPrefix(saml.HandlerPrefix, samlProvider.HttpHandler())
|
||||
|
||||
c, err := console.Start(config.Console, config.ExternalSecure, oidcProvider.IssuerFromRequest, middleware.CallDurationHandler, instanceInterceptor.Handler, accessInterceptor.Handle, config.CustomerPortal)
|
||||
c, err := console.Start(config.Console, config.ExternalSecure, oidcProvider.IssuerFromRequest, middleware.CallDurationHandler, instanceInterceptor.Handler, nonLimitingAccessInterceptor.Handle, config.CustomerPortal)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to start console: %w", err)
|
||||
}
|
||||
apis.RegisterHandlerOnPrefix(console.HandlerPrefix, c)
|
||||
|
||||
l, err := login.CreateLogin(config.Login, commands, queries, authRepo, store, console.HandlerPrefix+"/", op.AuthCallbackURL(oidcProvider), provider.AuthCallbackURL(samlProvider), config.ExternalSecure, userAgentInterceptor, op.NewIssuerInterceptor(oidcProvider.IssuerFromRequest).Handler, provider.NewIssuerInterceptor(samlProvider.IssuerFromRequest).Handler, instanceInterceptor.Handler, assetsCache.Handler, accessInterceptor.Handle, keys.User, keys.IDPConfig, keys.CSRFCookieKey)
|
||||
l, err := login.CreateLogin(config.Login, commands, queries, authRepo, store, console.HandlerPrefix+"/", op.AuthCallbackURL(oidcProvider), provider.AuthCallbackURL(samlProvider), config.ExternalSecure, userAgentInterceptor, op.NewIssuerInterceptor(oidcProvider.IssuerFromRequest).Handler, provider.NewIssuerInterceptor(samlProvider.IssuerFromRequest).Handler, instanceInterceptor.Handler, assetsCache.Handler, limitingAccessInterceptor.Handle, keys.User, keys.IDPConfig, keys.CSRFCookieKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to start login: %w", err)
|
||||
}
|
||||
@ -301,10 +394,12 @@ func startAPIs(
|
||||
return nil
|
||||
}
|
||||
|
||||
func listen(ctx context.Context, router *mux.Router, port uint16, tlsConfig *tls.Config) error {
|
||||
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 := listenConfig()
|
||||
lis, err := lc.Listen(ctx, "tcp", fmt.Sprintf(":%d", port))
|
||||
if err != nil {
|
||||
return fmt.Errorf("tcp listener on %d failed: %w", port, err)
|
||||
}
|
||||
@ -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")
|
||||
},
|
||||
}
|
||||
|
11
cmd/start/start_port.go
Normal file
11
cmd/start/start_port.go
Normal file
@ -0,0 +1,11 @@
|
||||
//go:build !integration
|
||||
|
||||
package start
|
||||
|
||||
import (
|
||||
"net"
|
||||
)
|
||||
|
||||
func listenConfig() *net.ListenConfig {
|
||||
return &net.ListenConfig{}
|
||||
}
|
25
cmd/start/start_port_integration.go
Normal file
25
cmd/start/start_port_integration.go
Normal file
@ -0,0 +1,25 @@
|
||||
//go:build integration
|
||||
|
||||
package start
|
||||
|
||||
import (
|
||||
"net"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func listenConfig() *net.ListenConfig {
|
||||
return &net.ListenConfig{
|
||||
Control: reusePort,
|
||||
}
|
||||
}
|
||||
|
||||
func reusePort(network, address string, conn syscall.RawConn) error {
|
||||
return conn.Control(func(descriptor uintptr) {
|
||||
err := syscall.SetsockoptInt(int(descriptor), syscall.SOL_SOCKET, unix.SO_REUSEPORT, 1)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
})
|
||||
}
|
@ -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(),
|
||||
)
|
||||
|
||||
|
@ -30,19 +30,23 @@
|
||||
<span class="fill-space"></span>
|
||||
<div class="app-specs cnsl-secondary-text">
|
||||
<div class="row" *ngIf="isOIDC && method && method.responseType !== undefined">
|
||||
<span>{{ 'APP.OIDC.RESPONSETYPE' | translate }}</span>
|
||||
<span class="row-entry">{{ 'APP.OIDC.RESPONSETYPE' | translate }}</span>
|
||||
<span>{{ 'APP.OIDC.RESPONSE.' + method.responseType.toString() | translate }}</span>
|
||||
</div>
|
||||
<div class="row" *ngIf="isOIDC && method.grantType !== undefined">
|
||||
<span>{{ 'APP.GRANT' | translate }}</span>
|
||||
<span>{{ 'APP.OIDC.GRANT.' + method.grantType.toString() | translate }}</span>
|
||||
<span class="row-entry">{{ 'APP.GRANT' | translate }}</span>
|
||||
<span
|
||||
><span class="space" *ngFor="let grant of method.grantType">{{
|
||||
'APP.OIDC.GRANT.' + grant.toString() | translate
|
||||
}}</span></span
|
||||
>
|
||||
</div>
|
||||
<div class="row" *ngIf="isOIDC && method.authMethod !== undefined">
|
||||
<span>{{ 'APP.AUTHMETHOD' | translate }}</span>
|
||||
<span class="row-entry">{{ 'APP.AUTHMETHOD' | translate }}</span>
|
||||
<span>{{ 'APP.OIDC.AUTHMETHOD.' + method.authMethod.toString() | translate }}</span>
|
||||
</div>
|
||||
<div class="row" *ngIf="!isOIDC && method.apiAuthMethod !== undefined">
|
||||
<span>{{ 'APP.AUTHMETHOD' | translate }}</span>
|
||||
<span class="row-entry">{{ 'APP.AUTHMETHOD' | translate }}</span>
|
||||
<span>{{ 'APP.API.AUTHMETHOD.' + method.apiAuthMethod.toString() | translate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -155,7 +155,11 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:first-child {
|
||||
.space {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.row-entry {
|
||||
margin-right: 1rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
@ -14,7 +14,7 @@ export interface RadioItemAuthType {
|
||||
prefix: string;
|
||||
background: string;
|
||||
responseType?: OIDCResponseType;
|
||||
grantType?: OIDCGrantType;
|
||||
grantType?: OIDCGrantType[];
|
||||
authMethod?: OIDCAuthMethodType;
|
||||
apiAuthMethod?: APIAuthMethodType;
|
||||
recommended?: boolean;
|
||||
|
@ -58,13 +58,9 @@
|
||||
</form>
|
||||
</mat-step>
|
||||
|
||||
<!-- skip for native OIDC and SAML applications -->
|
||||
<!-- skip for SAML applications -->
|
||||
<mat-step
|
||||
*ngIf="
|
||||
(appType?.value?.createType === AppCreateType.OIDC &&
|
||||
appType?.value.oidcAppType !== OIDCAppType.OIDC_APP_TYPE_NATIVE) ||
|
||||
appType?.value?.createType === AppCreateType.API
|
||||
"
|
||||
*ngIf="appType?.value?.createType === AppCreateType.OIDC || appType?.value?.createType === AppCreateType.API"
|
||||
[stepControl]="secondFormGroup"
|
||||
[editable]="true"
|
||||
>
|
||||
@ -93,9 +89,11 @@
|
||||
</div>
|
||||
</form>
|
||||
</mat-step>
|
||||
|
||||
<!-- show redirect step only for OIDC apps -->
|
||||
<mat-step *ngIf="appType?.value?.createType === AppCreateType.OIDC" [editable]="true">
|
||||
<mat-step
|
||||
*ngIf="appType?.value?.createType === AppCreateType.OIDC && authMethod?.value !== 'DEVICECODE'"
|
||||
[editable]="true"
|
||||
>
|
||||
<ng-template matStepLabel>{{ 'APP.OIDC.REDIRECTSECTION' | translate }}</ng-template>
|
||||
|
||||
<p class="step-title">{{ 'APP.OIDC.REDIRECTTITLE' | translate }}</p>
|
||||
@ -431,7 +429,13 @@
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div class="content" *ngIf="formappType?.value?.createType === AppCreateType.OIDC">
|
||||
<div
|
||||
class="content"
|
||||
*ngIf="
|
||||
formappType?.value?.createType === AppCreateType.OIDC &&
|
||||
!(oidcAppRequest.toObject().appType === OIDCAppType.OIDC_APP_TYPE_NATIVE && grantTypesListContainsOnlyDeviceCode)
|
||||
"
|
||||
>
|
||||
<div class="formfield full-width">
|
||||
<cnsl-redirect-uris
|
||||
class="redirect-section"
|
||||
|
@ -32,6 +32,7 @@ import { AppSecretDialogComponent } from '../app-secret-dialog/app-secret-dialog
|
||||
import {
|
||||
BASIC_AUTH_METHOD,
|
||||
CODE_METHOD,
|
||||
DEVICE_CODE_METHOD,
|
||||
getPartialConfigFromAuthMethod,
|
||||
IMPLICIT_METHOD,
|
||||
PKCE_METHOD,
|
||||
@ -112,6 +113,7 @@ export class AppCreateComponent implements OnInit, OnDestroy {
|
||||
{ type: OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE, checked: true, disabled: false },
|
||||
{ type: OIDCGrantType.OIDC_GRANT_TYPE_IMPLICIT, checked: false, disabled: true },
|
||||
{ type: OIDCGrantType.OIDC_GRANT_TYPE_REFRESH_TOKEN, checked: false, disabled: true },
|
||||
{ type: OIDCGrantType.OIDC_GRANT_TYPE_DEVICE_CODE, checked: false, disabled: true },
|
||||
];
|
||||
|
||||
public readonly separatorKeysCodes: number[] = [ENTER, COMMA, SPACE];
|
||||
@ -163,7 +165,7 @@ export class AppCreateComponent implements OnInit, OnDestroy {
|
||||
|
||||
switch (this.appType?.value.oidcAppType) {
|
||||
case OIDCAppType.OIDC_APP_TYPE_NATIVE:
|
||||
this.authMethods = [PKCE_METHOD];
|
||||
this.authMethods = [PKCE_METHOD, DEVICE_CODE_METHOD];
|
||||
|
||||
// automatically set to PKCE and skip step
|
||||
this.oidcAppRequest.setResponseTypesList([OIDCResponseType.OIDC_RESPONSE_TYPE_CODE]);
|
||||
@ -473,6 +475,13 @@ export class AppCreateComponent implements OnInit, OnDestroy {
|
||||
return this.form.get('grantTypesList');
|
||||
}
|
||||
|
||||
get grantTypesListContainsOnlyDeviceCode(): boolean {
|
||||
return (
|
||||
this.oidcAppRequest.toObject().grantTypesList.length === 1 &&
|
||||
this.oidcAppRequest.toObject().grantTypesList[0] === OIDCGrantType.OIDC_GRANT_TYPE_DEVICE_CODE
|
||||
);
|
||||
}
|
||||
|
||||
get formappType(): AbstractControl | null {
|
||||
return this.form.get('appType');
|
||||
}
|
||||
@ -480,9 +489,6 @@ export class AppCreateComponent implements OnInit, OnDestroy {
|
||||
get formMetadataUrl(): AbstractControl | null {
|
||||
return this.form.get('metadataUrl');
|
||||
}
|
||||
// get formapplicationType(): AbstractControl | null {
|
||||
// return this.form.get('applicationType');
|
||||
// }
|
||||
|
||||
get authMethodType(): AbstractControl | null {
|
||||
return this.form.get('authMethodType');
|
||||
|
@ -46,6 +46,7 @@ import {
|
||||
BASIC_AUTH_METHOD,
|
||||
CODE_METHOD,
|
||||
CUSTOM_METHOD,
|
||||
DEVICE_CODE_METHOD,
|
||||
getAuthMethodFromPartialConfig,
|
||||
getPartialConfigFromAuthMethod,
|
||||
IMPLICIT_METHOD,
|
||||
@ -89,6 +90,7 @@ export class AppDetailComponent implements OnInit, OnDestroy {
|
||||
public oidcGrantTypes: OIDCGrantType[] = [
|
||||
OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE,
|
||||
OIDCGrantType.OIDC_GRANT_TYPE_IMPLICIT,
|
||||
OIDCGrantType.OIDC_GRANT_TYPE_DEVICE_CODE,
|
||||
OIDCGrantType.OIDC_GRANT_TYPE_REFRESH_TOKEN,
|
||||
];
|
||||
public oidcAppTypes: OIDCAppType[] = [
|
||||
@ -274,6 +276,16 @@ export class AppDetailComponent implements OnInit, OnDestroy {
|
||||
if (this.app.oidcConfig) {
|
||||
this.getAuthMethodOptions('OIDC');
|
||||
|
||||
if (
|
||||
this.app.oidcConfig.grantTypesList.length === 1 &&
|
||||
this.app.oidcConfig.grantTypesList[0] === OIDCGrantType.OIDC_GRANT_TYPE_DEVICE_CODE
|
||||
) {
|
||||
this.settingsList = [
|
||||
{ id: 'configuration', i18nKey: 'APP.CONFIGURATION' },
|
||||
{ id: 'token', i18nKey: 'APP.TOKEN' },
|
||||
{ id: 'urls', i18nKey: 'APP.URLS' },
|
||||
];
|
||||
} else {
|
||||
this.settingsList = [
|
||||
{ id: 'configuration', i18nKey: 'APP.CONFIGURATION' },
|
||||
{ id: 'token', i18nKey: 'APP.TOKEN' },
|
||||
@ -281,6 +293,7 @@ export class AppDetailComponent implements OnInit, OnDestroy {
|
||||
{ id: 'additional-origins', i18nKey: 'APP.ADDITIONALORIGINS' },
|
||||
{ id: 'urls', i18nKey: 'APP.URLS' },
|
||||
];
|
||||
}
|
||||
|
||||
this.initialAuthMethod = this.authMethodFromPartialConfig({ oidc: this.app.oidcConfig });
|
||||
this.currentAuthMethod = this.initialAuthMethod;
|
||||
@ -381,7 +394,7 @@ export class AppDetailComponent implements OnInit, OnDestroy {
|
||||
if (type === 'OIDC') {
|
||||
switch (this.app?.oidcConfig?.appType) {
|
||||
case OIDCAppType.OIDC_APP_TYPE_NATIVE:
|
||||
this.authMethods = [PKCE_METHOD, CUSTOM_METHOD];
|
||||
this.authMethods = [PKCE_METHOD, DEVICE_CODE_METHOD, CUSTOM_METHOD];
|
||||
break;
|
||||
case OIDCAppType.OIDC_APP_TYPE_WEB:
|
||||
this.authMethods = [PKCE_METHOD, CODE_METHOD, PK_JWT_METHOD, POST_METHOD];
|
||||
|
@ -16,10 +16,11 @@ export const CODE_METHOD: RadioItemAuthType = {
|
||||
prefix: 'CODE',
|
||||
background: 'linear-gradient(40deg, rgb(25 105 143) 30%, rgb(23 95 129))',
|
||||
responseType: OIDCResponseType.OIDC_RESPONSE_TYPE_CODE,
|
||||
grantType: OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE,
|
||||
grantType: [OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE],
|
||||
authMethod: OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_BASIC,
|
||||
recommended: false,
|
||||
};
|
||||
|
||||
export const PKCE_METHOD: RadioItemAuthType = {
|
||||
key: 'PKCE',
|
||||
titleI18nKey: 'APP.AUTHMETHODS.PKCE.TITLE',
|
||||
@ -28,10 +29,11 @@ export const PKCE_METHOD: RadioItemAuthType = {
|
||||
prefix: 'PKCE',
|
||||
background: 'linear-gradient(40deg, #059669 30%, #047857)',
|
||||
responseType: OIDCResponseType.OIDC_RESPONSE_TYPE_CODE,
|
||||
grantType: OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE,
|
||||
grantType: [OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE],
|
||||
authMethod: OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_NONE,
|
||||
recommended: true,
|
||||
};
|
||||
|
||||
export const POST_METHOD: RadioItemAuthType = {
|
||||
key: 'POST',
|
||||
titleI18nKey: 'APP.AUTHMETHODS.POST.TITLE',
|
||||
@ -40,10 +42,11 @@ export const POST_METHOD: RadioItemAuthType = {
|
||||
prefix: 'POST',
|
||||
background: 'linear-gradient(40deg, #c53b3b 30%, rgb(169 51 51))',
|
||||
responseType: OIDCResponseType.OIDC_RESPONSE_TYPE_CODE,
|
||||
grantType: OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE,
|
||||
grantType: [OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE],
|
||||
authMethod: OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_POST,
|
||||
notRecommended: true,
|
||||
};
|
||||
|
||||
export const PK_JWT_METHOD: RadioItemAuthType = {
|
||||
key: 'PK_JWT',
|
||||
titleI18nKey: 'APP.AUTHMETHODS.PK_JWT.TITLE',
|
||||
@ -52,11 +55,12 @@ export const PK_JWT_METHOD: RadioItemAuthType = {
|
||||
prefix: 'JWT',
|
||||
background: 'linear-gradient(40deg, rgb(70 77 145) 30%, rgb(58 65 124))',
|
||||
responseType: OIDCResponseType.OIDC_RESPONSE_TYPE_CODE,
|
||||
grantType: OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE,
|
||||
grantType: [OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE],
|
||||
authMethod: OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT,
|
||||
apiAuthMethod: APIAuthMethodType.API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT,
|
||||
// recommended: true,
|
||||
};
|
||||
|
||||
export const BASIC_AUTH_METHOD: RadioItemAuthType = {
|
||||
key: 'BASIC',
|
||||
titleI18nKey: 'APP.AUTHMETHODS.BASIC.TITLE',
|
||||
@ -65,7 +69,7 @@ export const BASIC_AUTH_METHOD: RadioItemAuthType = {
|
||||
prefix: 'BASIC',
|
||||
background: 'linear-gradient(40deg, #c53b3b 30%, rgb(169 51 51))',
|
||||
responseType: OIDCResponseType.OIDC_RESPONSE_TYPE_CODE,
|
||||
grantType: OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE,
|
||||
grantType: [OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE],
|
||||
authMethod: OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_POST,
|
||||
apiAuthMethod: APIAuthMethodType.API_AUTH_METHOD_TYPE_BASIC,
|
||||
};
|
||||
@ -78,11 +82,24 @@ export const IMPLICIT_METHOD: RadioItemAuthType = {
|
||||
prefix: 'IMP',
|
||||
background: 'linear-gradient(40deg, #c53b3b 30%, rgb(169 51 51))',
|
||||
responseType: OIDCResponseType.OIDC_RESPONSE_TYPE_ID_TOKEN,
|
||||
grantType: OIDCGrantType.OIDC_GRANT_TYPE_IMPLICIT,
|
||||
grantType: [OIDCGrantType.OIDC_GRANT_TYPE_IMPLICIT],
|
||||
authMethod: OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_NONE,
|
||||
notRecommended: true,
|
||||
};
|
||||
|
||||
export const DEVICE_CODE_METHOD: RadioItemAuthType = {
|
||||
key: 'DEVICECODE',
|
||||
titleI18nKey: 'APP.AUTHMETHODS.DEVICECODE.TITLE',
|
||||
descI18nKey: 'APP.AUTHMETHODS.DEVICECODE.DESCRIPTION',
|
||||
disabled: false,
|
||||
prefix: 'DEVICECODE',
|
||||
background: 'linear-gradient(40deg, rgb(56 189 248) 30%, rgb(14 165 233))',
|
||||
responseType: OIDCResponseType.OIDC_RESPONSE_TYPE_CODE,
|
||||
grantType: [OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE, OIDCGrantType.OIDC_GRANT_TYPE_DEVICE_CODE],
|
||||
authMethod: OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_BASIC,
|
||||
recommended: false,
|
||||
};
|
||||
|
||||
export const CUSTOM_METHOD: RadioItemAuthType = {
|
||||
key: 'CUSTOM',
|
||||
titleI18nKey: 'APP.AUTHMETHODS.CUSTOM.TITLE',
|
||||
@ -112,6 +129,15 @@ export function getPartialConfigFromAuthMethod(authMethod: string):
|
||||
},
|
||||
};
|
||||
return config;
|
||||
case DEVICE_CODE_METHOD.key:
|
||||
config = {
|
||||
oidc: {
|
||||
responseTypesList: [OIDCResponseType.OIDC_RESPONSE_TYPE_CODE],
|
||||
grantTypesList: [OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE, OIDCGrantType.OIDC_GRANT_TYPE_DEVICE_CODE],
|
||||
authMethodType: OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_NONE,
|
||||
},
|
||||
};
|
||||
return config;
|
||||
case PKCE_METHOD.key:
|
||||
config = {
|
||||
oidc: {
|
||||
@ -211,6 +237,38 @@ export function getAuthMethodFromPartialConfig(config: {
|
||||
OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_POST,
|
||||
]);
|
||||
|
||||
const deviceCode = JSON.stringify([
|
||||
[OIDCResponseType.OIDC_RESPONSE_TYPE_CODE],
|
||||
[OIDCGrantType.OIDC_GRANT_TYPE_DEVICE_CODE],
|
||||
OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_NONE,
|
||||
]);
|
||||
|
||||
const deviceCodeWithCode = JSON.stringify([
|
||||
[OIDCResponseType.OIDC_RESPONSE_TYPE_CODE],
|
||||
[
|
||||
OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE,
|
||||
OIDCGrantType.OIDC_GRANT_TYPE_DEVICE_CODE,
|
||||
// OIDCGrantType.OIDC_GRANT_TYPE_REFRESH_TOKEN,
|
||||
],
|
||||
OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_NONE,
|
||||
]);
|
||||
|
||||
const deviceCodeWithCodeAndRefresh = JSON.stringify([
|
||||
[OIDCResponseType.OIDC_RESPONSE_TYPE_CODE],
|
||||
[
|
||||
OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE,
|
||||
OIDCGrantType.OIDC_GRANT_TYPE_DEVICE_CODE,
|
||||
OIDCGrantType.OIDC_GRANT_TYPE_REFRESH_TOKEN,
|
||||
],
|
||||
OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_NONE,
|
||||
]);
|
||||
|
||||
const deviceCodeWithRefresh = JSON.stringify([
|
||||
[OIDCResponseType.OIDC_RESPONSE_TYPE_CODE],
|
||||
[OIDCGrantType.OIDC_GRANT_TYPE_DEVICE_CODE, OIDCGrantType.OIDC_GRANT_TYPE_REFRESH_TOKEN],
|
||||
OIDCAuthMethodType.OIDC_AUTH_METHOD_TYPE_NONE,
|
||||
]);
|
||||
|
||||
const pkjwt = JSON.stringify([
|
||||
[OIDCResponseType.OIDC_RESPONSE_TYPE_CODE],
|
||||
[OIDCGrantType.OIDC_GRANT_TYPE_AUTHORIZATION_CODE],
|
||||
@ -245,6 +303,15 @@ export function getAuthMethodFromPartialConfig(config: {
|
||||
case postWithRefresh:
|
||||
return POST_METHOD.key;
|
||||
|
||||
case deviceCode:
|
||||
return DEVICE_CODE_METHOD.key;
|
||||
case deviceCodeWithCode:
|
||||
return DEVICE_CODE_METHOD.key;
|
||||
case deviceCodeWithRefresh:
|
||||
return DEVICE_CODE_METHOD.key;
|
||||
case deviceCodeWithCodeAndRefresh:
|
||||
return DEVICE_CODE_METHOD.key;
|
||||
|
||||
case pkjwt:
|
||||
return PK_JWT_METHOD.key;
|
||||
case pkjwtWithRefresh:
|
||||
|
@ -1965,7 +1965,8 @@
|
||||
"GRANT": {
|
||||
"0": "Authorization Code",
|
||||
"1": "Implicit",
|
||||
"2": "Refresh Token"
|
||||
"2": "Refresh Token",
|
||||
"3": "Device Code"
|
||||
},
|
||||
"AUTHMETHOD": {
|
||||
"0": "Basic",
|
||||
@ -2056,6 +2057,10 @@
|
||||
"TITLE": "Implicit",
|
||||
"DESCRIPTION": "Erhalte die Token direkt vom authorize Endpoint"
|
||||
},
|
||||
"DEVICECODE": {
|
||||
"TITLE": "Device Code",
|
||||
"DESCRIPTION": "Autorisieren Sie das Gerät auf einem Computer oder Smartphone."
|
||||
},
|
||||
"CUSTOM": {
|
||||
"TITLE": "Custom",
|
||||
"DESCRIPTION": "Deine Konfiguration entspricht keiner anderen Option."
|
||||
|
@ -1962,7 +1962,8 @@
|
||||
"GRANT": {
|
||||
"0": "Authorization Code",
|
||||
"1": "Implicit",
|
||||
"2": "Refresh Token"
|
||||
"2": "Refresh Token",
|
||||
"3": "Device Code"
|
||||
},
|
||||
"AUTHMETHOD": {
|
||||
"0": "Basic",
|
||||
@ -2053,6 +2054,10 @@
|
||||
"TITLE": "Implicit",
|
||||
"DESCRIPTION": "Get the tokens directly from the authorization endpoint"
|
||||
},
|
||||
"DEVICECODE": {
|
||||
"TITLE": "Device Code",
|
||||
"DESCRIPTION": "Authorize the device on a computer or smartphone."
|
||||
},
|
||||
"CUSTOM": {
|
||||
"TITLE": "Custom",
|
||||
"DESCRIPTION": "Your setting doesn't correspond to any other option."
|
||||
|
@ -1962,7 +1962,8 @@
|
||||
"GRANT": {
|
||||
"0": "Código de autorización",
|
||||
"1": "Implícito",
|
||||
"2": "Token de refresco"
|
||||
"2": "Token de refresco",
|
||||
"3": "Device Code"
|
||||
},
|
||||
"AUTHMETHOD": {
|
||||
"0": "Básico",
|
||||
@ -2053,6 +2054,10 @@
|
||||
"TITLE": "Implícita",
|
||||
"DESCRIPTION": "Obtén los tokens directamente del endpoint de autorización"
|
||||
},
|
||||
"DEVICECODE": {
|
||||
"TITLE": "Device Code",
|
||||
"DESCRIPTION": "Autorizar el dispositivo en una computadora o teléfono."
|
||||
},
|
||||
"CUSTOM": {
|
||||
"TITLE": "Personalizada",
|
||||
"DESCRIPTION": "Tu configuración no se corresponde con alguna de las otras opciones."
|
||||
|
@ -1966,7 +1966,8 @@
|
||||
"GRANT": {
|
||||
"0": "Code d'autorisation",
|
||||
"1": "Implicite",
|
||||
"2": "Rafraîchir le jeton"
|
||||
"2": "Rafraîchir le jeton",
|
||||
"3": "Device Code"
|
||||
},
|
||||
"AUTHMETHOD": {
|
||||
"0": "Basic",
|
||||
@ -2045,6 +2046,10 @@
|
||||
"TITLE": "Implicite",
|
||||
"DESCRIPTION": "Obtenir les jetons directement à partir du point final d'autorisation"
|
||||
},
|
||||
"DEVICECODE": {
|
||||
"TITLE": "Device Code",
|
||||
"DESCRIPTION": "Autoriser l'appareil sur un ordinateur ou un smartphone."
|
||||
},
|
||||
"CUSTOM": {
|
||||
"TITLE": "Personnalisé",
|
||||
"DESCRIPTION": "Votre paramètre ne correspond à aucune autre option."
|
||||
|
@ -1967,7 +1967,8 @@
|
||||
"GRANT": {
|
||||
"0": "Authorization Code",
|
||||
"1": "Implicit",
|
||||
"2": "Refresh Token"
|
||||
"2": "Refresh Token",
|
||||
"3": "Device Code"
|
||||
},
|
||||
"AUTHMETHOD": {
|
||||
"0": "Basic",
|
||||
@ -2058,6 +2059,10 @@
|
||||
"TITLE": "Implicit",
|
||||
"DESCRIPTION": "Ottenere i token direttamente dall'endpoint di autorizzazione"
|
||||
},
|
||||
"DEVICECODE": {
|
||||
"TITLE": "Device Code",
|
||||
"DESCRIPTION": "Autorizza il dispositivo su un computer o uno smartphone."
|
||||
},
|
||||
"CUSTOM": {
|
||||
"TITLE": "Custom",
|
||||
"DESCRIPTION": "La tua impostazione non corrisponde a nessun'altra opzione."
|
||||
|
@ -1957,7 +1957,8 @@
|
||||
"GRANT": {
|
||||
"0": "Authorization Code",
|
||||
"1": "Implicit",
|
||||
"2": "Refresh Token"
|
||||
"2": "Refresh Token",
|
||||
"3": "Device Code"
|
||||
},
|
||||
"AUTHMETHOD": {
|
||||
"0": "Basic",
|
||||
@ -2048,6 +2049,10 @@
|
||||
"TITLE": "Implicit",
|
||||
"DESCRIPTION": "認証エンドポイントから直接トークンを取得します。"
|
||||
},
|
||||
"DEVICECODE": {
|
||||
"TITLE": "Device Code",
|
||||
"DESCRIPTION": "コンピューターまたはスマートフォンでデバイスを認証します。"
|
||||
},
|
||||
"CUSTOM": {
|
||||
"TITLE": "Custom",
|
||||
"DESCRIPTION": "設定は他のオプションに対応していません。"
|
||||
|
@ -1966,7 +1966,8 @@
|
||||
"GRANT": {
|
||||
"0": "Kod autoryzacyjny",
|
||||
"1": "Implicite",
|
||||
"2": "Token odświeżający"
|
||||
"2": "Token odświeżający",
|
||||
"3": "Device Code"
|
||||
},
|
||||
"AUTHMETHOD": {
|
||||
"0": "Podstawowy",
|
||||
@ -2057,6 +2058,10 @@
|
||||
"TITLE": "Implicit",
|
||||
"DESCRIPTION": "Pobierz tokeny bezpośrednio z punktu autoryzacyjnego"
|
||||
},
|
||||
"DEVICECODE": {
|
||||
"TITLE": "Device Code",
|
||||
"DESCRIPTION": "Autoryzuj urządzenie na komputerze lub smartfonie."
|
||||
},
|
||||
"CUSTOM": {
|
||||
"TITLE": "Niestandardowy",
|
||||
"DESCRIPTION": "Twoje ustawienie nie odpowiada żadnej innej opcji."
|
||||
|
@ -1965,7 +1965,8 @@
|
||||
"GRANT": {
|
||||
"0": "Authorization Code",
|
||||
"1": "Implicit",
|
||||
"2": "Refresh Token"
|
||||
"2": "Refresh Token",
|
||||
"3": "Device Code"
|
||||
},
|
||||
"AUTHMETHOD": {
|
||||
"0": "Basic",
|
||||
@ -2044,6 +2045,10 @@
|
||||
"TITLE": "Implicit",
|
||||
"DESCRIPTION": "直接从授权端点获取令牌"
|
||||
},
|
||||
"DEVICECODE": {
|
||||
"TITLE": "Device Code",
|
||||
"DESCRIPTION": "在计算机或智能手机上授权设备。"
|
||||
},
|
||||
"CUSTOM": {
|
||||
"TITLE": "Custom",
|
||||
"DESCRIPTION": "您的设置与任何其他选项都不对应。"
|
||||
|
@ -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).
|
@ -4,7 +4,7 @@ services:
|
||||
traefik:
|
||||
networks:
|
||||
- 'zitadel'
|
||||
image: "traefik:v2.7"
|
||||
image: "traefik:v2.10.1"
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
|
@ -1,3 +1,8 @@
|
||||
log:
|
||||
level: DEBUG
|
||||
|
||||
accessLog: {}
|
||||
|
||||
entrypoints:
|
||||
web:
|
||||
address: ":80"
|
||||
|
@ -23,3 +23,8 @@ Database:
|
||||
RootCert: "/crdb-certs/ca.crt"
|
||||
Cert: "/crdb-certs/client.root.crt"
|
||||
Key: "/crdb-certs/client.root.key"
|
||||
|
||||
LogStore:
|
||||
Access:
|
||||
Stdout:
|
||||
Enabled: true
|
||||
|
@ -70,3 +70,8 @@ This is the IAM admin users login according to your configuration in the [exampl
|
||||
- **password**: *RootPassword1!*
|
||||
|
||||
Read more about [the login process](/guides/integrate/login-users).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
You can connect to cockroach like this: `docker exec -it loadbalancing-example-my-cockroach-db-1 cockroach sql --host my-cockroach-db --certs-dir /cockroach/certs/`
|
||||
For example, to show all login names: `docker exec -it loadbalancing-example-my-cockroach-db-1 cockroach sql --database zitadel --host my-cockroach-db --certs-dir /cockroach/certs/ --execute "select * from projections.login_names2"`
|
||||
|
@ -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,20 @@ module.exports = {
|
||||
sidebarOptions: {
|
||||
groupPathsBy: "tag",
|
||||
},
|
||||
},
|
||||
session: {
|
||||
specPath: ".artifacts/openapi/zitadel/session/v2alpha/session_service.swagger.json",
|
||||
outputDir: "docs/apis/session_service",
|
||||
sidebarOptions: {
|
||||
groupPathsBy: "tag",
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
specPath: ".artifacts/openapi/zitadel/settings/v2alpha/settings_service.swagger.json",
|
||||
outputDir: "docs/apis/settings_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,34 @@ 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: "Settings Lifecycle (Alpha)",
|
||||
link: {
|
||||
type: "generated-index",
|
||||
title: "Settings Service API (Alpha)",
|
||||
slug: "/apis/settings_service",
|
||||
description:
|
||||
"This API is intended to manage settings in a ZITADEL instance.\n"+
|
||||
"\n"+
|
||||
"This project is in alpha state. It can AND will continue breaking until the services provide the same functionality as the current login.",
|
||||
},
|
||||
items: require("./docs/apis/settings_service/sidebar.js"),
|
||||
},
|
||||
{
|
||||
type: "category",
|
||||
label: "Assets",
|
||||
|
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,7 +2,8 @@ import { Apps, ensureProjectExists, ensureProjectResourceDoesntExist } from '../
|
||||
import { Context } from 'support/commands';
|
||||
|
||||
const testProjectName = 'e2eprojectapplication';
|
||||
const testAppName = 'e2eappundertest';
|
||||
const testPKCEAppName = 'e2eapppkcetest';
|
||||
const testDEVICECODEAppName = 'e2eappdevicecodetest';
|
||||
|
||||
describe('applications', () => {
|
||||
beforeEach(() => {
|
||||
@ -17,15 +18,15 @@ describe('applications', () => {
|
||||
beforeEach(`ensure it doesn't exist already`, () => {
|
||||
cy.get<Context>('@ctx').then((ctx) => {
|
||||
cy.get<string>('@projectId').then((projectId) => {
|
||||
ensureProjectResourceDoesntExist(ctx.api, projectId, Apps, testAppName);
|
||||
ensureProjectResourceDoesntExist(ctx.api, projectId, Apps, testPKCEAppName);
|
||||
cy.visit(`/projects/${projectId}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('add app', () => {
|
||||
it('add web pkce app', () => {
|
||||
cy.get('[data-e2e="app-card-add"]').should('be.visible').click();
|
||||
cy.get('[formcontrolname="name"]').focus().type(testAppName);
|
||||
cy.get('[formcontrolname="name"]').focus().type(testPKCEAppName);
|
||||
cy.get('[for="WEB"]').click();
|
||||
cy.get('[data-e2e="continue-button-nameandtype"]').click();
|
||||
cy.get('[for="PKCE"]').should('be.visible').click();
|
||||
@ -43,6 +44,33 @@ describe('applications', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('add native device code app', () => {
|
||||
beforeEach(`ensure it doesn't exist already`, () => {
|
||||
cy.get<Context>('@ctx').then((ctx) => {
|
||||
cy.get<string>('@projectId').then((projectId) => {
|
||||
ensureProjectResourceDoesntExist(ctx.api, projectId, Apps, testDEVICECODEAppName);
|
||||
cy.visit(`/projects/${projectId}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('add device code app', () => {
|
||||
cy.get('[data-e2e="app-card-add"]').should('be.visible').click();
|
||||
cy.get('[formcontrolname="name"]').focus().type(testDEVICECODEAppName);
|
||||
cy.get('[for="N"]').click();
|
||||
cy.get('[data-e2e="continue-button-nameandtype"]').click();
|
||||
cy.get('[for="DEVICECODE"]').should('be.visible').click();
|
||||
cy.get('[data-e2e="continue-button-authmethod"]').click();
|
||||
cy.get('[data-e2e="create-button"]').click();
|
||||
cy.get('[id*=overlay]').should('exist');
|
||||
cy.shouldConfirmSuccess();
|
||||
const expectClientId = new RegExp(`^.*[0-9]+\\@${testProjectName}.*$`);
|
||||
cy.get('[data-e2e="client-id-copy"]').click();
|
||||
cy.contains('[data-e2e="client-id"]', expectClientId);
|
||||
cy.clipboardMatches(expectClientId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit app', () => {
|
||||
it('should configure an application to enable dev mode');
|
||||
it('should configure an application to put user roles and info inside id token');
|
||||
|
@ -107,14 +107,9 @@ describe('quotas', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
expectCookieDoesntExist();
|
||||
const expiresMax = new Date();
|
||||
expiresMax.setMinutes(expiresMax.getMinutes() + 2);
|
||||
cy.getCookie('zitadel.quota.limiting').then((cookie) => {
|
||||
expect(cookie.value).to.equal('false');
|
||||
const cookieExpiry = new Date();
|
||||
cookieExpiry.setTime(cookie.expiry * 1000);
|
||||
expect(cookieExpiry).to.be.within(start, expiresMax);
|
||||
});
|
||||
cy.request({
|
||||
url: urls[0],
|
||||
method: 'GET',
|
||||
@ -127,12 +122,16 @@ describe('quotas', () => {
|
||||
});
|
||||
cy.getCookie('zitadel.quota.limiting').then((cookie) => {
|
||||
expect(cookie.value).to.equal('true');
|
||||
const cookieExpiry = new Date();
|
||||
cookieExpiry.setTime(cookie.expiry * 1000);
|
||||
expect(cookieExpiry).to.be.within(start, expiresMax);
|
||||
});
|
||||
createHumanUser(ctx.api, testUserName, false).then((res) => {
|
||||
expect(res.status).to.equal(429);
|
||||
});
|
||||
ensureQuotaIsRemoved(ctx, Unit.AuthenticatedRequests);
|
||||
createHumanUser(ctx.api, testUserName);
|
||||
expectCookieDoesntExist();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -301,3 +300,9 @@ describe('quotas', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function expectCookieDoesntExist() {
|
||||
cy.getCookie('zitadel.quota.limiting').then((cookie) => {
|
||||
expect(cookie).to.be.null;
|
||||
});
|
||||
}
|
||||
|
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
|
||||
|
@ -16,6 +16,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"
|
||||
@ -33,6 +34,9 @@ type API struct {
|
||||
http1HostName string
|
||||
grpcGateway *server.Gateway
|
||||
healthServer *health.Server
|
||||
cookieHandler *http_util.CookieHandler
|
||||
cookieConfig *http_mw.AccessConfig
|
||||
queries *query.Queries
|
||||
}
|
||||
|
||||
type healthCheck interface {
|
||||
@ -48,6 +52,8 @@ func New(
|
||||
authZ internal_authz.Config,
|
||||
tlsConfig *tls.Config, http2HostName, http1HostName string,
|
||||
accessSvc *logstore.Service,
|
||||
cookieHandler *http_util.CookieHandler,
|
||||
cookieConfig *http_mw.AccessConfig,
|
||||
) (_ *API, err error) {
|
||||
api := &API{
|
||||
port: port,
|
||||
@ -55,10 +61,13 @@ func New(
|
||||
health: queries,
|
||||
router: router,
|
||||
http1HostName: http1HostName,
|
||||
cookieConfig: cookieConfig,
|
||||
cookieHandler: cookieHandler,
|
||||
queries: queries,
|
||||
}
|
||||
|
||||
api.grpcServer = server.CreateServer(api.verifier, authZ, queries, http2HostName, tlsConfig, accessSvc)
|
||||
api.grpcGateway, err = server.CreateGateway(ctx, port, http1HostName)
|
||||
api.grpcGateway, err = server.CreateGateway(ctx, port, http1HostName, cookieHandler, cookieConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -76,7 +85,15 @@ func New(
|
||||
// used for v1 api (system, admin, mgmt, auth)
|
||||
func (a *API) RegisterServer(ctx context.Context, grpcServer server.WithGatewayPrefix) error {
|
||||
grpcServer.RegisterServer(a.grpcServer)
|
||||
handler, prefix, err := server.CreateGatewayWithPrefix(ctx, grpcServer, a.port, a.http1HostName)
|
||||
handler, prefix, err := server.CreateGatewayWithPrefix(
|
||||
ctx,
|
||||
grpcServer,
|
||||
a.port,
|
||||
a.http1HostName,
|
||||
a.cookieHandler,
|
||||
a.cookieConfig,
|
||||
a.queries,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -167,6 +184,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"
|
||||
@ -18,6 +20,7 @@ import (
|
||||
|
||||
const (
|
||||
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)
|
||||
}
|
@ -1,9 +1,13 @@
|
||||
package object
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha"
|
||||
)
|
||||
|
||||
@ -17,3 +21,31 @@ 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
|
||||
}
|
||||
|
||||
func ResourceOwnerFromReq(ctx context.Context, req *object.RequestContext) string {
|
||||
if req.GetInstance() {
|
||||
return authz.GetInstance(ctx).InstanceID()
|
||||
}
|
||||
if req.GetOrgId() != "" {
|
||||
return req.GetOrgId()
|
||||
}
|
||||
return authz.GetCtxData(ctx).OrgID
|
||||
}
|
||||
|
@ -12,10 +12,13 @@ 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"
|
||||
http_utils "github.com/zitadel/zitadel/internal/api/http"
|
||||
http_mw "github.com/zitadel/zitadel/internal/api/http/middleware"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -38,6 +41,7 @@ var (
|
||||
runtime.WithMarshalerOption(runtime.MIMEWildcard, jsonMarshaler),
|
||||
runtime.WithIncomingHeaderMatcher(headerMatcher),
|
||||
runtime.WithOutgoingHeaderMatcher(runtime.DefaultHeaderMatcher),
|
||||
runtime.WithForwardResponseOption(responseForwarder),
|
||||
}
|
||||
|
||||
headerMatcher = runtime.HeaderMatcherFunc(
|
||||
@ -50,21 +54,45 @@ 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 {
|
||||
mux *runtime.ServeMux
|
||||
http1HostName string
|
||||
connection *grpc.ClientConn
|
||||
cookieHandler *http_utils.CookieHandler
|
||||
cookieConfig *http_mw.AccessConfig
|
||||
queries *query.Queries
|
||||
}
|
||||
|
||||
func (g *Gateway) Handler() http.Handler {
|
||||
return addInterceptors(g.mux, g.http1HostName)
|
||||
return addInterceptors(g.mux, g.http1HostName, g.cookieHandler, g.cookieConfig, g.queries)
|
||||
}
|
||||
|
||||
type CustomHTTPResponse interface {
|
||||
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) {
|
||||
func CreateGatewayWithPrefix(
|
||||
ctx context.Context,
|
||||
g WithGatewayPrefix,
|
||||
port uint16,
|
||||
http1HostName string,
|
||||
cookieHandler *http_utils.CookieHandler,
|
||||
cookieConfig *http_mw.AccessConfig,
|
||||
queries *query.Queries,
|
||||
) (http.Handler, string, error) {
|
||||
runtimeMux := runtime.NewServeMux(serveMuxOptions...)
|
||||
opts := []grpc.DialOption{
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
@ -78,10 +106,10 @@ func CreateGatewayWithPrefix(ctx context.Context, g WithGatewayPrefix, port uint
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to register grpc gateway: %w", err)
|
||||
}
|
||||
return addInterceptors(runtimeMux, http1HostName), g.GatewayPathPrefix(), nil
|
||||
return addInterceptors(runtimeMux, http1HostName, cookieHandler, cookieConfig, queries), g.GatewayPathPrefix(), nil
|
||||
}
|
||||
|
||||
func CreateGateway(ctx context.Context, port uint16, http1HostName string) (*Gateway, error) {
|
||||
func CreateGateway(ctx context.Context, port uint16, http1HostName string, cookieHandler *http_utils.CookieHandler, cookieConfig *http_mw.AccessConfig) (*Gateway, error) {
|
||||
connection, err := dial(ctx,
|
||||
port,
|
||||
[]grpc.DialOption{
|
||||
@ -96,6 +124,8 @@ func CreateGateway(ctx context.Context, port uint16, http1HostName string) (*Gat
|
||||
mux: runtimeMux,
|
||||
http1HostName: http1HostName,
|
||||
connection: connection,
|
||||
cookieHandler: cookieHandler,
|
||||
cookieConfig: cookieConfig,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -130,12 +160,23 @@ func dial(ctx context.Context, port uint16, opts []grpc.DialOption) (*grpc.Clien
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func addInterceptors(handler http.Handler, http1HostName string) http.Handler {
|
||||
func addInterceptors(
|
||||
handler http.Handler,
|
||||
http1HostName string,
|
||||
cookieHandler *http_utils.CookieHandler,
|
||||
cookieConfig *http_mw.AccessConfig,
|
||||
queries *query.Queries,
|
||||
) http.Handler {
|
||||
handler = http_mw.CallDurationHandler(handler)
|
||||
handler = http1Host(handler, http1HostName)
|
||||
handler = http_mw.CORSInterceptor(handler)
|
||||
handler = http_mw.RobotsTagHandler(handler)
|
||||
handler = http_mw.DefaultTelemetryHandler(handler)
|
||||
return http_mw.DefaultMetricsHandler(handler)
|
||||
// For some non-obvious reason, the exhaustedCookieInterceptor sends the SetCookie header
|
||||
// only if it follows the http_mw.DefaultTelemetryHandler
|
||||
handler = exhaustedCookieInterceptor(handler, cookieHandler, cookieConfig, queries)
|
||||
handler = http_mw.DefaultMetricsHandler(handler)
|
||||
return handler
|
||||
}
|
||||
|
||||
func http1Host(next http.Handler, http1HostName string) http.Handler {
|
||||
@ -149,3 +190,38 @@ func http1Host(next http.Handler, http1HostName string) http.Handler {
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func exhaustedCookieInterceptor(
|
||||
next http.Handler,
|
||||
cookieHandler *http_utils.CookieHandler,
|
||||
cookieConfig *http_mw.AccessConfig,
|
||||
queries *query.Queries,
|
||||
) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
next.ServeHTTP(&cookieResponseWriter{
|
||||
ResponseWriter: writer,
|
||||
cookieHandler: cookieHandler,
|
||||
cookieConfig: cookieConfig,
|
||||
request: request,
|
||||
queries: queries,
|
||||
}, request)
|
||||
})
|
||||
}
|
||||
|
||||
type cookieResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
cookieHandler *http_utils.CookieHandler
|
||||
cookieConfig *http_mw.AccessConfig
|
||||
request *http.Request
|
||||
queries *query.Queries
|
||||
}
|
||||
|
||||
func (r *cookieResponseWriter) WriteHeader(status int) {
|
||||
if status >= 200 && status < 300 {
|
||||
http_mw.DeleteExhaustedCookie(r.cookieHandler, r.ResponseWriter, r.request, r.cookieConfig)
|
||||
}
|
||||
if status == http.StatusTooManyRequests {
|
||||
http_mw.SetExhaustedCookie(r.cookieHandler, r.ResponseWriter, r.cookieConfig, r.request)
|
||||
}
|
||||
r.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
@ -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,8 +6,9 @@ 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)
|
||||
@ -16,6 +17,7 @@ type Server struct {
|
||||
session.UnimplementedSessionServiceServer
|
||||
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,
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
57
internal/api/grpc/settings/v2/server.go
Normal file
57
internal/api/grpc/settings/v2/server.go
Normal file
@ -0,0 +1,57 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/assets"
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/api/grpc/server"
|
||||
"github.com/zitadel/zitadel/internal/command"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2alpha"
|
||||
)
|
||||
|
||||
var _ settings.SettingsServiceServer = (*Server)(nil)
|
||||
|
||||
type Server struct {
|
||||
settings.UnimplementedSettingsServiceServer
|
||||
command *command.Commands
|
||||
query *query.Queries
|
||||
assetsAPIDomain func(context.Context) string
|
||||
}
|
||||
|
||||
type Config struct{}
|
||||
|
||||
func CreateServer(
|
||||
command *command.Commands,
|
||||
query *query.Queries,
|
||||
externalSecure bool,
|
||||
) *Server {
|
||||
return &Server{
|
||||
command: command,
|
||||
query: query,
|
||||
assetsAPIDomain: assets.AssetAPI(externalSecure),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) RegisterServer(grpcServer *grpc.Server) {
|
||||
settings.RegisterSettingsServiceServer(grpcServer, s)
|
||||
}
|
||||
|
||||
func (s *Server) AppName() string {
|
||||
return settings.SettingsService_ServiceDesc.ServiceName
|
||||
}
|
||||
|
||||
func (s *Server) MethodPrefix() string {
|
||||
return settings.SettingsService_ServiceDesc.ServiceName
|
||||
}
|
||||
|
||||
func (s *Server) AuthMethods() authz.MethodMapping {
|
||||
return settings.SettingsService_AuthMethods
|
||||
}
|
||||
|
||||
func (s *Server) RegisterGateway() server.RegisterGatewayFunc {
|
||||
return settings.RegisterSettingsServiceHandler
|
||||
}
|
129
internal/api/grpc/settings/v2/settings.go
Normal file
129
internal/api/grpc/settings/v2/settings.go
Normal file
@ -0,0 +1,129 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/api/grpc/object/v2"
|
||||
"github.com/zitadel/zitadel/internal/api/grpc/text"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/settings/v2alpha"
|
||||
)
|
||||
|
||||
func (s *Server) GetLoginSettings(ctx context.Context, req *settings.GetLoginSettingsRequest) (*settings.GetLoginSettingsResponse, error) {
|
||||
current, err := s.query.LoginPolicyByID(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &settings.GetLoginSettingsResponse{
|
||||
Settings: loginSettingsToPb(current),
|
||||
Details: &object_pb.Details{
|
||||
Sequence: current.Sequence,
|
||||
ChangeDate: timestamppb.New(current.ChangeDate),
|
||||
ResourceOwner: current.OrgID,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) GetPasswordComplexitySettings(ctx context.Context, req *settings.GetPasswordComplexitySettingsRequest) (*settings.GetPasswordComplexitySettingsResponse, error) {
|
||||
current, err := s.query.PasswordComplexityPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &settings.GetPasswordComplexitySettingsResponse{
|
||||
Settings: passwordSettingsToPb(current),
|
||||
Details: &object_pb.Details{
|
||||
Sequence: current.Sequence,
|
||||
ChangeDate: timestamppb.New(current.ChangeDate),
|
||||
ResourceOwner: current.ResourceOwner,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) GetBrandingSettings(ctx context.Context, req *settings.GetBrandingSettingsRequest) (*settings.GetBrandingSettingsResponse, error) {
|
||||
current, err := s.query.ActiveLabelPolicyByOrg(ctx, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &settings.GetBrandingSettingsResponse{
|
||||
Settings: brandingSettingsToPb(current, s.assetsAPIDomain(ctx)),
|
||||
Details: &object_pb.Details{
|
||||
Sequence: current.Sequence,
|
||||
ChangeDate: timestamppb.New(current.ChangeDate),
|
||||
ResourceOwner: current.ResourceOwner,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) GetDomainSettings(ctx context.Context, req *settings.GetDomainSettingsRequest) (*settings.GetDomainSettingsResponse, error) {
|
||||
current, err := s.query.DomainPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &settings.GetDomainSettingsResponse{
|
||||
Settings: domainSettingsToPb(current),
|
||||
Details: &object_pb.Details{
|
||||
Sequence: current.Sequence,
|
||||
ChangeDate: timestamppb.New(current.ChangeDate),
|
||||
ResourceOwner: current.ResourceOwner,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) GetLegalAndSupportSettings(ctx context.Context, req *settings.GetLegalAndSupportSettingsRequest) (*settings.GetLegalAndSupportSettingsResponse, error) {
|
||||
current, err := s.query.PrivacyPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &settings.GetLegalAndSupportSettingsResponse{
|
||||
Settings: legalAndSupportSettingsToPb(current),
|
||||
Details: &object_pb.Details{
|
||||
Sequence: current.Sequence,
|
||||
ChangeDate: timestamppb.New(current.ChangeDate),
|
||||
ResourceOwner: current.ResourceOwner,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) GetLockoutSettings(ctx context.Context, req *settings.GetLockoutSettingsRequest) (*settings.GetLockoutSettingsResponse, error) {
|
||||
current, err := s.query.LockoutPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &settings.GetLockoutSettingsResponse{
|
||||
Settings: lockoutSettingsToPb(current),
|
||||
Details: &object_pb.Details{
|
||||
Sequence: current.Sequence,
|
||||
ChangeDate: timestamppb.New(current.ChangeDate),
|
||||
ResourceOwner: current.ResourceOwner,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) GetActiveIdentityProviders(ctx context.Context, req *settings.GetActiveIdentityProvidersRequest) (*settings.GetActiveIdentityProvidersResponse, error) {
|
||||
links, err := s.query.IDPLoginPolicyLinks(ctx, object.ResourceOwnerFromReq(ctx, req.GetCtx()), &query.IDPLoginPolicyLinksSearchQuery{}, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &settings.GetActiveIdentityProvidersResponse{
|
||||
Details: object.ToListDetails(links.SearchResponse),
|
||||
IdentityProviders: identityProvidersToPb(links.Links),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) GetGeneralSettings(ctx context.Context, _ *settings.GetGeneralSettingsRequest) (*settings.GetGeneralSettingsResponse, error) {
|
||||
langs, err := s.query.Languages(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
instance := authz.GetInstance(ctx)
|
||||
return &settings.GetGeneralSettingsResponse{
|
||||
SupportedLanguages: text.LanguageTagsToStrings(langs),
|
||||
DefaultOrgId: instance.DefaultOrganisationID(),
|
||||
DefaultLanguage: instance.DefaultLanguage().String(),
|
||||
}, nil
|
||||
}
|
189
internal/api/grpc/settings/v2/settings_converter.go
Normal file
189
internal/api/grpc/settings/v2/settings_converter.go
Normal file
@ -0,0 +1,189 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"google.golang.org/protobuf/types/known/durationpb"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2alpha"
|
||||
)
|
||||
|
||||
// TODO: ?
|
||||
func loginSettingsToPb(current *query.LoginPolicy) *settings.LoginSettings {
|
||||
multi := make([]settings.MultiFactorType, len(current.MultiFactors))
|
||||
for i, typ := range current.MultiFactors {
|
||||
multi[i] = multiFactorTypeToPb(typ)
|
||||
}
|
||||
second := make([]settings.SecondFactorType, len(current.SecondFactors))
|
||||
for i, typ := range current.SecondFactors {
|
||||
second[i] = secondFactorTypeToPb(typ)
|
||||
}
|
||||
|
||||
return &settings.LoginSettings{
|
||||
AllowUsernamePassword: current.AllowUsernamePassword,
|
||||
AllowRegister: current.AllowRegister,
|
||||
AllowExternalIdp: current.AllowExternalIDPs,
|
||||
ForceMfa: current.ForceMFA,
|
||||
PasskeysType: passkeysTypeToPb(current.PasswordlessType),
|
||||
HidePasswordReset: current.HidePasswordReset,
|
||||
IgnoreUnknownUsernames: current.IgnoreUnknownUsernames,
|
||||
AllowDomainDiscovery: current.AllowDomainDiscovery,
|
||||
DisableLoginWithEmail: current.DisableLoginWithEmail,
|
||||
DisableLoginWithPhone: current.DisableLoginWithPhone,
|
||||
DefaultRedirectUri: current.DefaultRedirectURI,
|
||||
PasswordCheckLifetime: durationpb.New(current.PasswordCheckLifetime),
|
||||
ExternalLoginCheckLifetime: durationpb.New(current.ExternalLoginCheckLifetime),
|
||||
MfaInitSkipLifetime: durationpb.New(current.MFAInitSkipLifetime),
|
||||
SecondFactorCheckLifetime: durationpb.New(current.SecondFactorCheckLifetime),
|
||||
MultiFactorCheckLifetime: durationpb.New(current.MultiFactorCheckLifetime),
|
||||
SecondFactors: second,
|
||||
MultiFactors: multi,
|
||||
ResourceOwnerType: isDefaultToResourceOwnerTypePb(current.IsDefault),
|
||||
}
|
||||
}
|
||||
|
||||
func isDefaultToResourceOwnerTypePb(isDefault bool) settings.ResourceOwnerType {
|
||||
if isDefault {
|
||||
return settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE
|
||||
}
|
||||
return settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_ORG
|
||||
}
|
||||
|
||||
func passkeysTypeToPb(passwordlessType domain.PasswordlessType) settings.PasskeysType {
|
||||
switch passwordlessType {
|
||||
case domain.PasswordlessTypeAllowed:
|
||||
return settings.PasskeysType_PASSKEYS_TYPE_ALLOWED
|
||||
case domain.PasswordlessTypeNotAllowed:
|
||||
return settings.PasskeysType_PASSKEYS_TYPE_NOT_ALLOWED
|
||||
default:
|
||||
return settings.PasskeysType_PASSKEYS_TYPE_NOT_ALLOWED
|
||||
}
|
||||
}
|
||||
|
||||
func secondFactorTypeToPb(secondFactorType domain.SecondFactorType) settings.SecondFactorType {
|
||||
switch secondFactorType {
|
||||
case domain.SecondFactorTypeOTP:
|
||||
return settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP
|
||||
case domain.SecondFactorTypeU2F:
|
||||
return settings.SecondFactorType_SECOND_FACTOR_TYPE_U2F
|
||||
case domain.SecondFactorTypeUnspecified:
|
||||
return settings.SecondFactorType_SECOND_FACTOR_TYPE_UNSPECIFIED
|
||||
default:
|
||||
return settings.SecondFactorType_SECOND_FACTOR_TYPE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func multiFactorTypeToPb(typ domain.MultiFactorType) settings.MultiFactorType {
|
||||
switch typ {
|
||||
case domain.MultiFactorTypeU2FWithPIN:
|
||||
return settings.MultiFactorType_MULTI_FACTOR_TYPE_U2F_WITH_VERIFICATION
|
||||
case domain.MultiFactorTypeUnspecified:
|
||||
return settings.MultiFactorType_MULTI_FACTOR_TYPE_UNSPECIFIED
|
||||
default:
|
||||
return settings.MultiFactorType_MULTI_FACTOR_TYPE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func passwordSettingsToPb(current *query.PasswordComplexityPolicy) *settings.PasswordComplexitySettings {
|
||||
return &settings.PasswordComplexitySettings{
|
||||
MinLength: current.MinLength,
|
||||
RequiresUppercase: current.HasUppercase,
|
||||
RequiresLowercase: current.HasLowercase,
|
||||
RequiresNumber: current.HasNumber,
|
||||
RequiresSymbol: current.HasSymbol,
|
||||
ResourceOwnerType: isDefaultToResourceOwnerTypePb(current.IsDefault),
|
||||
}
|
||||
}
|
||||
|
||||
func brandingSettingsToPb(current *query.LabelPolicy, assetPrefix string) *settings.BrandingSettings {
|
||||
return &settings.BrandingSettings{
|
||||
LightTheme: themeToPb(current.Light, assetPrefix, current.ResourceOwner),
|
||||
DarkTheme: themeToPb(current.Dark, assetPrefix, current.ResourceOwner),
|
||||
FontUrl: domain.AssetURL(assetPrefix, current.ResourceOwner, current.FontURL),
|
||||
DisableWatermark: current.WatermarkDisabled,
|
||||
HideLoginNameSuffix: current.HideLoginNameSuffix,
|
||||
ResourceOwnerType: isDefaultToResourceOwnerTypePb(current.IsDefault),
|
||||
}
|
||||
}
|
||||
|
||||
func themeToPb(theme query.Theme, assetPrefix, resourceOwner string) *settings.Theme {
|
||||
return &settings.Theme{
|
||||
PrimaryColor: theme.PrimaryColor,
|
||||
BackgroundColor: theme.BackgroundColor,
|
||||
FontColor: theme.FontColor,
|
||||
WarnColor: theme.WarnColor,
|
||||
LogoUrl: domain.AssetURL(assetPrefix, resourceOwner, theme.LogoURL),
|
||||
IconUrl: domain.AssetURL(assetPrefix, resourceOwner, theme.IconURL),
|
||||
}
|
||||
}
|
||||
|
||||
func domainSettingsToPb(current *query.DomainPolicy) *settings.DomainSettings {
|
||||
return &settings.DomainSettings{
|
||||
LoginNameIncludesDomain: current.UserLoginMustBeDomain,
|
||||
RequireOrgDomainVerification: current.ValidateOrgDomains,
|
||||
SmtpSenderAddressMatchesInstanceDomain: current.SMTPSenderAddressMatchesInstanceDomain,
|
||||
ResourceOwnerType: isDefaultToResourceOwnerTypePb(current.IsDefault),
|
||||
}
|
||||
}
|
||||
|
||||
func legalAndSupportSettingsToPb(current *query.PrivacyPolicy) *settings.LegalAndSupportSettings {
|
||||
return &settings.LegalAndSupportSettings{
|
||||
TosLink: current.TOSLink,
|
||||
PrivacyPolicyLink: current.PrivacyLink,
|
||||
HelpLink: current.HelpLink,
|
||||
SupportEmail: string(current.SupportEmail),
|
||||
ResourceOwnerType: isDefaultToResourceOwnerTypePb(current.IsDefault),
|
||||
}
|
||||
}
|
||||
|
||||
func lockoutSettingsToPb(current *query.LockoutPolicy) *settings.LockoutSettings {
|
||||
return &settings.LockoutSettings{
|
||||
MaxPasswordAttempts: current.MaxPasswordAttempts,
|
||||
ResourceOwnerType: isDefaultToResourceOwnerTypePb(current.IsDefault),
|
||||
}
|
||||
}
|
||||
|
||||
func identityProvidersToPb(idps []*query.IDPLoginPolicyLink) []*settings.IdentityProvider {
|
||||
providers := make([]*settings.IdentityProvider, len(idps))
|
||||
for i, idp := range idps {
|
||||
providers[i] = identityProviderToPb(idp)
|
||||
}
|
||||
return providers
|
||||
}
|
||||
|
||||
func identityProviderToPb(idp *query.IDPLoginPolicyLink) *settings.IdentityProvider {
|
||||
return &settings.IdentityProvider{
|
||||
Id: idp.IDPID,
|
||||
Name: domain.IDPName(idp.IDPName, idp.IDPType),
|
||||
Type: idpTypeToPb(idp.IDPType),
|
||||
}
|
||||
}
|
||||
|
||||
func idpTypeToPb(idpType domain.IDPType) settings.IdentityProviderType {
|
||||
switch idpType {
|
||||
case domain.IDPTypeUnspecified:
|
||||
return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_UNSPECIFIED
|
||||
case domain.IDPTypeOIDC:
|
||||
return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_OIDC
|
||||
case domain.IDPTypeJWT:
|
||||
return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_JWT
|
||||
case domain.IDPTypeOAuth:
|
||||
return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_OAUTH
|
||||
case domain.IDPTypeLDAP:
|
||||
return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_LDAP
|
||||
case domain.IDPTypeAzureAD:
|
||||
return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_AZURE_AD
|
||||
case domain.IDPTypeGitHub:
|
||||
return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GITHUB
|
||||
case domain.IDPTypeGitHubEnterprise:
|
||||
return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GITHUB_ES
|
||||
case domain.IDPTypeGitLab:
|
||||
return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GITLAB
|
||||
case domain.IDPTypeGitLabSelfHosted:
|
||||
return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GITLAB_SELF_HOSTED
|
||||
case domain.IDPTypeGoogle:
|
||||
return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GOOGLE
|
||||
default:
|
||||
return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_UNSPECIFIED
|
||||
}
|
||||
}
|
461
internal/api/grpc/settings/v2/settings_converter_test.go
Normal file
461
internal/api/grpc/settings/v2/settings_converter_test.go
Normal file
@ -0,0 +1,461 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/reflect/protoreflect"
|
||||
"google.golang.org/protobuf/types/known/durationpb"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2alpha"
|
||||
)
|
||||
|
||||
var ignoreMessageTypes = map[protoreflect.FullName]bool{
|
||||
"google.protobuf.Duration": true,
|
||||
}
|
||||
|
||||
// allFieldsSet recusively checks if all values in a message
|
||||
// have a non-zero value.
|
||||
func allFieldsSet(t testing.TB, msg protoreflect.Message) {
|
||||
md := msg.Descriptor()
|
||||
name := md.FullName()
|
||||
if ignoreMessageTypes[name] {
|
||||
return
|
||||
}
|
||||
|
||||
fields := md.Fields()
|
||||
|
||||
for i := 0; i < fields.Len(); i++ {
|
||||
fd := fields.Get(i)
|
||||
if !msg.Has(fd) {
|
||||
t.Errorf("not all fields set in %q, missing %q", name, fd.Name())
|
||||
continue
|
||||
}
|
||||
|
||||
if fd.Kind() == protoreflect.MessageKind {
|
||||
allFieldsSet(t, msg.Get(fd).Message())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func Test_loginSettingsToPb(t *testing.T) {
|
||||
arg := &query.LoginPolicy{
|
||||
AllowUsernamePassword: true,
|
||||
AllowRegister: true,
|
||||
AllowExternalIDPs: true,
|
||||
ForceMFA: true,
|
||||
PasswordlessType: domain.PasswordlessTypeAllowed,
|
||||
HidePasswordReset: true,
|
||||
IgnoreUnknownUsernames: true,
|
||||
AllowDomainDiscovery: true,
|
||||
DisableLoginWithEmail: true,
|
||||
DisableLoginWithPhone: true,
|
||||
DefaultRedirectURI: "example.com",
|
||||
PasswordCheckLifetime: time.Hour,
|
||||
ExternalLoginCheckLifetime: time.Minute,
|
||||
MFAInitSkipLifetime: time.Millisecond,
|
||||
SecondFactorCheckLifetime: time.Microsecond,
|
||||
MultiFactorCheckLifetime: time.Nanosecond,
|
||||
SecondFactors: []domain.SecondFactorType{
|
||||
domain.SecondFactorTypeOTP,
|
||||
domain.SecondFactorTypeU2F,
|
||||
},
|
||||
MultiFactors: []domain.MultiFactorType{
|
||||
domain.MultiFactorTypeU2FWithPIN,
|
||||
},
|
||||
IsDefault: true,
|
||||
}
|
||||
|
||||
want := &settings.LoginSettings{
|
||||
AllowUsernamePassword: true,
|
||||
AllowRegister: true,
|
||||
AllowExternalIdp: true,
|
||||
ForceMfa: true,
|
||||
PasskeysType: settings.PasskeysType_PASSKEYS_TYPE_ALLOWED,
|
||||
HidePasswordReset: true,
|
||||
IgnoreUnknownUsernames: true,
|
||||
AllowDomainDiscovery: true,
|
||||
DisableLoginWithEmail: true,
|
||||
DisableLoginWithPhone: true,
|
||||
DefaultRedirectUri: "example.com",
|
||||
PasswordCheckLifetime: durationpb.New(time.Hour),
|
||||
ExternalLoginCheckLifetime: durationpb.New(time.Minute),
|
||||
MfaInitSkipLifetime: durationpb.New(time.Millisecond),
|
||||
SecondFactorCheckLifetime: durationpb.New(time.Microsecond),
|
||||
MultiFactorCheckLifetime: durationpb.New(time.Nanosecond),
|
||||
SecondFactors: []settings.SecondFactorType{
|
||||
settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP,
|
||||
settings.SecondFactorType_SECOND_FACTOR_TYPE_U2F,
|
||||
},
|
||||
MultiFactors: []settings.MultiFactorType{
|
||||
settings.MultiFactorType_MULTI_FACTOR_TYPE_U2F_WITH_VERIFICATION,
|
||||
},
|
||||
ResourceOwnerType: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE,
|
||||
}
|
||||
|
||||
got := loginSettingsToPb(arg)
|
||||
allFieldsSet(t, got.ProtoReflect())
|
||||
if !proto.Equal(got, want) {
|
||||
t.Errorf("loginSettingsToPb() =\n%v\nwant\n%v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_isDefaultToResourceOwnerTypePb(t *testing.T) {
|
||||
type args struct {
|
||||
isDefault bool
|
||||
}
|
||||
tests := []struct {
|
||||
args args
|
||||
want settings.ResourceOwnerType
|
||||
}{
|
||||
{
|
||||
args: args{false},
|
||||
want: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_ORG,
|
||||
},
|
||||
{
|
||||
args: args{true},
|
||||
want: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.want.String(), func(t *testing.T) {
|
||||
got := isDefaultToResourceOwnerTypePb(tt.args.isDefault)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_passkeysTypeToPb(t *testing.T) {
|
||||
type args struct {
|
||||
passwordlessType domain.PasswordlessType
|
||||
}
|
||||
tests := []struct {
|
||||
args args
|
||||
want settings.PasskeysType
|
||||
}{
|
||||
{
|
||||
args: args{domain.PasswordlessTypeNotAllowed},
|
||||
want: settings.PasskeysType_PASSKEYS_TYPE_NOT_ALLOWED,
|
||||
},
|
||||
{
|
||||
args: args{domain.PasswordlessTypeAllowed},
|
||||
want: settings.PasskeysType_PASSKEYS_TYPE_ALLOWED,
|
||||
},
|
||||
{
|
||||
args: args{99},
|
||||
want: settings.PasskeysType_PASSKEYS_TYPE_NOT_ALLOWED,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.want.String(), func(t *testing.T) {
|
||||
got := passkeysTypeToPb(tt.args.passwordlessType)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_secondFactorTypeToPb(t *testing.T) {
|
||||
type args struct {
|
||||
secondFactorType domain.SecondFactorType
|
||||
}
|
||||
tests := []struct {
|
||||
args args
|
||||
want settings.SecondFactorType
|
||||
}{
|
||||
{
|
||||
args: args{domain.SecondFactorTypeOTP},
|
||||
want: settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP,
|
||||
},
|
||||
{
|
||||
args: args{domain.SecondFactorTypeU2F},
|
||||
want: settings.SecondFactorType_SECOND_FACTOR_TYPE_U2F,
|
||||
},
|
||||
{
|
||||
args: args{domain.SecondFactorTypeUnspecified},
|
||||
want: settings.SecondFactorType_SECOND_FACTOR_TYPE_UNSPECIFIED,
|
||||
},
|
||||
{
|
||||
args: args{99},
|
||||
want: settings.SecondFactorType_SECOND_FACTOR_TYPE_UNSPECIFIED,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.want.String(), func(t *testing.T) {
|
||||
got := secondFactorTypeToPb(tt.args.secondFactorType)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_multiFactorTypeToPb(t *testing.T) {
|
||||
type args struct {
|
||||
typ domain.MultiFactorType
|
||||
}
|
||||
tests := []struct {
|
||||
args args
|
||||
want settings.MultiFactorType
|
||||
}{
|
||||
{
|
||||
args: args{domain.MultiFactorTypeU2FWithPIN},
|
||||
want: settings.MultiFactorType_MULTI_FACTOR_TYPE_U2F_WITH_VERIFICATION,
|
||||
},
|
||||
{
|
||||
args: args{domain.MultiFactorTypeUnspecified},
|
||||
want: settings.MultiFactorType_MULTI_FACTOR_TYPE_UNSPECIFIED,
|
||||
},
|
||||
{
|
||||
args: args{99},
|
||||
want: settings.MultiFactorType_MULTI_FACTOR_TYPE_UNSPECIFIED,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.want.String(), func(t *testing.T) {
|
||||
got := multiFactorTypeToPb(tt.args.typ)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_passwordSettingsToPb(t *testing.T) {
|
||||
arg := &query.PasswordComplexityPolicy{
|
||||
MinLength: 12,
|
||||
HasUppercase: true,
|
||||
HasLowercase: true,
|
||||
HasNumber: true,
|
||||
HasSymbol: true,
|
||||
IsDefault: true,
|
||||
}
|
||||
want := &settings.PasswordComplexitySettings{
|
||||
MinLength: 12,
|
||||
RequiresUppercase: true,
|
||||
RequiresLowercase: true,
|
||||
RequiresNumber: true,
|
||||
RequiresSymbol: true,
|
||||
ResourceOwnerType: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE,
|
||||
}
|
||||
|
||||
got := passwordSettingsToPb(arg)
|
||||
allFieldsSet(t, got.ProtoReflect())
|
||||
if !proto.Equal(got, want) {
|
||||
t.Errorf("passwordSettingsToPb() =\n%v\nwant\n%v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_brandingSettingsToPb(t *testing.T) {
|
||||
arg := &query.LabelPolicy{
|
||||
Light: query.Theme{
|
||||
PrimaryColor: "red",
|
||||
WarnColor: "white",
|
||||
BackgroundColor: "blue",
|
||||
FontColor: "orange",
|
||||
LogoURL: "light-logo",
|
||||
IconURL: "light-icon",
|
||||
},
|
||||
Dark: query.Theme{
|
||||
PrimaryColor: "magenta",
|
||||
WarnColor: "pink",
|
||||
BackgroundColor: "black",
|
||||
FontColor: "white",
|
||||
LogoURL: "dark-logo",
|
||||
IconURL: "dark-icon",
|
||||
},
|
||||
ResourceOwner: "me",
|
||||
FontURL: "fonts",
|
||||
WatermarkDisabled: true,
|
||||
HideLoginNameSuffix: true,
|
||||
IsDefault: true,
|
||||
}
|
||||
want := &settings.BrandingSettings{
|
||||
LightTheme: &settings.Theme{
|
||||
PrimaryColor: "red",
|
||||
WarnColor: "white",
|
||||
BackgroundColor: "blue",
|
||||
FontColor: "orange",
|
||||
LogoUrl: "http://example.com/me/light-logo",
|
||||
IconUrl: "http://example.com/me/light-icon",
|
||||
},
|
||||
DarkTheme: &settings.Theme{
|
||||
PrimaryColor: "magenta",
|
||||
WarnColor: "pink",
|
||||
BackgroundColor: "black",
|
||||
FontColor: "white",
|
||||
LogoUrl: "http://example.com/me/dark-logo",
|
||||
IconUrl: "http://example.com/me/dark-icon",
|
||||
},
|
||||
FontUrl: "http://example.com/me/fonts",
|
||||
DisableWatermark: true,
|
||||
HideLoginNameSuffix: true,
|
||||
ResourceOwnerType: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE,
|
||||
}
|
||||
|
||||
got := brandingSettingsToPb(arg, "http://example.com")
|
||||
allFieldsSet(t, got.ProtoReflect())
|
||||
if !proto.Equal(got, want) {
|
||||
t.Errorf("brandingSettingsToPb() =\n%v\nwant\n%v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_domainSettingsToPb(t *testing.T) {
|
||||
arg := &query.DomainPolicy{
|
||||
UserLoginMustBeDomain: true,
|
||||
ValidateOrgDomains: true,
|
||||
SMTPSenderAddressMatchesInstanceDomain: true,
|
||||
IsDefault: true,
|
||||
}
|
||||
want := &settings.DomainSettings{
|
||||
LoginNameIncludesDomain: true,
|
||||
RequireOrgDomainVerification: true,
|
||||
SmtpSenderAddressMatchesInstanceDomain: true,
|
||||
ResourceOwnerType: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE,
|
||||
}
|
||||
got := domainSettingsToPb(arg)
|
||||
allFieldsSet(t, got.ProtoReflect())
|
||||
if !proto.Equal(got, want) {
|
||||
t.Errorf("domainSettingsToPb() =\n%v\nwant\n%v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_legalSettingsToPb(t *testing.T) {
|
||||
arg := &query.PrivacyPolicy{
|
||||
TOSLink: "http://example.com/tos",
|
||||
PrivacyLink: "http://example.com/pricacy",
|
||||
HelpLink: "http://example.com/help",
|
||||
SupportEmail: "support@zitadel.com",
|
||||
IsDefault: true,
|
||||
}
|
||||
want := &settings.LegalAndSupportSettings{
|
||||
TosLink: "http://example.com/tos",
|
||||
PrivacyPolicyLink: "http://example.com/pricacy",
|
||||
HelpLink: "http://example.com/help",
|
||||
SupportEmail: "support@zitadel.com",
|
||||
ResourceOwnerType: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE,
|
||||
}
|
||||
got := legalAndSupportSettingsToPb(arg)
|
||||
allFieldsSet(t, got.ProtoReflect())
|
||||
if !proto.Equal(got, want) {
|
||||
t.Errorf("legalSettingsToPb() =\n%v\nwant\n%v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_lockoutSettingsToPb(t *testing.T) {
|
||||
arg := &query.LockoutPolicy{
|
||||
MaxPasswordAttempts: 22,
|
||||
IsDefault: true,
|
||||
}
|
||||
want := &settings.LockoutSettings{
|
||||
MaxPasswordAttempts: 22,
|
||||
ResourceOwnerType: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE,
|
||||
}
|
||||
got := lockoutSettingsToPb(arg)
|
||||
allFieldsSet(t, got.ProtoReflect())
|
||||
if !proto.Equal(got, want) {
|
||||
t.Errorf("lockoutSettingsToPb() =\n%v\nwant\n%v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_identityProvidersToPb(t *testing.T) {
|
||||
arg := []*query.IDPLoginPolicyLink{
|
||||
{
|
||||
IDPID: "1",
|
||||
IDPName: "foo",
|
||||
IDPType: domain.IDPTypeOIDC,
|
||||
},
|
||||
{
|
||||
IDPID: "2",
|
||||
IDPName: "bar",
|
||||
IDPType: domain.IDPTypeGitHub,
|
||||
},
|
||||
}
|
||||
want := []*settings.IdentityProvider{
|
||||
{
|
||||
Id: "1",
|
||||
Name: "foo",
|
||||
Type: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_OIDC,
|
||||
},
|
||||
{
|
||||
Id: "2",
|
||||
Name: "bar",
|
||||
Type: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GITHUB,
|
||||
},
|
||||
}
|
||||
got := identityProvidersToPb(arg)
|
||||
require.Len(t, got, len(got))
|
||||
for i, v := range got {
|
||||
allFieldsSet(t, v.ProtoReflect())
|
||||
if !proto.Equal(v, want[i]) {
|
||||
t.Errorf("identityProvidersToPb() =\n%v\nwant\n%v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func Test_idpTypeToPb(t *testing.T) {
|
||||
type args struct {
|
||||
idpType domain.IDPType
|
||||
}
|
||||
tests := []struct {
|
||||
args args
|
||||
want settings.IdentityProviderType
|
||||
}{
|
||||
{
|
||||
args: args{domain.IDPTypeUnspecified},
|
||||
want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_UNSPECIFIED,
|
||||
},
|
||||
{
|
||||
args: args{domain.IDPTypeOIDC},
|
||||
want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_OIDC,
|
||||
},
|
||||
{
|
||||
args: args{domain.IDPTypeJWT},
|
||||
want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_JWT,
|
||||
},
|
||||
{
|
||||
args: args{domain.IDPTypeOAuth},
|
||||
want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_OAUTH,
|
||||
},
|
||||
{
|
||||
args: args{domain.IDPTypeLDAP},
|
||||
want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_LDAP,
|
||||
},
|
||||
{
|
||||
args: args{domain.IDPTypeAzureAD},
|
||||
want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_AZURE_AD,
|
||||
},
|
||||
{
|
||||
args: args{domain.IDPTypeGitHub},
|
||||
want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GITHUB,
|
||||
},
|
||||
{
|
||||
args: args{domain.IDPTypeGitHubEnterprise},
|
||||
want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GITHUB_ES,
|
||||
},
|
||||
{
|
||||
args: args{domain.IDPTypeGitLab},
|
||||
want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GITLAB,
|
||||
},
|
||||
{
|
||||
args: args{domain.IDPTypeGitLabSelfHosted},
|
||||
want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GITLAB_SELF_HOSTED,
|
||||
},
|
||||
{
|
||||
args: args{domain.IDPTypeGoogle},
|
||||
want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_GOOGLE,
|
||||
},
|
||||
{
|
||||
args: args{99},
|
||||
want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_UNSPECIFIED,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.want.String(), func(t *testing.T) {
|
||||
if got := idpTypeToPb(tt.args.idpType); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("idpTypeToPb() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
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"
|
||||
|
@ -1,15 +1,16 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/api/grpc/server/middleware"
|
||||
http_utils "github.com/zitadel/zitadel/internal/api/http"
|
||||
"github.com/zitadel/zitadel/internal/logstore"
|
||||
"github.com/zitadel/zitadel/internal/logstore/emitters/access"
|
||||
@ -20,6 +21,7 @@ type AccessInterceptor struct {
|
||||
svc *logstore.Service
|
||||
cookieHandler *http_utils.CookieHandler
|
||||
limitConfig *AccessConfig
|
||||
storeOnly bool
|
||||
}
|
||||
|
||||
type AccessConfig struct {
|
||||
@ -27,14 +29,15 @@ type AccessConfig struct {
|
||||
ExhaustedCookieMaxAge time.Duration
|
||||
}
|
||||
|
||||
func NewAccessInterceptor(svc *logstore.Service, cookieConfig *AccessConfig) *AccessInterceptor {
|
||||
// NewAccessInterceptor intercepts all requests and stores them to the logstore.
|
||||
// If storeOnly is false, it also checks if requests are exhausted.
|
||||
// If requests are exhausted, it also returns http.StatusTooManyRequests and sets a cookie
|
||||
func NewAccessInterceptor(svc *logstore.Service, cookieHandler *http_utils.CookieHandler, cookieConfig *AccessConfig, storeOnly bool) *AccessInterceptor {
|
||||
return &AccessInterceptor{
|
||||
svc: svc,
|
||||
cookieHandler: http_utils.NewCookieHandler(
|
||||
http_utils.WithUnsecure(),
|
||||
http_utils.WithMaxAge(int(math.Floor(cookieConfig.ExhaustedCookieMaxAge.Seconds()))),
|
||||
),
|
||||
cookieHandler: cookieHandler,
|
||||
limitConfig: cookieConfig,
|
||||
storeOnly: storeOnly,
|
||||
}
|
||||
}
|
||||
|
||||
@ -43,34 +46,34 @@ 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}
|
||||
|
||||
instance := authz.GetInstance(ctx)
|
||||
limit := false
|
||||
if !a.storeOnly {
|
||||
remaining := a.svc.Limit(tracingCtx, instance.InstanceID())
|
||||
limit := remaining != nil && *remaining == 0
|
||||
|
||||
a.cookieHandler.SetCookie(wrappedWriter, a.limitConfig.ExhaustedCookieKey, request.Host, strconv.FormatBool(limit))
|
||||
|
||||
if limit {
|
||||
wrappedWriter.WriteHeader(http.StatusTooManyRequests)
|
||||
wrappedWriter.ignoreWrites = true
|
||||
limit = remaining != nil && *remaining == 0
|
||||
}
|
||||
|
||||
checkSpan.End()
|
||||
if limit {
|
||||
// Limit can only be true when storeOnly is false, so set the cookie and the response code
|
||||
SetExhaustedCookie(a.cookieHandler, wrappedWriter, a.limitConfig, request)
|
||||
http.Error(wrappedWriter, "quota for authenticated requests is exhausted", http.StatusTooManyRequests)
|
||||
} else {
|
||||
if !a.storeOnly {
|
||||
// If not limited and not storeOnly, ensure the cookie is deleted
|
||||
DeleteExhaustedCookie(a.cookieHandler, wrappedWriter, request, a.limitConfig)
|
||||
}
|
||||
// Always serve if not limited
|
||||
next.ServeHTTP(wrappedWriter, request)
|
||||
|
||||
}
|
||||
tracingCtx, writeSpan := tracing.NewNamedSpan(tracingCtx, "writeAccess")
|
||||
defer writeSpan.End()
|
||||
requestURL := request.RequestURI
|
||||
unescapedURL, err := url.QueryUnescape(requestURL)
|
||||
if err != nil {
|
||||
logging.WithError(err).WithField("url", requestURL).Warning("failed to unescape request url")
|
||||
// err = nil is effective because of deferred tracing span end
|
||||
err = nil
|
||||
}
|
||||
a.svc.Handle(tracingCtx, &access.Record{
|
||||
LogDate: time.Now(),
|
||||
@ -87,6 +90,24 @@ func (a *AccessInterceptor) Handle(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func SetExhaustedCookie(cookieHandler *http_utils.CookieHandler, writer http.ResponseWriter, cookieConfig *AccessConfig, request *http.Request) {
|
||||
cookieValue := "true"
|
||||
host := request.Header.Get(middleware.HTTP1Host)
|
||||
domain := host
|
||||
if strings.ContainsAny(host, ":") {
|
||||
var err error
|
||||
domain, _, err = net.SplitHostPort(host)
|
||||
if err != nil {
|
||||
logging.WithError(err).WithField("host", host).Warning("failed to extract cookie domain from request host")
|
||||
}
|
||||
}
|
||||
cookieHandler.SetCookie(writer, cookieConfig.ExhaustedCookieKey, domain, cookieValue)
|
||||
}
|
||||
|
||||
func DeleteExhaustedCookie(cookieHandler *http_utils.CookieHandler, writer http.ResponseWriter, request *http.Request, cookieConfig *AccessConfig) {
|
||||
cookieHandler.DeleteCookie(writer, request, cookieConfig.ExhaustedCookieKey)
|
||||
}
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
|
@ -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()
|
||||
}
|
@ -1,9 +1,11 @@
|
||||
package console
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
@ -24,6 +26,7 @@ import (
|
||||
type Config struct {
|
||||
ShortCache middleware.CacheConfig
|
||||
LongCache middleware.CacheConfig
|
||||
InstanceManagementURL string
|
||||
}
|
||||
|
||||
type spaHandler struct {
|
||||
@ -106,7 +109,13 @@ func Start(config Config, externalSecure bool, issuer op.IssuerFromRequest, call
|
||||
handler.Use(callDurationInterceptor, instanceHandler, security, accessInterceptor)
|
||||
handler.Handle(envRequestPath, middleware.TelemetryHandler()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
url := http_util.BuildOrigin(r.Host, externalSecure)
|
||||
environmentJSON, err := createEnvironmentJSON(url, issuer(r), authz.GetInstance(r.Context()).ConsoleClientID(), customerPortal)
|
||||
instance := authz.GetInstance(r.Context())
|
||||
instanceMgmtURL, err := templateInstanceManagementURL(config.InstanceManagementURL, instance)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("unable to template instance management url for console: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
environmentJSON, err := createEnvironmentJSON(url, issuer(r), instance.ConsoleClientID(), customerPortal, instanceMgmtURL)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("unable to marshal env for console: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
@ -118,6 +127,18 @@ func Start(config Config, externalSecure bool, issuer op.IssuerFromRequest, call
|
||||
return handler, nil
|
||||
}
|
||||
|
||||
func templateInstanceManagementURL(templateableCookieValue string, instance authz.Instance) (string, error) {
|
||||
cookieValueTemplate, err := template.New("cookievalue").Parse(templateableCookieValue)
|
||||
if err != nil {
|
||||
return templateableCookieValue, err
|
||||
}
|
||||
cookieValue := new(bytes.Buffer)
|
||||
if err = cookieValueTemplate.Execute(cookieValue, instance); err != nil {
|
||||
return templateableCookieValue, err
|
||||
}
|
||||
return cookieValue.String(), nil
|
||||
}
|
||||
|
||||
func csp() *middleware.CSP {
|
||||
csp := middleware.DefaultSCP
|
||||
csp.StyleSrc = csp.StyleSrc.AddInline()
|
||||
@ -127,17 +148,19 @@ func csp() *middleware.CSP {
|
||||
return &csp
|
||||
}
|
||||
|
||||
func createEnvironmentJSON(api, issuer, clientID, customerPortal string) ([]byte, error) {
|
||||
func createEnvironmentJSON(api, issuer, clientID, customerPortal, instanceMgmtUrl string) ([]byte, error) {
|
||||
environment := struct {
|
||||
API string `json:"api,omitempty"`
|
||||
Issuer string `json:"issuer,omitempty"`
|
||||
ClientID string `json:"clientid,omitempty"`
|
||||
CustomerPortal string `json:"customer_portal,omitempty"`
|
||||
InstanceManagementURL string `json:"instance_management_url,omitempty"`
|
||||
}{
|
||||
API: api,
|
||||
Issuer: issuer,
|
||||
ClientID: clientID,
|
||||
CustomerPortal: customerPortal,
|
||||
InstanceManagementURL: instanceMgmtUrl,
|
||||
}
|
||||
return json.Marshal(environment)
|
||||
}
|
||||
|
@ -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)
|
||||
},
|
||||
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,9 +71,11 @@ 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 {
|
||||
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 "github.com/zitadel/logging"
|
||||
|
||||
type IDPState int32
|
||||
|
||||
const (
|
||||
@ -56,3 +58,36 @@ func (t IDPType) GetCSSClass() string {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func IDPName(name string, idpType IDPType) string {
|
||||
if name != "" {
|
||||
return name
|
||||
}
|
||||
return idpType.DisplayName()
|
||||
}
|
||||
|
||||
// DisplayName returns the name or a default
|
||||
// to be used when always a name must be displayed (e.g. login)
|
||||
func (t IDPType) DisplayName() string {
|
||||
switch t {
|
||||
case IDPTypeGitHub:
|
||||
return "GitHub"
|
||||
case IDPTypeGitLab:
|
||||
return "GitLab"
|
||||
case IDPTypeGoogle:
|
||||
return "Google"
|
||||
case IDPTypeUnspecified,
|
||||
IDPTypeOIDC,
|
||||
IDPTypeJWT,
|
||||
IDPTypeOAuth,
|
||||
IDPTypeLDAP,
|
||||
IDPTypeAzureAD,
|
||||
IDPTypeGitHubEnterprise,
|
||||
IDPTypeGitLabSelfHosted:
|
||||
fallthrough
|
||||
default:
|
||||
// we should never get here, so log it
|
||||
logging.Errorf("name of provider (type %d) is empty", t)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
)
|
||||
|
@ -4,8 +4,6 @@ import (
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/eventstore/v1/models"
|
||||
)
|
||||
|
||||
@ -70,30 +68,7 @@ func (p IDPProvider) IsValid() bool {
|
||||
// DisplayName returns the name or a default
|
||||
// to be used when always a name must be displayed (e.g. login)
|
||||
func (p IDPProvider) DisplayName() string {
|
||||
if p.Name != "" {
|
||||
return p.Name
|
||||
}
|
||||
switch p.IDPType {
|
||||
case IDPTypeGitHub:
|
||||
return "GitHub"
|
||||
case IDPTypeGitLab:
|
||||
return "GitLab"
|
||||
case IDPTypeGoogle:
|
||||
return "Google"
|
||||
case IDPTypeUnspecified,
|
||||
IDPTypeOIDC,
|
||||
IDPTypeJWT,
|
||||
IDPTypeOAuth,
|
||||
IDPTypeLDAP,
|
||||
IDPTypeAzureAD,
|
||||
IDPTypeGitHubEnterprise,
|
||||
IDPTypeGitLabSelfHosted:
|
||||
fallthrough
|
||||
default:
|
||||
// we should never get here, so log it
|
||||
logging.Errorf("name of provider (type %d) is empty - id: %s", p.IDPType, p.IDPConfigID)
|
||||
return ""
|
||||
}
|
||||
return IDPName(p.Name, p.IDPType)
|
||||
}
|
||||
|
||||
type PasswordlessType int32
|
||||
|
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
|
||||
}
|
||||
|
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