From 7d2d85f57cccae4e22efab040b6fb5961d6441fe Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Fri, 26 Jul 2024 22:39:55 +0200 Subject: [PATCH] feat: api v2beta to api v2 (#8283) # Which Problems Are Solved The v2beta services are stable but not GA. # How the Problems Are Solved The v2beta services are copied to v2. The corresponding v1 and v2beta services are deprecated. # Additional Context Closes #7236 --------- Co-authored-by: Elio Bischof --- cmd/initialise/verify_database.go | 2 +- cmd/initialise/verify_zitadel.go | 5 +- cmd/key/masterkey.go | 3 +- cmd/start/start.go | 41 +- docs/docs/apis/v2.mdx | 5 +- docs/package.json | 2 +- .../v3alpha/execution_integration_test.go | 34 +- .../action/v3alpha/query_integration_test.go | 20 +- .../action/v3alpha/server_integration_test.go | 2 +- .../action/v3alpha/target_integration_test.go | 2 +- internal/api/grpc/feature/v2/converter.go | 2 +- .../api/grpc/feature/v2/converter_test.go | 4 +- internal/api/grpc/feature/v2/feature.go | 2 +- .../feature/v2/feature_integration_test.go | 4 +- internal/api/grpc/feature/v2/server.go | 2 +- internal/api/grpc/feature/v2beta/converter.go | 155 + .../api/grpc/feature/v2beta/converter_test.go | 268 ++ internal/api/grpc/feature/v2beta/feature.go | 86 + .../v2beta/feature_integration_test.go | 499 ++++ internal/api/grpc/feature/v2beta/server.go | 47 + internal/api/grpc/object/v2/converter.go | 2 +- internal/api/grpc/object/v2beta/converter.go | 72 + internal/api/grpc/oidc/v2/oidc.go | 2 +- .../api/grpc/oidc/v2/oidc_integration_test.go | 8 +- internal/api/grpc/oidc/v2/oidc_test.go | 2 +- internal/api/grpc/oidc/v2/server.go | 2 +- internal/api/grpc/oidc/v2beta/oidc.go | 204 ++ .../grpc/oidc/v2beta/oidc_integration_test.go | 258 ++ internal/api/grpc/oidc/v2beta/oidc_test.go | 150 + internal/api/grpc/oidc/v2beta/server.go | 59 + internal/api/grpc/org/v2/org.go | 2 +- .../api/grpc/org/v2/org_integration_test.go | 4 +- internal/api/grpc/org/v2/org_test.go | 6 +- internal/api/grpc/org/v2/server.go | 2 +- internal/api/grpc/org/v2beta/org.go | 83 + .../grpc/org/v2beta/org_integration_test.go | 207 ++ internal/api/grpc/org/v2beta/org_test.go | 172 ++ internal/api/grpc/org/v2beta/server.go | 55 + .../server/middleware/activity_interceptor.go | 2 + .../middleware/execution_interceptor_test.go | 38 +- internal/api/grpc/session/v2/server.go | 2 +- internal/api/grpc/session/v2/session.go | 2 +- .../session/v2/session_integration_test.go | 22 +- internal/api/grpc/session/v2/session_test.go | 4 +- internal/api/grpc/session/v2beta/server.go | 51 + internal/api/grpc/session/v2beta/session.go | 500 ++++ .../v2beta/session_integration_test.go | 996 +++++++ .../api/grpc/session/v2beta/session_test.go | 739 +++++ internal/api/grpc/settings/v2/server.go | 2 +- .../settings/v2/server_integration_test.go | 2 +- internal/api/grpc/settings/v2/settings.go | 4 +- .../grpc/settings/v2/settings_converter.go | 2 +- .../settings/v2/settings_converter_test.go | 2 +- .../settings/v2/settings_integration_test.go | 4 +- internal/api/grpc/settings/v2beta/server.go | 57 + .../v2beta/server_integration_test.go | 34 + internal/api/grpc/settings/v2beta/settings.go | 161 ++ .../settings/v2beta/settings_converter.go | 245 ++ .../v2beta/settings_converter_test.go | 517 ++++ .../v2beta/settings_integration_test.go | 174 ++ internal/api/grpc/user/converter.go | 17 - .../schema/v3alpha/schema_integration_test.go | 4 +- internal/api/grpc/user/v2/email.go | 4 +- .../grpc/user/v2/email_integration_test.go | 5 +- internal/api/grpc/user/v2/idp_link.go | 94 + .../grpc/user/v2/idp_link_integration_test.go | 360 +++ internal/api/grpc/user/v2/otp.go | 2 +- .../api/grpc/user/v2/otp_integration_test.go | 5 +- internal/api/grpc/user/v2/passkey.go | 70 +- .../grpc/user/v2/passkey_integration_test.go | 308 +- internal/api/grpc/user/v2/passkey_test.go | 4 +- internal/api/grpc/user/v2/password.go | 2 +- .../grpc/user/v2/password_integration_test.go | 5 +- internal/api/grpc/user/v2/password_test.go | 2 +- internal/api/grpc/user/v2/phone.go | 4 +- .../grpc/user/v2/phone_integration_test.go | 5 +- internal/api/grpc/user/v2/query.go | 2 +- .../grpc/user/v2/query_integration_test.go | 5 +- internal/api/grpc/user/v2/server.go | 2 +- internal/api/grpc/user/v2/totp.go | 2 +- .../api/grpc/user/v2/totp_integration_test.go | 5 +- internal/api/grpc/user/v2/totp_test.go | 4 +- internal/api/grpc/user/v2/u2f.go | 12 +- .../api/grpc/user/v2/u2f_integration_test.go | 151 +- internal/api/grpc/user/v2/u2f_test.go | 4 +- internal/api/grpc/user/v2/user.go | 19 +- .../api/grpc/user/v2/user_integration_test.go | 85 +- internal/api/grpc/user/v2/user_test.go | 4 +- internal/api/grpc/user/v2beta/email.go | 86 + .../user/v2beta/email_integration_test.go | 297 ++ internal/api/grpc/user/v2beta/otp.go | 42 + .../grpc/user/v2beta/otp_integration_test.go | 362 +++ internal/api/grpc/user/v2beta/passkey.go | 118 + .../user/v2beta/passkey_integration_test.go | 319 +++ internal/api/grpc/user/v2beta/passkey_test.go | 235 ++ internal/api/grpc/user/v2beta/password.go | 69 + .../user/v2beta/password_integration_test.go | 232 ++ .../api/grpc/user/v2beta/password_test.go | 39 + internal/api/grpc/user/v2beta/phone.go | 102 + .../user/v2beta/phone_integration_test.go | 344 +++ internal/api/grpc/user/v2beta/query.go | 338 +++ .../user/v2beta/query_integration_test.go | 982 +++++++ internal/api/grpc/user/v2beta/server.go | 75 + internal/api/grpc/user/v2beta/totp.go | 44 + .../grpc/user/v2beta/totp_integration_test.go | 284 ++ internal/api/grpc/user/v2beta/totp_test.go | 71 + internal/api/grpc/user/v2beta/u2f.go | 42 + .../grpc/user/v2beta/u2f_integration_test.go | 190 ++ internal/api/grpc/user/v2beta/u2f_test.go | 97 + internal/api/grpc/user/v2beta/user.go | 633 +++++ .../grpc/user/v2beta/user_integration_test.go | 2521 +++++++++++++++++ internal/api/grpc/user/v2beta/user_test.go | 410 +++ internal/api/http/domain_check.go | 4 +- internal/api/http/marshal.go | 2 +- internal/api/idp/idp_integration_test.go | 2 +- .../api/oidc/auth_request_integration_test.go | 4 +- internal/api/oidc/client_integration_test.go | 2 +- internal/api/oidc/oidc_integration_test.go | 6 +- .../oidc/token_exchange_integration_test.go | 2 +- .../api/oidc/userinfo_integration_test.go | 4 +- internal/command/org_idp_config_test.go | 86 +- internal/command/user_human_webauthn.go | 5 + internal/command/user_idp_link.go | 5 + internal/command/user_idp_link_test.go | 43 +- internal/config/config.go | 3 +- internal/integration/assert.go | 18 +- internal/integration/client.go | 86 +- internal/notification/channels/log/channel.go | 4 +- .../telemetry_pusher_integration_test.go | 2 +- internal/query/idp_user_link.go | 23 + internal/query/user_auth_method.go | 23 + internal/repository/user/machine_key.go | 3 +- internal/telemetry/tracing/caller.go | 4 +- pkg/grpc/user/v2/user.go | 3 + .../action/v3alpha/action_service.proto | 20 +- proto/zitadel/action/v3alpha/execution.proto | 12 +- proto/zitadel/action/v3alpha/query.proto | 6 +- proto/zitadel/action/v3alpha/target.proto | 4 +- proto/zitadel/management.proto | 1 + .../user/schema/v3alpha/user_schema.proto | 8 +- .../schema/v3alpha/user_schema_service.proto | 16 +- statik/doc.go | 2 +- 142 files changed, 15170 insertions(+), 386 deletions(-) create mode 100644 internal/api/grpc/feature/v2beta/converter.go create mode 100644 internal/api/grpc/feature/v2beta/converter_test.go create mode 100644 internal/api/grpc/feature/v2beta/feature.go create mode 100644 internal/api/grpc/feature/v2beta/feature_integration_test.go create mode 100644 internal/api/grpc/feature/v2beta/server.go create mode 100644 internal/api/grpc/object/v2beta/converter.go create mode 100644 internal/api/grpc/oidc/v2beta/oidc.go create mode 100644 internal/api/grpc/oidc/v2beta/oidc_integration_test.go create mode 100644 internal/api/grpc/oidc/v2beta/oidc_test.go create mode 100644 internal/api/grpc/oidc/v2beta/server.go create mode 100644 internal/api/grpc/org/v2beta/org.go create mode 100644 internal/api/grpc/org/v2beta/org_integration_test.go create mode 100644 internal/api/grpc/org/v2beta/org_test.go create mode 100644 internal/api/grpc/org/v2beta/server.go create mode 100644 internal/api/grpc/session/v2beta/server.go create mode 100644 internal/api/grpc/session/v2beta/session.go create mode 100644 internal/api/grpc/session/v2beta/session_integration_test.go create mode 100644 internal/api/grpc/session/v2beta/session_test.go create mode 100644 internal/api/grpc/settings/v2beta/server.go create mode 100644 internal/api/grpc/settings/v2beta/server_integration_test.go create mode 100644 internal/api/grpc/settings/v2beta/settings.go create mode 100644 internal/api/grpc/settings/v2beta/settings_converter.go create mode 100644 internal/api/grpc/settings/v2beta/settings_converter_test.go create mode 100644 internal/api/grpc/settings/v2beta/settings_integration_test.go create mode 100644 internal/api/grpc/user/v2/idp_link.go create mode 100644 internal/api/grpc/user/v2/idp_link_integration_test.go create mode 100644 internal/api/grpc/user/v2beta/email.go create mode 100644 internal/api/grpc/user/v2beta/email_integration_test.go create mode 100644 internal/api/grpc/user/v2beta/otp.go create mode 100644 internal/api/grpc/user/v2beta/otp_integration_test.go create mode 100644 internal/api/grpc/user/v2beta/passkey.go create mode 100644 internal/api/grpc/user/v2beta/passkey_integration_test.go create mode 100644 internal/api/grpc/user/v2beta/passkey_test.go create mode 100644 internal/api/grpc/user/v2beta/password.go create mode 100644 internal/api/grpc/user/v2beta/password_integration_test.go create mode 100644 internal/api/grpc/user/v2beta/password_test.go create mode 100644 internal/api/grpc/user/v2beta/phone.go create mode 100644 internal/api/grpc/user/v2beta/phone_integration_test.go create mode 100644 internal/api/grpc/user/v2beta/query.go create mode 100644 internal/api/grpc/user/v2beta/query_integration_test.go create mode 100644 internal/api/grpc/user/v2beta/server.go create mode 100644 internal/api/grpc/user/v2beta/totp.go create mode 100644 internal/api/grpc/user/v2beta/totp_integration_test.go create mode 100644 internal/api/grpc/user/v2beta/totp_test.go create mode 100644 internal/api/grpc/user/v2beta/u2f.go create mode 100644 internal/api/grpc/user/v2beta/u2f_integration_test.go create mode 100644 internal/api/grpc/user/v2beta/u2f_test.go create mode 100644 internal/api/grpc/user/v2beta/user.go create mode 100644 internal/api/grpc/user/v2beta/user_integration_test.go create mode 100644 internal/api/grpc/user/v2beta/user_test.go create mode 100644 pkg/grpc/user/v2/user.go diff --git a/cmd/initialise/verify_database.go b/cmd/initialise/verify_database.go index ac7d39253a..be3ec19bab 100644 --- a/cmd/initialise/verify_database.go +++ b/cmd/initialise/verify_database.go @@ -38,6 +38,6 @@ func VerifyDatabase(databaseName string) func(*database.DB) error { return func(db *database.DB) error { logging.WithFields("database", databaseName).Info("verify database") - return exec(db, fmt.Sprintf(string(databaseStmt), databaseName), []string{dbAlreadyExistsCode}) + return exec(db, fmt.Sprintf(databaseStmt, databaseName), []string{dbAlreadyExistsCode}) } } diff --git a/cmd/initialise/verify_zitadel.go b/cmd/initialise/verify_zitadel.go index 53203eb4f6..7c16fdaadf 100644 --- a/cmd/initialise/verify_zitadel.go +++ b/cmd/initialise/verify_zitadel.go @@ -95,7 +95,8 @@ func createEncryptionKeys(ctx context.Context, db *database.DB) error { return err } if _, err = tx.Exec(createEncryptionKeysStmt); err != nil { - tx.Rollback() + rollbackErr := tx.Rollback() + logging.OnError(rollbackErr).Error("rollback failed") return err } @@ -110,7 +111,7 @@ func createEvents(ctx context.Context, db *database.DB) (err error) { defer func() { if err != nil { rollbackErr := tx.Rollback() - logging.OnError(rollbackErr).Debug("rollback failed") + logging.OnError(rollbackErr).Error("rollback failed") return } err = tx.Commit() diff --git a/cmd/key/masterkey.go b/cmd/key/masterkey.go index e1d3792b8a..9c14eb020e 100644 --- a/cmd/key/masterkey.go +++ b/cmd/key/masterkey.go @@ -2,7 +2,6 @@ package key import ( "errors" - "io/ioutil" "os" "github.com/spf13/cobra" @@ -42,7 +41,7 @@ func MasterKey(cmd *cobra.Command) (string, error) { if masterKeyFromEnv { return os.Getenv(envMasterKey), nil } - data, err := ioutil.ReadFile(masterKeyFile) + data, err := os.ReadFile(masterKeyFile) if err != nil { return "", err } diff --git a/cmd/start/start.go b/cmd/start/start.go index e424a3d903..0969c5388a 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -37,15 +37,21 @@ import ( action_v3_alpha "github.com/zitadel/zitadel/internal/api/grpc/action/v3alpha" "github.com/zitadel/zitadel/internal/api/grpc/admin" "github.com/zitadel/zitadel/internal/api/grpc/auth" - "github.com/zitadel/zitadel/internal/api/grpc/feature/v2" + feature_v2 "github.com/zitadel/zitadel/internal/api/grpc/feature/v2" + feature_v2beta "github.com/zitadel/zitadel/internal/api/grpc/feature/v2beta" "github.com/zitadel/zitadel/internal/api/grpc/management" oidc_v2 "github.com/zitadel/zitadel/internal/api/grpc/oidc/v2" - "github.com/zitadel/zitadel/internal/api/grpc/org/v2" - "github.com/zitadel/zitadel/internal/api/grpc/session/v2" - "github.com/zitadel/zitadel/internal/api/grpc/settings/v2" + oidc_v2beta "github.com/zitadel/zitadel/internal/api/grpc/oidc/v2beta" + org_v2 "github.com/zitadel/zitadel/internal/api/grpc/org/v2" + org_v2beta "github.com/zitadel/zitadel/internal/api/grpc/org/v2beta" + session_v2 "github.com/zitadel/zitadel/internal/api/grpc/session/v2" + session_v2beta "github.com/zitadel/zitadel/internal/api/grpc/session/v2beta" + settings_v2 "github.com/zitadel/zitadel/internal/api/grpc/settings/v2" + settings_v2beta "github.com/zitadel/zitadel/internal/api/grpc/settings/v2beta" "github.com/zitadel/zitadel/internal/api/grpc/system" user_schema_v3_alpha "github.com/zitadel/zitadel/internal/api/grpc/user/schema/v3alpha" user_v2 "github.com/zitadel/zitadel/internal/api/grpc/user/v2" + user_v2beta "github.com/zitadel/zitadel/internal/api/grpc/user/v2beta" http_util "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/api/http/middleware" "github.com/zitadel/zitadel/internal/api/idp" @@ -399,20 +405,34 @@ func startAPIs( if err := apis.RegisterServer(ctx, auth.CreateServer(commands, queries, authRepo, config.SystemDefaults, keys.User, config.ExternalSecure), tlsConfig); err != nil { return nil, err } + if err := apis.RegisterService(ctx, user_v2beta.CreateServer(commands, queries, keys.User, keys.IDPConfig, idp.CallbackURL(config.ExternalSecure), idp.SAMLRootURL(config.ExternalSecure), assets.AssetAPI(config.ExternalSecure), permissionCheck)); err != nil { + return nil, err + } if err := apis.RegisterService(ctx, user_v2.CreateServer(commands, queries, keys.User, keys.IDPConfig, idp.CallbackURL(config.ExternalSecure), idp.SAMLRootURL(config.ExternalSecure), assets.AssetAPI(config.ExternalSecure), permissionCheck)); err != nil { return nil, err } - if err := apis.RegisterService(ctx, session.CreateServer(commands, queries)); err != nil { + if err := apis.RegisterService(ctx, session_v2beta.CreateServer(commands, queries)); err != nil { return nil, err } - - if err := apis.RegisterService(ctx, settings.CreateServer(commands, queries, config.ExternalSecure)); err != nil { + if err := apis.RegisterService(ctx, settings_v2beta.CreateServer(commands, queries, config.ExternalSecure)); err != nil { return nil, err } - if err := apis.RegisterService(ctx, org.CreateServer(commands, queries, permissionCheck)); err != nil { + if err := apis.RegisterService(ctx, org_v2beta.CreateServer(commands, queries, permissionCheck)); err != nil { return nil, err } - if err := apis.RegisterService(ctx, feature.CreateServer(commands, queries)); err != nil { + if err := apis.RegisterService(ctx, feature_v2beta.CreateServer(commands, queries)); err != nil { + return nil, err + } + if err := apis.RegisterService(ctx, session_v2.CreateServer(commands, queries)); err != nil { + return nil, err + } + if err := apis.RegisterService(ctx, settings_v2.CreateServer(commands, queries, config.ExternalSecure)); err != nil { + return nil, err + } + if err := apis.RegisterService(ctx, org_v2.CreateServer(commands, queries, permissionCheck)); err != nil { + return nil, err + } + if err := apis.RegisterService(ctx, feature_v2.CreateServer(commands, queries)); err != nil { return nil, err } if err := apis.RegisterService(ctx, action_v3_alpha.CreateServer(commands, queries, domain.AllFunctions, apis.ListGrpcMethods, apis.ListGrpcServices)); err != nil { @@ -491,6 +511,9 @@ func startAPIs( apis.HandleFunc(login.EndpointDeviceAuth, login.RedirectDeviceAuthToPrefix) // After OIDC provider so that the callback endpoint can be used + if err := apis.RegisterService(ctx, oidc_v2beta.CreateServer(commands, queries, oidcServer, config.ExternalSecure)); err != nil { + return nil, err + } if err := apis.RegisterService(ctx, oidc_v2.CreateServer(commands, queries, oidcServer, config.ExternalSecure)); err != nil { return nil, err } diff --git a/docs/docs/apis/v2.mdx b/docs/docs/apis/v2.mdx index 2f51dba6e9..5f5f582a4e 100644 --- a/docs/docs/apis/v2.mdx +++ b/docs/docs/apis/v2.mdx @@ -1,5 +1,5 @@ --- -title: APIs V2 (General Available) +title: APIs V2 (Generally Available) --- import DocCardList from '@theme/DocCardList'; @@ -7,4 +7,7 @@ import DocCardList from '@theme/DocCardList'; APIs V2 organize access by resources (users, settings, etc.), unlike context-specific V1 APIs. This simplifies finding the right API, especially for multi-organization resources. +Users created with the V2 API have no initial state anymore, so new users are immediately active. +Requesting ZITADEL to send a verification email on user creation is still possible. + \ No newline at end of file diff --git a/docs/package.json b/docs/package.json index edaf6e72ca..c7e4d36796 100644 --- a/docs/package.json +++ b/docs/package.json @@ -6,7 +6,7 @@ "docusaurus": "docusaurus", "start": "docusaurus start", "start:api": "yarn run generate && docusaurus start", - "build": "yarn run generate && docusaurus build", + "build": "yarn run generate && NODE_OPTIONS=--max-old-space-size=6144 docusaurus build", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", "clear": "docusaurus clear", diff --git a/internal/api/grpc/action/v3alpha/execution_integration_test.go b/internal/api/grpc/action/v3alpha/execution_integration_test.go index fcbdb2b576..2a01e40383 100644 --- a/internal/api/grpc/action/v3alpha/execution_integration_test.go +++ b/internal/api/grpc/action/v3alpha/execution_integration_test.go @@ -12,7 +12,7 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/integration" action "github.com/zitadel/zitadel/pkg/grpc/action/v3alpha" - object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/object/v2" ) func executionTargetsSingleTarget(id string) []*action.ExecutionTargetType { @@ -69,7 +69,7 @@ func TestServer_SetExecution_Request(t *testing.T) { ConditionType: &action.Condition_Request{ Request: &action.RequestExecution{ Condition: &action.RequestExecution_Method{ - Method: "/zitadel.session.v2beta.NotExistingService/List", + Method: "/zitadel.session.v2.NotExistingService/List", }, }, }, @@ -86,7 +86,7 @@ func TestServer_SetExecution_Request(t *testing.T) { ConditionType: &action.Condition_Request{ Request: &action.RequestExecution{ Condition: &action.RequestExecution_Method{ - Method: "/zitadel.session.v2beta.SessionService/ListSessions", + Method: "/zitadel.session.v2.SessionService/ListSessions", }, }, }, @@ -125,7 +125,7 @@ func TestServer_SetExecution_Request(t *testing.T) { ConditionType: &action.Condition_Request{ Request: &action.RequestExecution{ Condition: &action.RequestExecution_Service{ - Service: "zitadel.session.v2beta.SessionService", + Service: "zitadel.session.v2.SessionService", }, }, }, @@ -200,7 +200,7 @@ func TestServer_SetExecution_Request_Include(t *testing.T) { ConditionType: &action.Condition_Request{ Request: &action.RequestExecution{ Condition: &action.RequestExecution_Service{ - Service: "zitadel.session.v2beta.SessionService", + Service: "zitadel.session.v2.SessionService", }, }, }, @@ -213,7 +213,7 @@ func TestServer_SetExecution_Request_Include(t *testing.T) { ConditionType: &action.Condition_Request{ Request: &action.RequestExecution{ Condition: &action.RequestExecution_Method{ - Method: "/zitadel.session.v2beta.SessionService/ListSessions", + Method: "/zitadel.session.v2.SessionService/ListSessions", }, }, }, @@ -247,7 +247,7 @@ func TestServer_SetExecution_Request_Include(t *testing.T) { ConditionType: &action.Condition_Request{ Request: &action.RequestExecution{ Condition: &action.RequestExecution_Method{ - Method: "/zitadel.session.v2beta.SessionService/ListSessions", + Method: "/zitadel.session.v2.SessionService/ListSessions", }, }, }, @@ -269,7 +269,7 @@ func TestServer_SetExecution_Request_Include(t *testing.T) { ConditionType: &action.Condition_Request{ Request: &action.RequestExecution{ Condition: &action.RequestExecution_Service{ - Service: "zitadel.session.v2beta.SessionService", + Service: "zitadel.session.v2.SessionService", }, }, }, @@ -347,7 +347,7 @@ func TestServer_DeleteExecution_Request(t *testing.T) { ConditionType: &action.Condition_Request{ Request: &action.RequestExecution{ Condition: &action.RequestExecution_Method{ - Method: "/zitadel.session.v2beta.SessionService/NotExisting", + Method: "/zitadel.session.v2.SessionService/NotExisting", }, }, }, @@ -367,7 +367,7 @@ func TestServer_DeleteExecution_Request(t *testing.T) { ConditionType: &action.Condition_Request{ Request: &action.RequestExecution{ Condition: &action.RequestExecution_Method{ - Method: "/zitadel.session.v2beta.SessionService/GetSession", + Method: "/zitadel.session.v2.SessionService/GetSession", }, }, }, @@ -408,7 +408,7 @@ func TestServer_DeleteExecution_Request(t *testing.T) { ConditionType: &action.Condition_Request{ Request: &action.RequestExecution{ Condition: &action.RequestExecution_Service{ - Service: "zitadel.user.v2beta.UserService", + Service: "zitadel.user.v2.UserService", }, }, }, @@ -512,7 +512,7 @@ func TestServer_SetExecution_Response(t *testing.T) { ConditionType: &action.Condition_Response{ Response: &action.ResponseExecution{ Condition: &action.ResponseExecution_Method{ - Method: "/zitadel.session.v2beta.NotExistingService/List", + Method: "/zitadel.session.v2.NotExistingService/List", }, }, }, @@ -529,7 +529,7 @@ func TestServer_SetExecution_Response(t *testing.T) { ConditionType: &action.Condition_Response{ Response: &action.ResponseExecution{ Condition: &action.ResponseExecution_Method{ - Method: "/zitadel.session.v2beta.SessionService/ListSessions", + Method: "/zitadel.session.v2.SessionService/ListSessions", }, }, }, @@ -568,7 +568,7 @@ func TestServer_SetExecution_Response(t *testing.T) { ConditionType: &action.Condition_Response{ Response: &action.ResponseExecution{ Condition: &action.ResponseExecution_Service{ - Service: "zitadel.session.v2beta.SessionService", + Service: "zitadel.session.v2.SessionService", }, }, }, @@ -670,7 +670,7 @@ func TestServer_DeleteExecution_Response(t *testing.T) { ConditionType: &action.Condition_Response{ Response: &action.ResponseExecution{ Condition: &action.ResponseExecution_Method{ - Method: "/zitadel.session.v2beta.SessionService/NotExisting", + Method: "/zitadel.session.v2.SessionService/NotExisting", }, }, }, @@ -690,7 +690,7 @@ func TestServer_DeleteExecution_Response(t *testing.T) { ConditionType: &action.Condition_Response{ Response: &action.ResponseExecution{ Condition: &action.ResponseExecution_Method{ - Method: "/zitadel.session.v2beta.SessionService/GetSession", + Method: "/zitadel.session.v2.SessionService/GetSession", }, }, }, @@ -731,7 +731,7 @@ func TestServer_DeleteExecution_Response(t *testing.T) { ConditionType: &action.Condition_Response{ Response: &action.ResponseExecution{ Condition: &action.ResponseExecution_Service{ - Service: "zitadel.user.v2beta.UserService", + Service: "zitadel.user.v2.UserService", }, }, }, diff --git a/internal/api/grpc/action/v3alpha/query_integration_test.go b/internal/api/grpc/action/v3alpha/query_integration_test.go index b083eda5a4..279109ef78 100644 --- a/internal/api/grpc/action/v3alpha/query_integration_test.go +++ b/internal/api/grpc/action/v3alpha/query_integration_test.go @@ -16,7 +16,7 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/integration" action "github.com/zitadel/zitadel/pkg/grpc/action/v3alpha" - object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/object/v2" ) func TestServer_GetTargetByID(t *testing.T) { @@ -532,7 +532,7 @@ func TestServer_ListExecutions(t *testing.T) { ConditionType: &action.Condition_Request{ Request: &action.RequestExecution{ Condition: &action.RequestExecution_Method{ - Method: "/zitadel.session.v2beta.SessionService/GetSession", + Method: "/zitadel.session.v2.SessionService/GetSession", }, }, }, @@ -555,7 +555,7 @@ func TestServer_ListExecutions(t *testing.T) { ConditionType: &action.Condition_Request{ Request: &action.RequestExecution{ Condition: &action.RequestExecution_Method{ - Method: "/zitadel.session.v2beta.SessionService/GetSession", + Method: "/zitadel.session.v2.SessionService/GetSession", }, }, }, @@ -720,7 +720,7 @@ func TestServer_ListExecutions(t *testing.T) { ConditionType: &action.Condition_Request{ Request: &action.RequestExecution{ Condition: &action.RequestExecution_Method{ - Method: "/zitadel.session.v2beta.SessionService/GetSession", + Method: "/zitadel.session.v2.SessionService/GetSession", }, }, }, @@ -729,7 +729,7 @@ func TestServer_ListExecutions(t *testing.T) { ConditionType: &action.Condition_Request{ Request: &action.RequestExecution{ Condition: &action.RequestExecution_Method{ - Method: "/zitadel.session.v2beta.SessionService/CreateSession", + Method: "/zitadel.session.v2.SessionService/CreateSession", }, }, }, @@ -738,7 +738,7 @@ func TestServer_ListExecutions(t *testing.T) { ConditionType: &action.Condition_Request{ Request: &action.RequestExecution{ Condition: &action.RequestExecution_Method{ - Method: "/zitadel.session.v2beta.SessionService/SetSession", + Method: "/zitadel.session.v2.SessionService/SetSession", }, }, }, @@ -795,11 +795,11 @@ func TestServer_ListExecutions(t *testing.T) { Query: &action.SearchQuery_InConditionsQuery{ InConditionsQuery: &action.InConditionsQuery{ Conditions: []*action.Condition{ - {ConditionType: &action.Condition_Request{Request: &action.RequestExecution{Condition: &action.RequestExecution_Method{Method: "/zitadel.session.v2beta.SessionService/GetSession"}}}}, - {ConditionType: &action.Condition_Request{Request: &action.RequestExecution{Condition: &action.RequestExecution_Service{Service: "zitadel.session.v2beta.SessionService"}}}}, + {ConditionType: &action.Condition_Request{Request: &action.RequestExecution{Condition: &action.RequestExecution_Method{Method: "/zitadel.session.v2.SessionService/GetSession"}}}}, + {ConditionType: &action.Condition_Request{Request: &action.RequestExecution{Condition: &action.RequestExecution_Service{Service: "zitadel.session.v2.SessionService"}}}}, {ConditionType: &action.Condition_Request{Request: &action.RequestExecution{Condition: &action.RequestExecution_All{All: true}}}}, - {ConditionType: &action.Condition_Response{Response: &action.ResponseExecution{Condition: &action.ResponseExecution_Method{Method: "/zitadel.session.v2beta.SessionService/GetSession"}}}}, - {ConditionType: &action.Condition_Response{Response: &action.ResponseExecution{Condition: &action.ResponseExecution_Service{Service: "zitadel.session.v2beta.SessionService"}}}}, + {ConditionType: &action.Condition_Response{Response: &action.ResponseExecution{Condition: &action.ResponseExecution_Method{Method: "/zitadel.session.v2.SessionService/GetSession"}}}}, + {ConditionType: &action.Condition_Response{Response: &action.ResponseExecution{Condition: &action.ResponseExecution_Service{Service: "zitadel.session.v2.SessionService"}}}}, {ConditionType: &action.Condition_Response{Response: &action.ResponseExecution{Condition: &action.ResponseExecution_All{All: true}}}}, {ConditionType: &action.Condition_Event{Event: &action.EventExecution{Condition: &action.EventExecution_Event{Event: "user.added"}}}}, {ConditionType: &action.Condition_Event{Event: &action.EventExecution{Condition: &action.EventExecution_Group{Group: "user"}}}}, diff --git a/internal/api/grpc/action/v3alpha/server_integration_test.go b/internal/api/grpc/action/v3alpha/server_integration_test.go index d27105fc48..e97605e1f0 100644 --- a/internal/api/grpc/action/v3alpha/server_integration_test.go +++ b/internal/api/grpc/action/v3alpha/server_integration_test.go @@ -14,7 +14,7 @@ import ( "github.com/zitadel/zitadel/internal/integration" action "github.com/zitadel/zitadel/pkg/grpc/action/v3alpha" - feature "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/feature/v2" ) var ( diff --git a/internal/api/grpc/action/v3alpha/target_integration_test.go b/internal/api/grpc/action/v3alpha/target_integration_test.go index 8b143fddb8..539f3c6d35 100644 --- a/internal/api/grpc/action/v3alpha/target_integration_test.go +++ b/internal/api/grpc/action/v3alpha/target_integration_test.go @@ -17,7 +17,7 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/integration" action "github.com/zitadel/zitadel/pkg/grpc/action/v3alpha" - object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/object/v2" ) func TestServer_CreateTarget(t *testing.T) { diff --git a/internal/api/grpc/feature/v2/converter.go b/internal/api/grpc/feature/v2/converter.go index e65a1f26b1..4d0698feaf 100644 --- a/internal/api/grpc/feature/v2/converter.go +++ b/internal/api/grpc/feature/v2/converter.go @@ -5,7 +5,7 @@ import ( "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/feature" "github.com/zitadel/zitadel/internal/query" - feature_pb "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta" + feature_pb "github.com/zitadel/zitadel/pkg/grpc/feature/v2" ) func systemFeaturesToCommand(req *feature_pb.SetSystemFeaturesRequest) *command.SystemFeatures { diff --git a/internal/api/grpc/feature/v2/converter_test.go b/internal/api/grpc/feature/v2/converter_test.go index 35dbf98014..7c2cf5fc39 100644 --- a/internal/api/grpc/feature/v2/converter_test.go +++ b/internal/api/grpc/feature/v2/converter_test.go @@ -12,8 +12,8 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/feature" "github.com/zitadel/zitadel/internal/query" - feature_pb "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta" - object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" + feature_pb "github.com/zitadel/zitadel/pkg/grpc/feature/v2" + "github.com/zitadel/zitadel/pkg/grpc/object/v2" ) func Test_systemFeaturesToCommand(t *testing.T) { diff --git a/internal/api/grpc/feature/v2/feature.go b/internal/api/grpc/feature/v2/feature.go index 1e4200ccf1..9125dea518 100644 --- a/internal/api/grpc/feature/v2/feature.go +++ b/internal/api/grpc/feature/v2/feature.go @@ -7,7 +7,7 @@ import ( "google.golang.org/grpc/status" "github.com/zitadel/zitadel/internal/api/grpc/object/v2" - feature "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/feature/v2" ) func (s *Server) SetSystemFeatures(ctx context.Context, req *feature.SetSystemFeaturesRequest) (_ *feature.SetSystemFeaturesResponse, err error) { diff --git a/internal/api/grpc/feature/v2/feature_integration_test.go b/internal/api/grpc/feature/v2/feature_integration_test.go index 5dcd0c37f4..3936b4bcd5 100644 --- a/internal/api/grpc/feature/v2/feature_integration_test.go +++ b/internal/api/grpc/feature/v2/feature_integration_test.go @@ -14,8 +14,8 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/integration" - feature "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta" - object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/feature/v2" + "github.com/zitadel/zitadel/pkg/grpc/object/v2" ) var ( diff --git a/internal/api/grpc/feature/v2/server.go b/internal/api/grpc/feature/v2/server.go index 4208c4acfc..ab92df5822 100644 --- a/internal/api/grpc/feature/v2/server.go +++ b/internal/api/grpc/feature/v2/server.go @@ -7,7 +7,7 @@ import ( "github.com/zitadel/zitadel/internal/api/grpc/server" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/query" - feature "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/feature/v2" ) type Server struct { diff --git a/internal/api/grpc/feature/v2beta/converter.go b/internal/api/grpc/feature/v2beta/converter.go new file mode 100644 index 0000000000..c866cc017d --- /dev/null +++ b/internal/api/grpc/feature/v2beta/converter.go @@ -0,0 +1,155 @@ +package feature + +import ( + object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/feature" + "github.com/zitadel/zitadel/internal/query" + feature_pb "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta" +) + +func systemFeaturesToCommand(req *feature_pb.SetSystemFeaturesRequest) *command.SystemFeatures { + return &command.SystemFeatures{ + LoginDefaultOrg: req.LoginDefaultOrg, + TriggerIntrospectionProjections: req.OidcTriggerIntrospectionProjections, + LegacyIntrospection: req.OidcLegacyIntrospection, + UserSchema: req.UserSchema, + Actions: req.Actions, + TokenExchange: req.OidcTokenExchange, + ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), + } +} + +func systemFeaturesToPb(f *query.SystemFeatures) *feature_pb.GetSystemFeaturesResponse { + return &feature_pb.GetSystemFeaturesResponse{ + Details: object.DomainToDetailsPb(f.Details), + LoginDefaultOrg: featureSourceToFlagPb(&f.LoginDefaultOrg), + OidcTriggerIntrospectionProjections: featureSourceToFlagPb(&f.TriggerIntrospectionProjections), + OidcLegacyIntrospection: featureSourceToFlagPb(&f.LegacyIntrospection), + UserSchema: featureSourceToFlagPb(&f.UserSchema), + OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), + Actions: featureSourceToFlagPb(&f.Actions), + ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), + } +} + +func instanceFeaturesToCommand(req *feature_pb.SetInstanceFeaturesRequest) *command.InstanceFeatures { + return &command.InstanceFeatures{ + LoginDefaultOrg: req.LoginDefaultOrg, + TriggerIntrospectionProjections: req.OidcTriggerIntrospectionProjections, + LegacyIntrospection: req.OidcLegacyIntrospection, + UserSchema: req.UserSchema, + TokenExchange: req.OidcTokenExchange, + Actions: req.Actions, + ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), + } +} + +func instanceFeaturesToPb(f *query.InstanceFeatures) *feature_pb.GetInstanceFeaturesResponse { + return &feature_pb.GetInstanceFeaturesResponse{ + Details: object.DomainToDetailsPb(f.Details), + LoginDefaultOrg: featureSourceToFlagPb(&f.LoginDefaultOrg), + OidcTriggerIntrospectionProjections: featureSourceToFlagPb(&f.TriggerIntrospectionProjections), + OidcLegacyIntrospection: featureSourceToFlagPb(&f.LegacyIntrospection), + UserSchema: featureSourceToFlagPb(&f.UserSchema), + OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), + Actions: featureSourceToFlagPb(&f.Actions), + ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), + } +} + +func featureSourceToImprovedPerformanceFlagPb(fs *query.FeatureSource[[]feature.ImprovedPerformanceType]) *feature_pb.ImprovedPerformanceFeatureFlag { + return &feature_pb.ImprovedPerformanceFeatureFlag{ + ExecutionPaths: improvedPerformanceTypesToPb(fs.Value), + Source: featureLevelToSourcePb(fs.Level), + } +} + +func featureSourceToFlagPb(fs *query.FeatureSource[bool]) *feature_pb.FeatureFlag { + return &feature_pb.FeatureFlag{ + Enabled: fs.Value, + Source: featureLevelToSourcePb(fs.Level), + } +} + +func featureLevelToSourcePb(level feature.Level) feature_pb.Source { + switch level { + case feature.LevelUnspecified: + return feature_pb.Source_SOURCE_UNSPECIFIED + case feature.LevelSystem: + return feature_pb.Source_SOURCE_SYSTEM + case feature.LevelInstance: + return feature_pb.Source_SOURCE_INSTANCE + case feature.LevelOrg: + return feature_pb.Source_SOURCE_ORGANIZATION + case feature.LevelProject: + return feature_pb.Source_SOURCE_PROJECT + case feature.LevelApp: + return feature_pb.Source_SOURCE_APP + case feature.LevelUser: + return feature_pb.Source_SOURCE_USER + default: + return feature_pb.Source(level) + } +} + +func improvedPerformanceTypesToPb(types []feature.ImprovedPerformanceType) []feature_pb.ImprovedPerformance { + res := make([]feature_pb.ImprovedPerformance, len(types)) + + for i, typ := range types { + res[i] = improvedPerformanceTypeToPb(typ) + } + + return res +} + +func improvedPerformanceTypeToPb(typ feature.ImprovedPerformanceType) feature_pb.ImprovedPerformance { + switch typ { + case feature.ImprovedPerformanceTypeUnknown: + return feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_UNSPECIFIED + case feature.ImprovedPerformanceTypeOrgByID: + return feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_ORG_BY_ID + case feature.ImprovedPerformanceTypeProjectGrant: + return feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_PROJECT_GRANT + case feature.ImprovedPerformanceTypeProject: + return feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_PROJECT + case feature.ImprovedPerformanceTypeUserGrant: + return feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_USER_GRANT + case feature.ImprovedPerformanceTypeOrgDomainVerified: + return feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_ORG_DOMAIN_VERIFIED + default: + return feature_pb.ImprovedPerformance(typ) + } +} + +func improvedPerformanceListToDomain(list []feature_pb.ImprovedPerformance) []feature.ImprovedPerformanceType { + if list == nil { + return nil + } + res := make([]feature.ImprovedPerformanceType, len(list)) + + for i, typ := range list { + res[i] = improvedPerformanceToDomain(typ) + } + + return res +} + +func improvedPerformanceToDomain(typ feature_pb.ImprovedPerformance) feature.ImprovedPerformanceType { + switch typ { + case feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_UNSPECIFIED: + return feature.ImprovedPerformanceTypeUnknown + case feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_ORG_BY_ID: + return feature.ImprovedPerformanceTypeOrgByID + case feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_PROJECT_GRANT: + return feature.ImprovedPerformanceTypeProjectGrant + case feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_PROJECT: + return feature.ImprovedPerformanceTypeProject + case feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_USER_GRANT: + return feature.ImprovedPerformanceTypeUserGrant + case feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_ORG_DOMAIN_VERIFIED: + return feature.ImprovedPerformanceTypeOrgDomainVerified + default: + return feature.ImprovedPerformanceTypeUnknown + } +} diff --git a/internal/api/grpc/feature/v2beta/converter_test.go b/internal/api/grpc/feature/v2beta/converter_test.go new file mode 100644 index 0000000000..35dbf98014 --- /dev/null +++ b/internal/api/grpc/feature/v2beta/converter_test.go @@ -0,0 +1,268 @@ +package feature + +import ( + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/feature" + "github.com/zitadel/zitadel/internal/query" + feature_pb "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" +) + +func Test_systemFeaturesToCommand(t *testing.T) { + arg := &feature_pb.SetSystemFeaturesRequest{ + LoginDefaultOrg: gu.Ptr(true), + OidcTriggerIntrospectionProjections: gu.Ptr(false), + OidcLegacyIntrospection: nil, + UserSchema: gu.Ptr(true), + Actions: gu.Ptr(true), + OidcTokenExchange: gu.Ptr(true), + ImprovedPerformance: nil, + } + want := &command.SystemFeatures{ + LoginDefaultOrg: gu.Ptr(true), + TriggerIntrospectionProjections: gu.Ptr(false), + LegacyIntrospection: nil, + UserSchema: gu.Ptr(true), + Actions: gu.Ptr(true), + TokenExchange: gu.Ptr(true), + ImprovedPerformance: nil, + } + got := systemFeaturesToCommand(arg) + assert.Equal(t, want, got) +} + +func Test_systemFeaturesToPb(t *testing.T) { + arg := &query.SystemFeatures{ + Details: &domain.ObjectDetails{ + Sequence: 22, + EventDate: time.Unix(123, 0), + ResourceOwner: "SYSTEM", + }, + LoginDefaultOrg: query.FeatureSource[bool]{ + Level: feature.LevelSystem, + Value: true, + }, + TriggerIntrospectionProjections: query.FeatureSource[bool]{ + Level: feature.LevelUnspecified, + Value: false, + }, + LegacyIntrospection: query.FeatureSource[bool]{ + Level: feature.LevelSystem, + Value: true, + }, + UserSchema: query.FeatureSource[bool]{ + Level: feature.LevelSystem, + Value: true, + }, + Actions: query.FeatureSource[bool]{ + Level: feature.LevelSystem, + Value: true, + }, + TokenExchange: query.FeatureSource[bool]{ + Level: feature.LevelSystem, + Value: false, + }, + ImprovedPerformance: query.FeatureSource[[]feature.ImprovedPerformanceType]{ + Level: feature.LevelSystem, + Value: []feature.ImprovedPerformanceType{feature.ImprovedPerformanceTypeOrgByID}, + }, + } + want := &feature_pb.GetSystemFeaturesResponse{ + Details: &object.Details{ + Sequence: 22, + ChangeDate: ×tamppb.Timestamp{Seconds: 123}, + ResourceOwner: "SYSTEM", + }, + LoginDefaultOrg: &feature_pb.FeatureFlag{ + Enabled: true, + Source: feature_pb.Source_SOURCE_SYSTEM, + }, + OidcTriggerIntrospectionProjections: &feature_pb.FeatureFlag{ + Enabled: false, + Source: feature_pb.Source_SOURCE_UNSPECIFIED, + }, + OidcLegacyIntrospection: &feature_pb.FeatureFlag{ + Enabled: true, + Source: feature_pb.Source_SOURCE_SYSTEM, + }, + UserSchema: &feature_pb.FeatureFlag{ + Enabled: true, + Source: feature_pb.Source_SOURCE_SYSTEM, + }, + OidcTokenExchange: &feature_pb.FeatureFlag{ + Enabled: false, + Source: feature_pb.Source_SOURCE_SYSTEM, + }, + Actions: &feature_pb.FeatureFlag{ + Enabled: true, + Source: feature_pb.Source_SOURCE_SYSTEM, + }, + ImprovedPerformance: &feature_pb.ImprovedPerformanceFeatureFlag{ + ExecutionPaths: []feature_pb.ImprovedPerformance{feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_ORG_BY_ID}, + Source: feature_pb.Source_SOURCE_SYSTEM, + }, + } + got := systemFeaturesToPb(arg) + assert.Equal(t, want, got) +} + +func Test_instanceFeaturesToCommand(t *testing.T) { + arg := &feature_pb.SetInstanceFeaturesRequest{ + LoginDefaultOrg: gu.Ptr(true), + OidcTriggerIntrospectionProjections: gu.Ptr(false), + OidcLegacyIntrospection: nil, + UserSchema: gu.Ptr(true), + OidcTokenExchange: gu.Ptr(true), + Actions: gu.Ptr(true), + ImprovedPerformance: nil, + } + want := &command.InstanceFeatures{ + LoginDefaultOrg: gu.Ptr(true), + TriggerIntrospectionProjections: gu.Ptr(false), + LegacyIntrospection: nil, + UserSchema: gu.Ptr(true), + TokenExchange: gu.Ptr(true), + Actions: gu.Ptr(true), + ImprovedPerformance: nil, + } + got := instanceFeaturesToCommand(arg) + assert.Equal(t, want, got) +} + +func Test_instanceFeaturesToPb(t *testing.T) { + arg := &query.InstanceFeatures{ + Details: &domain.ObjectDetails{ + Sequence: 22, + EventDate: time.Unix(123, 0), + ResourceOwner: "instance1", + }, + LoginDefaultOrg: query.FeatureSource[bool]{ + Level: feature.LevelSystem, + Value: true, + }, + TriggerIntrospectionProjections: query.FeatureSource[bool]{ + Level: feature.LevelUnspecified, + Value: false, + }, + LegacyIntrospection: query.FeatureSource[bool]{ + Level: feature.LevelInstance, + Value: true, + }, + UserSchema: query.FeatureSource[bool]{ + Level: feature.LevelInstance, + Value: true, + }, + Actions: query.FeatureSource[bool]{ + Level: feature.LevelInstance, + Value: true, + }, + TokenExchange: query.FeatureSource[bool]{ + Level: feature.LevelSystem, + Value: false, + }, + ImprovedPerformance: query.FeatureSource[[]feature.ImprovedPerformanceType]{ + Level: feature.LevelSystem, + Value: []feature.ImprovedPerformanceType{feature.ImprovedPerformanceTypeOrgByID}, + }, + } + want := &feature_pb.GetInstanceFeaturesResponse{ + Details: &object.Details{ + Sequence: 22, + ChangeDate: ×tamppb.Timestamp{Seconds: 123}, + ResourceOwner: "instance1", + }, + LoginDefaultOrg: &feature_pb.FeatureFlag{ + Enabled: true, + Source: feature_pb.Source_SOURCE_SYSTEM, + }, + OidcTriggerIntrospectionProjections: &feature_pb.FeatureFlag{ + Enabled: false, + Source: feature_pb.Source_SOURCE_UNSPECIFIED, + }, + OidcLegacyIntrospection: &feature_pb.FeatureFlag{ + Enabled: true, + Source: feature_pb.Source_SOURCE_INSTANCE, + }, + UserSchema: &feature_pb.FeatureFlag{ + Enabled: true, + Source: feature_pb.Source_SOURCE_INSTANCE, + }, + Actions: &feature_pb.FeatureFlag{ + Enabled: true, + Source: feature_pb.Source_SOURCE_INSTANCE, + }, + OidcTokenExchange: &feature_pb.FeatureFlag{ + Enabled: false, + Source: feature_pb.Source_SOURCE_SYSTEM, + }, + ImprovedPerformance: &feature_pb.ImprovedPerformanceFeatureFlag{ + ExecutionPaths: []feature_pb.ImprovedPerformance{feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_ORG_BY_ID}, + Source: feature_pb.Source_SOURCE_SYSTEM, + }, + } + got := instanceFeaturesToPb(arg) + assert.Equal(t, want, got) +} + +func Test_featureLevelToSourcePb(t *testing.T) { + tests := []struct { + name string + level feature.Level + want feature_pb.Source + }{ + { + name: "unspecified", + level: feature.LevelUnspecified, + want: feature_pb.Source_SOURCE_UNSPECIFIED, + }, + { + name: "system", + level: feature.LevelSystem, + want: feature_pb.Source_SOURCE_SYSTEM, + }, + { + name: "instance", + level: feature.LevelInstance, + want: feature_pb.Source_SOURCE_INSTANCE, + }, + { + name: "org", + level: feature.LevelOrg, + want: feature_pb.Source_SOURCE_ORGANIZATION, + }, + { + name: "project", + level: feature.LevelProject, + want: feature_pb.Source_SOURCE_PROJECT, + }, + { + name: "app", + level: feature.LevelApp, + want: feature_pb.Source_SOURCE_APP, + }, + { + name: "user", + level: feature.LevelUser, + want: feature_pb.Source_SOURCE_USER, + }, + { + name: "unknown", + level: 99, + want: 99, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := featureLevelToSourcePb(tt.level) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/api/grpc/feature/v2beta/feature.go b/internal/api/grpc/feature/v2beta/feature.go new file mode 100644 index 0000000000..b94f8e7de2 --- /dev/null +++ b/internal/api/grpc/feature/v2beta/feature.go @@ -0,0 +1,86 @@ +package feature + +import ( + "context" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta" + feature "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta" +) + +func (s *Server) SetSystemFeatures(ctx context.Context, req *feature.SetSystemFeaturesRequest) (_ *feature.SetSystemFeaturesResponse, err error) { + details, err := s.command.SetSystemFeatures(ctx, systemFeaturesToCommand(req)) + if err != nil { + return nil, err + } + return &feature.SetSystemFeaturesResponse{ + Details: object.DomainToDetailsPb(details), + }, nil +} + +func (s *Server) ResetSystemFeatures(ctx context.Context, req *feature.ResetSystemFeaturesRequest) (_ *feature.ResetSystemFeaturesResponse, err error) { + details, err := s.command.ResetSystemFeatures(ctx) + if err != nil { + return nil, err + } + return &feature.ResetSystemFeaturesResponse{ + Details: object.DomainToDetailsPb(details), + }, nil +} + +func (s *Server) GetSystemFeatures(ctx context.Context, req *feature.GetSystemFeaturesRequest) (_ *feature.GetSystemFeaturesResponse, err error) { + f, err := s.query.GetSystemFeatures(ctx) + if err != nil { + return nil, err + } + return systemFeaturesToPb(f), nil +} + +func (s *Server) SetInstanceFeatures(ctx context.Context, req *feature.SetInstanceFeaturesRequest) (_ *feature.SetInstanceFeaturesResponse, err error) { + details, err := s.command.SetInstanceFeatures(ctx, instanceFeaturesToCommand(req)) + if err != nil { + return nil, err + } + return &feature.SetInstanceFeaturesResponse{ + Details: object.DomainToDetailsPb(details), + }, nil +} + +func (s *Server) ResetInstanceFeatures(ctx context.Context, req *feature.ResetInstanceFeaturesRequest) (_ *feature.ResetInstanceFeaturesResponse, err error) { + details, err := s.command.ResetInstanceFeatures(ctx) + if err != nil { + return nil, err + } + return &feature.ResetInstanceFeaturesResponse{ + Details: object.DomainToDetailsPb(details), + }, nil +} + +func (s *Server) GetInstanceFeatures(ctx context.Context, req *feature.GetInstanceFeaturesRequest) (_ *feature.GetInstanceFeaturesResponse, err error) { + f, err := s.query.GetInstanceFeatures(ctx, req.GetInheritance()) + if err != nil { + return nil, err + } + return instanceFeaturesToPb(f), nil +} + +func (s *Server) SetOrganizationFeatures(ctx context.Context, req *feature.SetOrganizationFeaturesRequest) (_ *feature.SetOrganizationFeaturesResponse, err error) { + return nil, status.Errorf(codes.Unimplemented, "method SetOrganizationFeatures not implemented") +} +func (s *Server) ResetOrganizationFeatures(ctx context.Context, req *feature.ResetOrganizationFeaturesRequest) (_ *feature.ResetOrganizationFeaturesResponse, err error) { + return nil, status.Errorf(codes.Unimplemented, "method ResetOrganizationFeatures not implemented") +} +func (s *Server) GetOrganizationFeatures(ctx context.Context, req *feature.GetOrganizationFeaturesRequest) (_ *feature.GetOrganizationFeaturesResponse, err error) { + return nil, status.Errorf(codes.Unimplemented, "method GetOrganizationFeatures not implemented") +} +func (s *Server) SetUserFeatures(ctx context.Context, req *feature.SetUserFeatureRequest) (_ *feature.SetUserFeaturesResponse, err error) { + return nil, status.Errorf(codes.Unimplemented, "method SetUserFeatures not implemented") +} +func (s *Server) ResetUserFeatures(ctx context.Context, req *feature.ResetUserFeaturesRequest) (_ *feature.ResetUserFeaturesResponse, err error) { + return nil, status.Errorf(codes.Unimplemented, "method ResetUserFeatures not implemented") +} +func (s *Server) GetUserFeatures(ctx context.Context, req *feature.GetUserFeaturesRequest) (_ *feature.GetUserFeaturesResponse, err error) { + return nil, status.Errorf(codes.Unimplemented, "method GetUserFeatures not implemented") +} diff --git a/internal/api/grpc/feature/v2beta/feature_integration_test.go b/internal/api/grpc/feature/v2beta/feature_integration_test.go new file mode 100644 index 0000000000..794a080202 --- /dev/null +++ b/internal/api/grpc/feature/v2beta/feature_integration_test.go @@ -0,0 +1,499 @@ +//go:build integration + +package feature_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/integration" + feature "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" +) + +var ( + SystemCTX context.Context + IamCTX context.Context + OrgCTX context.Context + Tester *integration.Tester + Client feature.FeatureServiceClient +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, _, cancel := integration.Contexts(5 * time.Minute) + defer cancel() + Tester = integration.NewTester(ctx) + SystemCTX = Tester.WithAuthorization(ctx, integration.SystemUser) + IamCTX = Tester.WithAuthorization(ctx, integration.IAMOwner) + OrgCTX = Tester.WithAuthorization(ctx, integration.OrgOwner) + + defer Tester.Done() + Client = Tester.Client.FeatureV2beta + + return m.Run() + }()) +} + +func TestServer_SetSystemFeatures(t *testing.T) { + type args struct { + ctx context.Context + req *feature.SetSystemFeaturesRequest + } + tests := []struct { + name string + args args + want *feature.SetSystemFeaturesResponse + wantErr bool + }{ + { + name: "permission error", + args: args{ + ctx: IamCTX, + req: &feature.SetSystemFeaturesRequest{ + OidcTriggerIntrospectionProjections: gu.Ptr(true), + }, + }, + wantErr: true, + }, + { + name: "no changes error", + args: args{ + ctx: SystemCTX, + req: &feature.SetSystemFeaturesRequest{}, + }, + wantErr: true, + }, + { + name: "success", + args: args{ + ctx: SystemCTX, + req: &feature.SetSystemFeaturesRequest{ + OidcTriggerIntrospectionProjections: gu.Ptr(true), + }, + }, + want: &feature.SetSystemFeaturesResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: "SYSTEM", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(func() { + // make sure we have a clean state after each test + _, err := Client.ResetSystemFeatures(SystemCTX, &feature.ResetSystemFeaturesRequest{}) + require.NoError(t, err) + }) + got, err := Client.SetSystemFeatures(tt.args.ctx, tt.args.req) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_ResetSystemFeatures(t *testing.T) { + _, err := Client.SetSystemFeatures(SystemCTX, &feature.SetSystemFeaturesRequest{ + LoginDefaultOrg: gu.Ptr(true), + }) + require.NoError(t, err) + + tests := []struct { + name string + ctx context.Context + want *feature.ResetSystemFeaturesResponse + wantErr bool + }{ + { + name: "permission error", + ctx: IamCTX, + wantErr: true, + }, + { + name: "success", + ctx: SystemCTX, + want: &feature.ResetSystemFeaturesResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: "SYSTEM", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.ResetSystemFeatures(tt.ctx, &feature.ResetSystemFeaturesRequest{}) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_GetSystemFeatures(t *testing.T) { + type args struct { + ctx context.Context + req *feature.GetSystemFeaturesRequest + } + tests := []struct { + name string + prepare func(t *testing.T) + args args + want *feature.GetSystemFeaturesResponse + wantErr bool + }{ + { + name: "permission error", + args: args{ + ctx: IamCTX, + req: &feature.GetSystemFeaturesRequest{}, + }, + wantErr: true, + }, + { + name: "nothing set", + args: args{ + ctx: SystemCTX, + req: &feature.GetSystemFeaturesRequest{}, + }, + want: &feature.GetSystemFeaturesResponse{}, + }, + { + name: "some features", + prepare: func(t *testing.T) { + _, err := Client.SetSystemFeatures(SystemCTX, &feature.SetSystemFeaturesRequest{ + LoginDefaultOrg: gu.Ptr(true), + OidcTriggerIntrospectionProjections: gu.Ptr(false), + }) + require.NoError(t, err) + }, + args: args{ + ctx: SystemCTX, + req: &feature.GetSystemFeaturesRequest{}, + }, + want: &feature.GetSystemFeaturesResponse{ + LoginDefaultOrg: &feature.FeatureFlag{ + Enabled: true, + Source: feature.Source_SOURCE_SYSTEM, + }, + OidcTriggerIntrospectionProjections: &feature.FeatureFlag{ + Enabled: false, + Source: feature.Source_SOURCE_SYSTEM, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(func() { + // make sure we have a clean state after each test + _, err := Client.ResetSystemFeatures(SystemCTX, &feature.ResetSystemFeaturesRequest{}) + require.NoError(t, err) + }) + if tt.prepare != nil { + tt.prepare(t) + } + got, err := Client.GetSystemFeatures(tt.args.ctx, tt.args.req) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assertFeatureFlag(t, tt.want.LoginDefaultOrg, got.LoginDefaultOrg) + assertFeatureFlag(t, tt.want.OidcTriggerIntrospectionProjections, got.OidcTriggerIntrospectionProjections) + assertFeatureFlag(t, tt.want.OidcLegacyIntrospection, got.OidcLegacyIntrospection) + assertFeatureFlag(t, tt.want.UserSchema, got.UserSchema) + assertFeatureFlag(t, tt.want.Actions, got.Actions) + }) + } +} + +func TestServer_SetInstanceFeatures(t *testing.T) { + type args struct { + ctx context.Context + req *feature.SetInstanceFeaturesRequest + } + tests := []struct { + name string + args args + want *feature.SetInstanceFeaturesResponse + wantErr bool + }{ + { + name: "permission error", + args: args{ + ctx: OrgCTX, + req: &feature.SetInstanceFeaturesRequest{ + OidcTriggerIntrospectionProjections: gu.Ptr(true), + }, + }, + wantErr: true, + }, + { + name: "no changes error", + args: args{ + ctx: IamCTX, + req: &feature.SetInstanceFeaturesRequest{}, + }, + wantErr: true, + }, + { + name: "success", + args: args{ + ctx: IamCTX, + req: &feature.SetInstanceFeaturesRequest{ + OidcTriggerIntrospectionProjections: gu.Ptr(true), + }, + }, + want: &feature.SetInstanceFeaturesResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(func() { + // make sure we have a clean state after each test + _, err := Client.ResetInstanceFeatures(IamCTX, &feature.ResetInstanceFeaturesRequest{}) + require.NoError(t, err) + }) + got, err := Client.SetInstanceFeatures(tt.args.ctx, tt.args.req) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_ResetInstanceFeatures(t *testing.T) { + _, err := Client.SetInstanceFeatures(IamCTX, &feature.SetInstanceFeaturesRequest{ + LoginDefaultOrg: gu.Ptr(true), + }) + require.NoError(t, err) + + tests := []struct { + name string + ctx context.Context + want *feature.ResetInstanceFeaturesResponse + wantErr bool + }{ + { + name: "permission error", + ctx: OrgCTX, + wantErr: true, + }, + { + name: "success", + ctx: IamCTX, + want: &feature.ResetInstanceFeaturesResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.ResetInstanceFeatures(tt.ctx, &feature.ResetInstanceFeaturesRequest{}) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_GetInstanceFeatures(t *testing.T) { + _, err := Client.SetSystemFeatures(SystemCTX, &feature.SetSystemFeaturesRequest{ + OidcLegacyIntrospection: gu.Ptr(true), + }) + require.NoError(t, err) + t.Cleanup(func() { + _, err := Client.ResetSystemFeatures(SystemCTX, &feature.ResetSystemFeaturesRequest{}) + require.NoError(t, err) + }) + + type args struct { + ctx context.Context + req *feature.GetInstanceFeaturesRequest + } + tests := []struct { + name string + prepare func(t *testing.T) + args args + want *feature.GetInstanceFeaturesResponse + wantErr bool + }{ + { + name: "permission error", + args: args{ + ctx: OrgCTX, + req: &feature.GetInstanceFeaturesRequest{}, + }, + wantErr: true, + }, + { + name: "defaults, no inheritance", + args: args{ + ctx: IamCTX, + req: &feature.GetInstanceFeaturesRequest{}, + }, + want: &feature.GetInstanceFeaturesResponse{}, + }, + { + name: "defaults, inheritance", + args: args{ + ctx: IamCTX, + req: &feature.GetInstanceFeaturesRequest{ + Inheritance: true, + }, + }, + want: &feature.GetInstanceFeaturesResponse{ + LoginDefaultOrg: &feature.FeatureFlag{ + Enabled: false, + Source: feature.Source_SOURCE_UNSPECIFIED, + }, + OidcTriggerIntrospectionProjections: &feature.FeatureFlag{ + Enabled: false, + Source: feature.Source_SOURCE_UNSPECIFIED, + }, + OidcLegacyIntrospection: &feature.FeatureFlag{ + Enabled: true, + Source: feature.Source_SOURCE_SYSTEM, + }, + UserSchema: &feature.FeatureFlag{ + Enabled: false, + Source: feature.Source_SOURCE_UNSPECIFIED, + }, + Actions: &feature.FeatureFlag{ + Enabled: false, + Source: feature.Source_SOURCE_UNSPECIFIED, + }, + }, + }, + { + name: "some features, no inheritance", + prepare: func(t *testing.T) { + _, err := Client.SetInstanceFeatures(IamCTX, &feature.SetInstanceFeaturesRequest{ + LoginDefaultOrg: gu.Ptr(true), + OidcTriggerIntrospectionProjections: gu.Ptr(false), + UserSchema: gu.Ptr(true), + Actions: gu.Ptr(true), + }) + require.NoError(t, err) + }, + args: args{ + ctx: IamCTX, + req: &feature.GetInstanceFeaturesRequest{}, + }, + want: &feature.GetInstanceFeaturesResponse{ + LoginDefaultOrg: &feature.FeatureFlag{ + Enabled: true, + Source: feature.Source_SOURCE_INSTANCE, + }, + OidcTriggerIntrospectionProjections: &feature.FeatureFlag{ + Enabled: false, + Source: feature.Source_SOURCE_INSTANCE, + }, + UserSchema: &feature.FeatureFlag{ + Enabled: true, + Source: feature.Source_SOURCE_INSTANCE, + }, + Actions: &feature.FeatureFlag{ + Enabled: true, + Source: feature.Source_SOURCE_INSTANCE, + }, + }, + }, + { + name: "one feature, inheritance", + prepare: func(t *testing.T) { + _, err := Client.SetInstanceFeatures(IamCTX, &feature.SetInstanceFeaturesRequest{ + LoginDefaultOrg: gu.Ptr(true), + }) + require.NoError(t, err) + }, + args: args{ + ctx: IamCTX, + req: &feature.GetInstanceFeaturesRequest{ + Inheritance: true, + }, + }, + want: &feature.GetInstanceFeaturesResponse{ + LoginDefaultOrg: &feature.FeatureFlag{ + Enabled: true, + Source: feature.Source_SOURCE_INSTANCE, + }, + OidcTriggerIntrospectionProjections: &feature.FeatureFlag{ + Enabled: false, + Source: feature.Source_SOURCE_UNSPECIFIED, + }, + OidcLegacyIntrospection: &feature.FeatureFlag{ + Enabled: true, + Source: feature.Source_SOURCE_SYSTEM, + }, + UserSchema: &feature.FeatureFlag{ + Enabled: false, + Source: feature.Source_SOURCE_UNSPECIFIED, + }, + Actions: &feature.FeatureFlag{ + Enabled: false, + Source: feature.Source_SOURCE_UNSPECIFIED, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(func() { + // make sure we have a clean state after each test + _, err := Client.ResetInstanceFeatures(IamCTX, &feature.ResetInstanceFeaturesRequest{}) + require.NoError(t, err) + }) + if tt.prepare != nil { + tt.prepare(t) + } + got, err := Client.GetInstanceFeatures(tt.args.ctx, tt.args.req) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assertFeatureFlag(t, tt.want.LoginDefaultOrg, got.LoginDefaultOrg) + assertFeatureFlag(t, tt.want.OidcTriggerIntrospectionProjections, got.OidcTriggerIntrospectionProjections) + assertFeatureFlag(t, tt.want.OidcLegacyIntrospection, got.OidcLegacyIntrospection) + assertFeatureFlag(t, tt.want.UserSchema, got.UserSchema) + }) + } +} + +func assertFeatureFlag(t *testing.T, expected, actual *feature.FeatureFlag) { + t.Helper() + assert.Equal(t, expected.GetEnabled(), actual.GetEnabled(), "enabled") + assert.Equal(t, expected.GetSource(), actual.GetSource(), "source") +} diff --git a/internal/api/grpc/feature/v2beta/server.go b/internal/api/grpc/feature/v2beta/server.go new file mode 100644 index 0000000000..4208c4acfc --- /dev/null +++ b/internal/api/grpc/feature/v2beta/server.go @@ -0,0 +1,47 @@ +package feature + +import ( + "google.golang.org/grpc" + + "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" + feature "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta" +) + +type Server struct { + feature.UnimplementedFeatureServiceServer + command *command.Commands + query *query.Queries +} + +func CreateServer( + command *command.Commands, + query *query.Queries, +) *Server { + return &Server{ + command: command, + query: query, + } +} + +func (s *Server) RegisterServer(grpcServer *grpc.Server) { + feature.RegisterFeatureServiceServer(grpcServer, s) +} + +func (s *Server) AppName() string { + return feature.FeatureService_ServiceDesc.ServiceName +} + +func (s *Server) MethodPrefix() string { + return feature.FeatureService_ServiceDesc.ServiceName +} + +func (s *Server) AuthMethods() authz.MethodMapping { + return feature.FeatureService_AuthMethods +} + +func (s *Server) RegisterGateway() server.RegisterGatewayFunc { + return feature.RegisterFeatureServiceHandler +} diff --git a/internal/api/grpc/object/v2/converter.go b/internal/api/grpc/object/v2/converter.go index cc7e02c7fe..fe8aba5d6e 100644 --- a/internal/api/grpc/object/v2/converter.go +++ b/internal/api/grpc/object/v2/converter.go @@ -8,7 +8,7 @@ import ( "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/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/object/v2" ) func DomainToDetailsPb(objectDetail *domain.ObjectDetails) *object.Details { diff --git a/internal/api/grpc/object/v2beta/converter.go b/internal/api/grpc/object/v2beta/converter.go new file mode 100644 index 0000000000..cc7e02c7fe --- /dev/null +++ b/internal/api/grpc/object/v2beta/converter.go @@ -0,0 +1,72 @@ +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/v2beta" +) + +func DomainToDetailsPb(objectDetail *domain.ObjectDetails) *object.Details { + details := &object.Details{ + Sequence: objectDetail.Sequence, + ResourceOwner: objectDetail.ResourceOwner, + } + if !objectDetail.EventDate.IsZero() { + details.ChangeDate = timestamppb.New(objectDetail.EventDate) + } + return details +} + +func ToListDetails(response query.SearchResponse) *object.ListDetails { + details := &object.ListDetails{ + TotalResult: response.Count, + ProcessedSequence: response.Sequence, + Timestamp: timestamppb.New(response.EventCreatedAt), + } + + 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 +} + +func TextMethodToQuery(method object.TextQueryMethod) query.TextComparison { + switch method { + case object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS: + return query.TextEquals + case object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS_IGNORE_CASE: + return query.TextEqualsIgnoreCase + case object.TextQueryMethod_TEXT_QUERY_METHOD_STARTS_WITH: + return query.TextStartsWith + case object.TextQueryMethod_TEXT_QUERY_METHOD_STARTS_WITH_IGNORE_CASE: + return query.TextStartsWithIgnoreCase + case object.TextQueryMethod_TEXT_QUERY_METHOD_CONTAINS: + return query.TextContains + case object.TextQueryMethod_TEXT_QUERY_METHOD_CONTAINS_IGNORE_CASE: + return query.TextContainsIgnoreCase + case object.TextQueryMethod_TEXT_QUERY_METHOD_ENDS_WITH: + return query.TextEndsWith + case object.TextQueryMethod_TEXT_QUERY_METHOD_ENDS_WITH_IGNORE_CASE: + return query.TextEndsWithIgnoreCase + default: + return -1 + } +} diff --git a/internal/api/grpc/oidc/v2/oidc.go b/internal/api/grpc/oidc/v2/oidc.go index 7a13c7ea99..d84edd1c2f 100644 --- a/internal/api/grpc/oidc/v2/oidc.go +++ b/internal/api/grpc/oidc/v2/oidc.go @@ -15,7 +15,7 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/zerrors" - oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta" + oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2" ) func (s *Server) GetAuthRequest(ctx context.Context, req *oidc_pb.GetAuthRequestRequest) (*oidc_pb.GetAuthRequestResponse, error) { diff --git a/internal/api/grpc/oidc/v2/oidc_integration_test.go b/internal/api/grpc/oidc/v2/oidc_integration_test.go index 27884e80a5..901e667b34 100644 --- a/internal/api/grpc/oidc/v2/oidc_integration_test.go +++ b/internal/api/grpc/oidc/v2/oidc_integration_test.go @@ -16,10 +16,10 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/integration" - object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" - oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta" - session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta" - user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/object/v2" + oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2" + "github.com/zitadel/zitadel/pkg/grpc/session/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) var ( diff --git a/internal/api/grpc/oidc/v2/oidc_test.go b/internal/api/grpc/oidc/v2/oidc_test.go index 27dcdf7fb7..c7d06c4a61 100644 --- a/internal/api/grpc/oidc/v2/oidc_test.go +++ b/internal/api/grpc/oidc/v2/oidc_test.go @@ -12,7 +12,7 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" - oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta" + oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2" ) func Test_authRequestToPb(t *testing.T) { diff --git a/internal/api/grpc/oidc/v2/server.go b/internal/api/grpc/oidc/v2/server.go index 7595ae927e..28c7134904 100644 --- a/internal/api/grpc/oidc/v2/server.go +++ b/internal/api/grpc/oidc/v2/server.go @@ -8,7 +8,7 @@ import ( "github.com/zitadel/zitadel/internal/api/oidc" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/query" - oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta" + oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2" ) var _ oidc_pb.OIDCServiceServer = (*Server)(nil) diff --git a/internal/api/grpc/oidc/v2beta/oidc.go b/internal/api/grpc/oidc/v2beta/oidc.go new file mode 100644 index 0000000000..d504e411f0 --- /dev/null +++ b/internal/api/grpc/oidc/v2beta/oidc.go @@ -0,0 +1,204 @@ +package oidc + +import ( + "context" + + "github.com/zitadel/logging" + "github.com/zitadel/oidc/v3/pkg/op" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/authz" + object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta" + "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/api/oidc" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta" +) + +func (s *Server) GetAuthRequest(ctx context.Context, req *oidc_pb.GetAuthRequestRequest) (*oidc_pb.GetAuthRequestResponse, error) { + authRequest, err := s.query.AuthRequestByID(ctx, true, req.GetAuthRequestId(), true) + if err != nil { + logging.WithError(err).Error("query authRequest by ID") + return nil, err + } + return &oidc_pb.GetAuthRequestResponse{ + AuthRequest: authRequestToPb(authRequest), + }, nil +} + +func authRequestToPb(a *query.AuthRequest) *oidc_pb.AuthRequest { + pba := &oidc_pb.AuthRequest{ + Id: a.ID, + CreationDate: timestamppb.New(a.CreationDate), + ClientId: a.ClientID, + Scope: a.Scope, + RedirectUri: a.RedirectURI, + Prompt: promptsToPb(a.Prompt), + UiLocales: a.UiLocales, + LoginHint: a.LoginHint, + HintUserId: a.HintUserID, + } + if a.MaxAge != nil { + pba.MaxAge = durationpb.New(*a.MaxAge) + } + return pba +} + +func promptsToPb(promps []domain.Prompt) []oidc_pb.Prompt { + out := make([]oidc_pb.Prompt, len(promps)) + for i, p := range promps { + out[i] = promptToPb(p) + } + return out +} + +func promptToPb(p domain.Prompt) oidc_pb.Prompt { + switch p { + case domain.PromptUnspecified: + return oidc_pb.Prompt_PROMPT_UNSPECIFIED + case domain.PromptNone: + return oidc_pb.Prompt_PROMPT_NONE + case domain.PromptLogin: + return oidc_pb.Prompt_PROMPT_LOGIN + case domain.PromptConsent: + return oidc_pb.Prompt_PROMPT_CONSENT + case domain.PromptSelectAccount: + return oidc_pb.Prompt_PROMPT_SELECT_ACCOUNT + case domain.PromptCreate: + return oidc_pb.Prompt_PROMPT_CREATE + default: + return oidc_pb.Prompt_PROMPT_UNSPECIFIED + } +} + +func (s *Server) CreateCallback(ctx context.Context, req *oidc_pb.CreateCallbackRequest) (*oidc_pb.CreateCallbackResponse, error) { + switch v := req.GetCallbackKind().(type) { + case *oidc_pb.CreateCallbackRequest_Error: + return s.failAuthRequest(ctx, req.GetAuthRequestId(), v.Error) + case *oidc_pb.CreateCallbackRequest_Session: + return s.linkSessionToAuthRequest(ctx, req.GetAuthRequestId(), v.Session) + default: + return nil, zerrors.ThrowUnimplementedf(nil, "OIDCv2-zee7A", "verification oneOf %T in method CreateCallback not implemented", v) + } +} + +func (s *Server) failAuthRequest(ctx context.Context, authRequestID string, ae *oidc_pb.AuthorizationError) (*oidc_pb.CreateCallbackResponse, error) { + details, aar, err := s.command.FailAuthRequest(ctx, authRequestID, errorReasonToDomain(ae.GetError())) + if err != nil { + return nil, err + } + authReq := &oidc.AuthRequestV2{CurrentAuthRequest: aar} + callback, err := oidc.CreateErrorCallbackURL(authReq, errorReasonToOIDC(ae.GetError()), ae.GetErrorDescription(), ae.GetErrorUri(), s.op.Provider()) + if err != nil { + return nil, err + } + return &oidc_pb.CreateCallbackResponse{ + Details: object.DomainToDetailsPb(details), + CallbackUrl: callback, + }, nil +} + +func (s *Server) linkSessionToAuthRequest(ctx context.Context, authRequestID string, session *oidc_pb.Session) (*oidc_pb.CreateCallbackResponse, error) { + details, aar, err := s.command.LinkSessionToAuthRequest(ctx, authRequestID, session.GetSessionId(), session.GetSessionToken(), true) + if err != nil { + return nil, err + } + authReq := &oidc.AuthRequestV2{CurrentAuthRequest: aar} + ctx = op.ContextWithIssuer(ctx, http.BuildOrigin(authz.GetInstance(ctx).RequestedHost(), s.externalSecure)) + var callback string + if aar.ResponseType == domain.OIDCResponseTypeCode { + callback, err = oidc.CreateCodeCallbackURL(ctx, authReq, s.op.Provider()) + } else { + callback, err = s.op.CreateTokenCallbackURL(ctx, authReq) + } + if err != nil { + return nil, err + } + return &oidc_pb.CreateCallbackResponse{ + Details: object.DomainToDetailsPb(details), + CallbackUrl: callback, + }, nil +} + +func errorReasonToDomain(errorReason oidc_pb.ErrorReason) domain.OIDCErrorReason { + switch errorReason { + case oidc_pb.ErrorReason_ERROR_REASON_UNSPECIFIED: + return domain.OIDCErrorReasonUnspecified + case oidc_pb.ErrorReason_ERROR_REASON_INVALID_REQUEST: + return domain.OIDCErrorReasonInvalidRequest + case oidc_pb.ErrorReason_ERROR_REASON_UNAUTHORIZED_CLIENT: + return domain.OIDCErrorReasonUnauthorizedClient + case oidc_pb.ErrorReason_ERROR_REASON_ACCESS_DENIED: + return domain.OIDCErrorReasonAccessDenied + case oidc_pb.ErrorReason_ERROR_REASON_UNSUPPORTED_RESPONSE_TYPE: + return domain.OIDCErrorReasonUnsupportedResponseType + case oidc_pb.ErrorReason_ERROR_REASON_INVALID_SCOPE: + return domain.OIDCErrorReasonInvalidScope + case oidc_pb.ErrorReason_ERROR_REASON_SERVER_ERROR: + return domain.OIDCErrorReasonServerError + case oidc_pb.ErrorReason_ERROR_REASON_TEMPORARY_UNAVAILABLE: + return domain.OIDCErrorReasonTemporaryUnavailable + case oidc_pb.ErrorReason_ERROR_REASON_INTERACTION_REQUIRED: + return domain.OIDCErrorReasonInteractionRequired + case oidc_pb.ErrorReason_ERROR_REASON_LOGIN_REQUIRED: + return domain.OIDCErrorReasonLoginRequired + case oidc_pb.ErrorReason_ERROR_REASON_ACCOUNT_SELECTION_REQUIRED: + return domain.OIDCErrorReasonAccountSelectionRequired + case oidc_pb.ErrorReason_ERROR_REASON_CONSENT_REQUIRED: + return domain.OIDCErrorReasonConsentRequired + case oidc_pb.ErrorReason_ERROR_REASON_INVALID_REQUEST_URI: + return domain.OIDCErrorReasonInvalidRequestURI + case oidc_pb.ErrorReason_ERROR_REASON_INVALID_REQUEST_OBJECT: + return domain.OIDCErrorReasonInvalidRequestObject + case oidc_pb.ErrorReason_ERROR_REASON_REQUEST_NOT_SUPPORTED: + return domain.OIDCErrorReasonRequestNotSupported + case oidc_pb.ErrorReason_ERROR_REASON_REQUEST_URI_NOT_SUPPORTED: + return domain.OIDCErrorReasonRequestURINotSupported + case oidc_pb.ErrorReason_ERROR_REASON_REGISTRATION_NOT_SUPPORTED: + return domain.OIDCErrorReasonRegistrationNotSupported + default: + return domain.OIDCErrorReasonUnspecified + } +} + +func errorReasonToOIDC(reason oidc_pb.ErrorReason) string { + switch reason { + case oidc_pb.ErrorReason_ERROR_REASON_INVALID_REQUEST: + return "invalid_request" + case oidc_pb.ErrorReason_ERROR_REASON_UNAUTHORIZED_CLIENT: + return "unauthorized_client" + case oidc_pb.ErrorReason_ERROR_REASON_ACCESS_DENIED: + return "access_denied" + case oidc_pb.ErrorReason_ERROR_REASON_UNSUPPORTED_RESPONSE_TYPE: + return "unsupported_response_type" + case oidc_pb.ErrorReason_ERROR_REASON_INVALID_SCOPE: + return "invalid_scope" + case oidc_pb.ErrorReason_ERROR_REASON_TEMPORARY_UNAVAILABLE: + return "temporarily_unavailable" + case oidc_pb.ErrorReason_ERROR_REASON_INTERACTION_REQUIRED: + return "interaction_required" + case oidc_pb.ErrorReason_ERROR_REASON_LOGIN_REQUIRED: + return "login_required" + case oidc_pb.ErrorReason_ERROR_REASON_ACCOUNT_SELECTION_REQUIRED: + return "account_selection_required" + case oidc_pb.ErrorReason_ERROR_REASON_CONSENT_REQUIRED: + return "consent_required" + case oidc_pb.ErrorReason_ERROR_REASON_INVALID_REQUEST_URI: + return "invalid_request_uri" + case oidc_pb.ErrorReason_ERROR_REASON_INVALID_REQUEST_OBJECT: + return "invalid_request_object" + case oidc_pb.ErrorReason_ERROR_REASON_REQUEST_NOT_SUPPORTED: + return "request_not_supported" + case oidc_pb.ErrorReason_ERROR_REASON_REQUEST_URI_NOT_SUPPORTED: + return "request_uri_not_supported" + case oidc_pb.ErrorReason_ERROR_REASON_REGISTRATION_NOT_SUPPORTED: + return "registration_not_supported" + case oidc_pb.ErrorReason_ERROR_REASON_UNSPECIFIED, oidc_pb.ErrorReason_ERROR_REASON_SERVER_ERROR: + fallthrough + default: + return "server_error" + } +} diff --git a/internal/api/grpc/oidc/v2beta/oidc_integration_test.go b/internal/api/grpc/oidc/v2beta/oidc_integration_test.go new file mode 100644 index 0000000000..574718212a --- /dev/null +++ b/internal/api/grpc/oidc/v2beta/oidc_integration_test.go @@ -0,0 +1,258 @@ +//go:build integration + +package oidc_test + +import ( + "context" + "net/url" + "os" + "regexp" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/integration" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" + oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta" + session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +var ( + CTX context.Context + Tester *integration.Tester + Client oidc_pb.OIDCServiceClient + User *user.AddHumanUserResponse +) + +const ( + redirectURI = "oidcintegrationtest://callback" + redirectURIImplicit = "http://localhost:9999/callback" + logoutRedirectURI = "oidcintegrationtest://logged-out" +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, _, cancel := integration.Contexts(5 * time.Minute) + defer cancel() + + Tester = integration.NewTester(ctx) + defer Tester.Done() + Client = Tester.Client.OIDCv2beta + + CTX = Tester.WithAuthorization(ctx, integration.OrgOwner) + User = Tester.CreateHumanUser(CTX) + return m.Run() + }()) +} + +func TestServer_GetAuthRequest(t *testing.T) { + project, err := Tester.CreateProject(CTX) + require.NoError(t, err) + client, err := Tester.CreateOIDCNativeClient(CTX, redirectURI, logoutRedirectURI, project.GetId(), false) + require.NoError(t, err) + authRequestID, err := Tester.CreateOIDCAuthRequest(CTX, client.GetClientId(), Tester.Users[integration.FirstInstanceUsersKey][integration.OrgOwner].ID, redirectURI) + require.NoError(t, err) + now := time.Now() + + tests := []struct { + name string + AuthRequestID string + want *oidc_pb.GetAuthRequestResponse + wantErr bool + }{ + { + name: "Not found", + AuthRequestID: "123", + wantErr: true, + }, + { + name: "success", + AuthRequestID: authRequestID, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.GetAuthRequest(CTX, &oidc_pb.GetAuthRequestRequest{ + AuthRequestId: tt.AuthRequestID, + }) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + authRequest := got.GetAuthRequest() + assert.NotNil(t, authRequest) + assert.Equal(t, authRequestID, authRequest.GetId()) + assert.WithinRange(t, authRequest.GetCreationDate().AsTime(), now.Add(-time.Second), now.Add(time.Second)) + assert.Contains(t, authRequest.GetScope(), "openid") + }) + } +} + +func TestServer_CreateCallback(t *testing.T) { + project, err := Tester.CreateProject(CTX) + require.NoError(t, err) + client, err := Tester.CreateOIDCNativeClient(CTX, redirectURI, logoutRedirectURI, project.GetId(), false) + require.NoError(t, err) + sessionResp, err := Tester.Client.SessionV2beta.CreateSession(CTX, &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: Tester.Users[integration.FirstInstanceUsersKey][integration.OrgOwner].ID, + }, + }, + }, + }) + require.NoError(t, err) + + tests := []struct { + name string + req *oidc_pb.CreateCallbackRequest + AuthError string + want *oidc_pb.CreateCallbackResponse + wantURL *url.URL + wantErr bool + }{ + { + name: "Not found", + req: &oidc_pb.CreateCallbackRequest{ + AuthRequestId: "123", + CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionResp.GetSessionId(), + SessionToken: sessionResp.GetSessionToken(), + }, + }, + }, + wantErr: true, + }, + { + name: "session not found", + req: &oidc_pb.CreateCallbackRequest{ + AuthRequestId: func() string { + authRequestID, err := Tester.CreateOIDCAuthRequest(CTX, client.GetClientId(), Tester.Users[integration.FirstInstanceUsersKey][integration.OrgOwner].ID, redirectURI) + require.NoError(t, err) + return authRequestID + }(), + CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: "foo", + SessionToken: "bar", + }, + }, + }, + wantErr: true, + }, + { + name: "session token invalid", + req: &oidc_pb.CreateCallbackRequest{ + AuthRequestId: func() string { + authRequestID, err := Tester.CreateOIDCAuthRequest(CTX, client.GetClientId(), Tester.Users[integration.FirstInstanceUsersKey][integration.OrgOwner].ID, redirectURI) + require.NoError(t, err) + return authRequestID + }(), + CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionResp.GetSessionId(), + SessionToken: "bar", + }, + }, + }, + wantErr: true, + }, + { + name: "fail callback", + req: &oidc_pb.CreateCallbackRequest{ + AuthRequestId: func() string { + authRequestID, err := Tester.CreateOIDCAuthRequest(CTX, client.GetClientId(), Tester.Users[integration.FirstInstanceUsersKey][integration.OrgOwner].ID, redirectURI) + require.NoError(t, err) + return authRequestID + }(), + CallbackKind: &oidc_pb.CreateCallbackRequest_Error{ + Error: &oidc_pb.AuthorizationError{ + Error: oidc_pb.ErrorReason_ERROR_REASON_ACCESS_DENIED, + ErrorDescription: gu.Ptr("nope"), + ErrorUri: gu.Ptr("https://example.com/docs"), + }, + }, + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: regexp.QuoteMeta(`oidcintegrationtest://callback?error=access_denied&error_description=nope&error_uri=https%3A%2F%2Fexample.com%2Fdocs&state=state`), + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + }, + wantErr: false, + }, + { + name: "code callback", + req: &oidc_pb.CreateCallbackRequest{ + AuthRequestId: func() string { + authRequestID, err := Tester.CreateOIDCAuthRequest(CTX, client.GetClientId(), Tester.Users[integration.FirstInstanceUsersKey][integration.OrgOwner].ID, redirectURI) + require.NoError(t, err) + return authRequestID + }(), + CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionResp.GetSessionId(), + SessionToken: sessionResp.GetSessionToken(), + }, + }, + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `oidcintegrationtest:\/\/callback\?code=(.*)&state=state`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + }, + wantErr: false, + }, + { + name: "implicit", + req: &oidc_pb.CreateCallbackRequest{ + AuthRequestId: func() string { + client, err := Tester.CreateOIDCImplicitFlowClient(CTX, redirectURIImplicit) + require.NoError(t, err) + authRequestID, err := Tester.CreateOIDCAuthRequestImplicit(CTX, client.GetClientId(), Tester.Users[integration.FirstInstanceUsersKey][integration.OrgOwner].ID, redirectURIImplicit) + require.NoError(t, err) + return authRequestID + }(), + CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionResp.GetSessionId(), + SessionToken: sessionResp.GetSessionToken(), + }, + }, + }, + want: &oidc_pb.CreateCallbackResponse{ + CallbackUrl: `http:\/\/localhost:9999\/callback#access_token=(.*)&expires_in=(.*)&id_token=(.*)&state=state&token_type=Bearer`, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.CreateCallback(CTX, tt.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + integration.AssertDetails(t, tt.want, got) + if tt.want != nil { + assert.Regexp(t, regexp.MustCompile(tt.want.CallbackUrl), got.GetCallbackUrl()) + } + }) + } +} diff --git a/internal/api/grpc/oidc/v2beta/oidc_test.go b/internal/api/grpc/oidc/v2beta/oidc_test.go new file mode 100644 index 0000000000..27dcdf7fb7 --- /dev/null +++ b/internal/api/grpc/oidc/v2beta/oidc_test.go @@ -0,0 +1,150 @@ +package oidc + +import ( + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta" +) + +func Test_authRequestToPb(t *testing.T) { + now := time.Now() + arg := &query.AuthRequest{ + ID: "authID", + CreationDate: now, + ClientID: "clientID", + Scope: []string{"a", "b", "c"}, + RedirectURI: "callbackURI", + Prompt: []domain.Prompt{ + domain.PromptUnspecified, + domain.PromptNone, + domain.PromptLogin, + domain.PromptConsent, + domain.PromptSelectAccount, + domain.PromptCreate, + 999, + }, + UiLocales: []string{"en", "fi"}, + LoginHint: gu.Ptr("foo@bar.com"), + MaxAge: gu.Ptr(time.Minute), + HintUserID: gu.Ptr("userID"), + } + want := &oidc_pb.AuthRequest{ + Id: "authID", + CreationDate: timestamppb.New(now), + ClientId: "clientID", + RedirectUri: "callbackURI", + Prompt: []oidc_pb.Prompt{ + oidc_pb.Prompt_PROMPT_UNSPECIFIED, + oidc_pb.Prompt_PROMPT_NONE, + oidc_pb.Prompt_PROMPT_LOGIN, + oidc_pb.Prompt_PROMPT_CONSENT, + oidc_pb.Prompt_PROMPT_SELECT_ACCOUNT, + oidc_pb.Prompt_PROMPT_CREATE, + oidc_pb.Prompt_PROMPT_UNSPECIFIED, + }, + UiLocales: []string{"en", "fi"}, + Scope: []string{"a", "b", "c"}, + LoginHint: gu.Ptr("foo@bar.com"), + MaxAge: durationpb.New(time.Minute), + HintUserId: gu.Ptr("userID"), + } + got := authRequestToPb(arg) + if !proto.Equal(want, got) { + t.Errorf("authRequestToPb() =\n%v\nwant\n%v\n", got, want) + } +} + +func Test_errorReasonToOIDC(t *testing.T) { + tests := []struct { + reason oidc_pb.ErrorReason + want string + }{ + { + reason: oidc_pb.ErrorReason_ERROR_REASON_UNSPECIFIED, + want: "server_error", + }, + { + reason: oidc_pb.ErrorReason_ERROR_REASON_INVALID_REQUEST, + want: "invalid_request", + }, + { + reason: oidc_pb.ErrorReason_ERROR_REASON_UNAUTHORIZED_CLIENT, + want: "unauthorized_client", + }, + { + reason: oidc_pb.ErrorReason_ERROR_REASON_ACCESS_DENIED, + want: "access_denied", + }, + { + reason: oidc_pb.ErrorReason_ERROR_REASON_UNSUPPORTED_RESPONSE_TYPE, + want: "unsupported_response_type", + }, + { + reason: oidc_pb.ErrorReason_ERROR_REASON_INVALID_SCOPE, + want: "invalid_scope", + }, + { + reason: oidc_pb.ErrorReason_ERROR_REASON_SERVER_ERROR, + want: "server_error", + }, + { + reason: oidc_pb.ErrorReason_ERROR_REASON_TEMPORARY_UNAVAILABLE, + want: "temporarily_unavailable", + }, + { + reason: oidc_pb.ErrorReason_ERROR_REASON_INTERACTION_REQUIRED, + want: "interaction_required", + }, + { + reason: oidc_pb.ErrorReason_ERROR_REASON_LOGIN_REQUIRED, + want: "login_required", + }, + { + reason: oidc_pb.ErrorReason_ERROR_REASON_ACCOUNT_SELECTION_REQUIRED, + want: "account_selection_required", + }, + { + reason: oidc_pb.ErrorReason_ERROR_REASON_CONSENT_REQUIRED, + want: "consent_required", + }, + { + reason: oidc_pb.ErrorReason_ERROR_REASON_INVALID_REQUEST_URI, + want: "invalid_request_uri", + }, + { + reason: oidc_pb.ErrorReason_ERROR_REASON_INVALID_REQUEST_OBJECT, + want: "invalid_request_object", + }, + { + reason: oidc_pb.ErrorReason_ERROR_REASON_REQUEST_NOT_SUPPORTED, + want: "request_not_supported", + }, + { + reason: oidc_pb.ErrorReason_ERROR_REASON_REQUEST_URI_NOT_SUPPORTED, + want: "request_uri_not_supported", + }, + { + reason: oidc_pb.ErrorReason_ERROR_REASON_REGISTRATION_NOT_SUPPORTED, + want: "registration_not_supported", + }, + { + reason: 99999, + want: "server_error", + }, + } + for _, tt := range tests { + t.Run(tt.reason.String(), func(t *testing.T) { + got := errorReasonToOIDC(tt.reason) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/api/grpc/oidc/v2beta/server.go b/internal/api/grpc/oidc/v2beta/server.go new file mode 100644 index 0000000000..7595ae927e --- /dev/null +++ b/internal/api/grpc/oidc/v2beta/server.go @@ -0,0 +1,59 @@ +package oidc + +import ( + "google.golang.org/grpc" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/grpc/server" + "github.com/zitadel/zitadel/internal/api/oidc" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/query" + oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta" +) + +var _ oidc_pb.OIDCServiceServer = (*Server)(nil) + +type Server struct { + oidc_pb.UnimplementedOIDCServiceServer + command *command.Commands + query *query.Queries + + op *oidc.Server + externalSecure bool +} + +type Config struct{} + +func CreateServer( + command *command.Commands, + query *query.Queries, + op *oidc.Server, + externalSecure bool, +) *Server { + return &Server{ + command: command, + query: query, + op: op, + externalSecure: externalSecure, + } +} + +func (s *Server) RegisterServer(grpcServer *grpc.Server) { + oidc_pb.RegisterOIDCServiceServer(grpcServer, s) +} + +func (s *Server) AppName() string { + return oidc_pb.OIDCService_ServiceDesc.ServiceName +} + +func (s *Server) MethodPrefix() string { + return oidc_pb.OIDCService_ServiceDesc.ServiceName +} + +func (s *Server) AuthMethods() authz.MethodMapping { + return oidc_pb.OIDCService_AuthMethods +} + +func (s *Server) RegisterGateway() server.RegisterGatewayFunc { + return oidc_pb.RegisterOIDCServiceHandler +} diff --git a/internal/api/grpc/org/v2/org.go b/internal/api/grpc/org/v2/org.go index 1fc0ca8aad..be830bc7b5 100644 --- a/internal/api/grpc/org/v2/org.go +++ b/internal/api/grpc/org/v2/org.go @@ -7,7 +7,7 @@ import ( "github.com/zitadel/zitadel/internal/api/grpc/user/v2" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/zerrors" - org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/org/v2" ) func (s *Server) AddOrganization(ctx context.Context, request *org.AddOrganizationRequest) (*org.AddOrganizationResponse, error) { diff --git a/internal/api/grpc/org/v2/org_integration_test.go b/internal/api/grpc/org/v2/org_integration_test.go index 7276e8d5eb..9f3f9fa64b 100644 --- a/internal/api/grpc/org/v2/org_integration_test.go +++ b/internal/api/grpc/org/v2/org_integration_test.go @@ -14,8 +14,8 @@ import ( "github.com/stretchr/testify/require" "github.com/zitadel/zitadel/internal/integration" - org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" - user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/org/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) var ( diff --git a/internal/api/grpc/org/v2/org_test.go b/internal/api/grpc/org/v2/org_test.go index 5024b59c1d..451c4006b3 100644 --- a/internal/api/grpc/org/v2/org_test.go +++ b/internal/api/grpc/org/v2/org_test.go @@ -12,9 +12,9 @@ import ( "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/zerrors" - object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" - org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" - user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/object/v2" + "github.com/zitadel/zitadel/pkg/grpc/org/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) func Test_addOrganizationRequestToCommand(t *testing.T) { diff --git a/internal/api/grpc/org/v2/server.go b/internal/api/grpc/org/v2/server.go index 89dba81702..36588f3eb7 100644 --- a/internal/api/grpc/org/v2/server.go +++ b/internal/api/grpc/org/v2/server.go @@ -8,7 +8,7 @@ import ( "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" - org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/org/v2" ) var _ org.OrganizationServiceServer = (*Server)(nil) diff --git a/internal/api/grpc/org/v2beta/org.go b/internal/api/grpc/org/v2beta/org.go new file mode 100644 index 0000000000..ab2da2b766 --- /dev/null +++ b/internal/api/grpc/org/v2beta/org.go @@ -0,0 +1,83 @@ +package org + +import ( + "context" + + object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta" + user "github.com/zitadel/zitadel/internal/api/grpc/user/v2beta" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/zerrors" + org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" +) + +func (s *Server) AddOrganization(ctx context.Context, request *org.AddOrganizationRequest) (*org.AddOrganizationResponse, error) { + orgSetup, err := addOrganizationRequestToCommand(request) + if err != nil { + return nil, err + } + createdOrg, err := s.command.SetUpOrg(ctx, orgSetup, false) + if err != nil { + return nil, err + } + return createdOrganizationToPb(createdOrg) +} + +func addOrganizationRequestToCommand(request *org.AddOrganizationRequest) (*command.OrgSetup, error) { + admins, err := addOrganizationRequestAdminsToCommand(request.GetAdmins()) + if err != nil { + return nil, err + } + return &command.OrgSetup{ + Name: request.GetName(), + CustomDomain: "", + Admins: admins, + }, nil +} + +func addOrganizationRequestAdminsToCommand(requestAdmins []*org.AddOrganizationRequest_Admin) (admins []*command.OrgSetupAdmin, err error) { + admins = make([]*command.OrgSetupAdmin, len(requestAdmins)) + for i, admin := range requestAdmins { + admins[i], err = addOrganizationRequestAdminToCommand(admin) + if err != nil { + return nil, err + } + } + return admins, nil +} + +func addOrganizationRequestAdminToCommand(admin *org.AddOrganizationRequest_Admin) (*command.OrgSetupAdmin, error) { + switch a := admin.GetUserType().(type) { + case *org.AddOrganizationRequest_Admin_UserId: + return &command.OrgSetupAdmin{ + ID: a.UserId, + Roles: admin.GetRoles(), + }, nil + case *org.AddOrganizationRequest_Admin_Human: + human, err := user.AddUserRequestToAddHuman(a.Human) + if err != nil { + return nil, err + } + return &command.OrgSetupAdmin{ + Human: human, + Roles: admin.GetRoles(), + }, nil + default: + return nil, zerrors.ThrowUnimplementedf(nil, "ORGv2-SD2r1", "userType oneOf %T in method AddOrganization not implemented", a) + } +} + +func createdOrganizationToPb(createdOrg *command.CreatedOrg) (_ *org.AddOrganizationResponse, err error) { + admins := make([]*org.AddOrganizationResponse_CreatedAdmin, len(createdOrg.CreatedAdmins)) + for i, admin := range createdOrg.CreatedAdmins { + admins[i] = &org.AddOrganizationResponse_CreatedAdmin{ + UserId: admin.ID, + EmailCode: admin.EmailCode, + PhoneCode: admin.PhoneCode, + } + } + return &org.AddOrganizationResponse{ + Details: object.DomainToDetailsPb(createdOrg.ObjectDetails), + OrganizationId: createdOrg.ObjectDetails.ResourceOwner, + CreatedAdmins: admins, + }, nil +} diff --git a/internal/api/grpc/org/v2beta/org_integration_test.go b/internal/api/grpc/org/v2beta/org_integration_test.go new file mode 100644 index 0000000000..97f7e0a719 --- /dev/null +++ b/internal/api/grpc/org/v2beta/org_integration_test.go @@ -0,0 +1,207 @@ +//go:build integration + +package org_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" + org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" + user_v2beta "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" +) + +var ( + CTX context.Context + Tester *integration.Tester + Client org.OrganizationServiceClient + User *user.AddHumanUserResponse +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, errCtx, cancel := integration.Contexts(5 * time.Minute) + defer cancel() + + Tester = integration.NewTester(ctx) + defer Tester.Done() + Client = Tester.Client.OrgV2beta + + CTX, _ = Tester.WithAuthorization(ctx, integration.IAMOwner), errCtx + User = Tester.CreateHumanUser(CTX) + return m.Run() + }()) +} + +func TestServer_AddOrganization(t *testing.T) { + idpID := Tester.AddGenericOAuthProvider(t, CTX) + + tests := []struct { + name string + ctx context.Context + req *org.AddOrganizationRequest + want *org.AddOrganizationResponse + wantErr bool + }{ + { + name: "missing permission", + ctx: Tester.WithAuthorization(context.Background(), integration.OrgOwner), + req: &org.AddOrganizationRequest{ + Name: "name", + Admins: nil, + }, + wantErr: true, + }, + { + name: "empty name", + ctx: CTX, + req: &org.AddOrganizationRequest{ + Name: "", + Admins: nil, + }, + wantErr: true, + }, + { + name: "invalid admin type", + ctx: CTX, + req: &org.AddOrganizationRequest{ + Name: fmt.Sprintf("%d", time.Now().UnixNano()), + Admins: []*org.AddOrganizationRequest_Admin{ + {}, + }, + }, + wantErr: true, + }, + { + name: "admin with init", + ctx: CTX, + req: &org.AddOrganizationRequest{ + Name: fmt.Sprintf("%d", time.Now().UnixNano()), + Admins: []*org.AddOrganizationRequest_Admin{ + { + UserType: &org.AddOrganizationRequest_Admin_Human{ + Human: &user_v2beta.AddHumanUserRequest{ + Profile: &user_v2beta.SetHumanProfile{ + GivenName: "firstname", + FamilyName: "lastname", + }, + Email: &user_v2beta.SetHumanEmail{ + Email: fmt.Sprintf("%d@mouse.com", time.Now().UnixNano()), + Verification: &user_v2beta.SetHumanEmail_ReturnCode{ + ReturnCode: &user_v2beta.ReturnEmailVerificationCode{}, + }, + }, + }, + }, + }, + }, + }, + want: &org.AddOrganizationResponse{ + OrganizationId: integration.NotEmpty, + CreatedAdmins: []*org.AddOrganizationResponse_CreatedAdmin{ + { + UserId: integration.NotEmpty, + EmailCode: gu.Ptr(integration.NotEmpty), + PhoneCode: nil, + }, + }, + }, + }, + { + name: "existing user and new human with idp", + ctx: CTX, + req: &org.AddOrganizationRequest{ + Name: fmt.Sprintf("%d", time.Now().UnixNano()), + Admins: []*org.AddOrganizationRequest_Admin{ + { + UserType: &org.AddOrganizationRequest_Admin_UserId{UserId: User.GetUserId()}, + }, + { + UserType: &org.AddOrganizationRequest_Admin_Human{ + Human: &user_v2beta.AddHumanUserRequest{ + Profile: &user_v2beta.SetHumanProfile{ + GivenName: "firstname", + FamilyName: "lastname", + }, + Email: &user_v2beta.SetHumanEmail{ + Email: fmt.Sprintf("%d@mouse.com", time.Now().UnixNano()), + Verification: &user_v2beta.SetHumanEmail_IsVerified{ + IsVerified: true, + }, + }, + IdpLinks: []*user_v2beta.IDPLink{ + { + IdpId: idpID, + UserId: "userID", + UserName: "username", + }, + }, + }, + }, + }, + }, + }, + want: &org.AddOrganizationResponse{ + CreatedAdmins: []*org.AddOrganizationResponse_CreatedAdmin{ + // a single admin is expected, because the first provided already exists + { + UserId: integration.NotEmpty, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.AddOrganization(tt.ctx, tt.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + // check details + assert.NotZero(t, got.GetDetails().GetSequence()) + gotCD := got.GetDetails().GetChangeDate().AsTime() + now := time.Now() + assert.WithinRange(t, gotCD, now.Add(-time.Minute), now.Add(time.Minute)) + assert.NotEmpty(t, got.GetDetails().GetResourceOwner()) + + // organization id must be the same as the resourceOwner + assert.Equal(t, got.GetDetails().GetResourceOwner(), got.GetOrganizationId()) + + // check the admins + require.Len(t, got.GetCreatedAdmins(), len(tt.want.GetCreatedAdmins())) + for i, admin := range tt.want.GetCreatedAdmins() { + gotAdmin := got.GetCreatedAdmins()[i] + assertCreatedAdmin(t, admin, gotAdmin) + } + }) + } +} + +func assertCreatedAdmin(t *testing.T, expected, got *org.AddOrganizationResponse_CreatedAdmin) { + if expected.GetUserId() != "" { + assert.NotEmpty(t, got.GetUserId()) + } else { + assert.Empty(t, got.GetUserId()) + } + if expected.GetEmailCode() != "" { + assert.NotEmpty(t, got.GetEmailCode()) + } else { + assert.Empty(t, got.GetEmailCode()) + } + if expected.GetPhoneCode() != "" { + assert.NotEmpty(t, got.GetPhoneCode()) + } else { + assert.Empty(t, got.GetPhoneCode()) + } +} diff --git a/internal/api/grpc/org/v2beta/org_test.go b/internal/api/grpc/org/v2beta/org_test.go new file mode 100644 index 0000000000..5024b59c1d --- /dev/null +++ b/internal/api/grpc/org/v2beta/org_test.go @@ -0,0 +1,172 @@ +package org + +import ( + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/zerrors" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" + org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" +) + +func Test_addOrganizationRequestToCommand(t *testing.T) { + type args struct { + request *org.AddOrganizationRequest + } + tests := []struct { + name string + args args + want *command.OrgSetup + wantErr error + }{ + { + name: "nil user", + args: args{ + request: &org.AddOrganizationRequest{ + Name: "name", + Admins: []*org.AddOrganizationRequest_Admin{ + {}, + }, + }, + }, + wantErr: zerrors.ThrowUnimplementedf(nil, "ORGv2-SD2r1", "userType oneOf %T in method AddOrganization not implemented", nil), + }, + { + name: "user ID", + args: args{ + request: &org.AddOrganizationRequest{ + Name: "name", + Admins: []*org.AddOrganizationRequest_Admin{ + { + UserType: &org.AddOrganizationRequest_Admin_UserId{ + UserId: "userID", + }, + Roles: nil, + }, + }, + }, + }, + want: &command.OrgSetup{ + Name: "name", + CustomDomain: "", + Admins: []*command.OrgSetupAdmin{ + { + ID: "userID", + }, + }, + }, + }, + { + name: "human user", + args: args{ + request: &org.AddOrganizationRequest{ + Name: "name", + Admins: []*org.AddOrganizationRequest_Admin{ + { + UserType: &org.AddOrganizationRequest_Admin_Human{ + Human: &user.AddHumanUserRequest{ + Profile: &user.SetHumanProfile{ + GivenName: "firstname", + FamilyName: "lastname", + }, + Email: &user.SetHumanEmail{ + Email: "email@test.com", + }, + }, + }, + Roles: nil, + }, + }, + }, + }, + want: &command.OrgSetup{ + Name: "name", + CustomDomain: "", + Admins: []*command.OrgSetupAdmin{ + { + Human: &command.AddHuman{ + Username: "email@test.com", + FirstName: "firstname", + LastName: "lastname", + Email: command.Email{ + Address: "email@test.com", + }, + Metadata: make([]*command.AddMetadataEntry, 0), + Links: make([]*command.AddLink, 0), + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := addOrganizationRequestToCommand(tt.args.request) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_createdOrganizationToPb(t *testing.T) { + now := time.Now() + type args struct { + createdOrg *command.CreatedOrg + } + tests := []struct { + name string + args args + want *org.AddOrganizationResponse + wantErr error + }{ + { + name: "human user with phone and email code", + args: args{ + createdOrg: &command.CreatedOrg{ + ObjectDetails: &domain.ObjectDetails{ + Sequence: 1, + EventDate: now, + ResourceOwner: "orgID", + }, + CreatedAdmins: []*command.CreatedOrgAdmin{ + { + ID: "id", + EmailCode: gu.Ptr("emailCode"), + PhoneCode: gu.Ptr("phoneCode"), + }, + }, + }, + }, + want: &org.AddOrganizationResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.New(now), + ResourceOwner: "orgID", + }, + OrganizationId: "orgID", + CreatedAdmins: []*org.AddOrganizationResponse_CreatedAdmin{ + { + UserId: "id", + EmailCode: gu.Ptr("emailCode"), + PhoneCode: gu.Ptr("phoneCode"), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := createdOrganizationToPb(tt.args.createdOrg) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/api/grpc/org/v2beta/server.go b/internal/api/grpc/org/v2beta/server.go new file mode 100644 index 0000000000..89dba81702 --- /dev/null +++ b/internal/api/grpc/org/v2beta/server.go @@ -0,0 +1,55 @@ +package org + +import ( + "google.golang.org/grpc" + + "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" + org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" +) + +var _ org.OrganizationServiceServer = (*Server)(nil) + +type Server struct { + org.UnimplementedOrganizationServiceServer + command *command.Commands + query *query.Queries + checkPermission domain.PermissionCheck +} + +type Config struct{} + +func CreateServer( + command *command.Commands, + query *query.Queries, + checkPermission domain.PermissionCheck, +) *Server { + return &Server{ + command: command, + query: query, + checkPermission: checkPermission, + } +} + +func (s *Server) RegisterServer(grpcServer *grpc.Server) { + org.RegisterOrganizationServiceServer(grpcServer, s) +} + +func (s *Server) AppName() string { + return org.OrganizationService_ServiceDesc.ServiceName +} + +func (s *Server) MethodPrefix() string { + return org.OrganizationService_ServiceDesc.ServiceName +} + +func (s *Server) AuthMethods() authz.MethodMapping { + return org.OrganizationService_AuthMethods +} + +func (s *Server) RegisterGateway() server.RegisterGatewayFunc { + return org.RegisterOrganizationServiceHandler +} diff --git a/internal/api/grpc/server/middleware/activity_interceptor.go b/internal/api/grpc/server/middleware/activity_interceptor.go index 7b8b164e99..29b7612eef 100644 --- a/internal/api/grpc/server/middleware/activity_interceptor.go +++ b/internal/api/grpc/server/middleware/activity_interceptor.go @@ -29,6 +29,8 @@ func ActivityInterceptor() grpc.UnaryServerInterceptor { var resourcePrefixes = []string{ "/zitadel.management.v1.ManagementService/", "/zitadel.admin.v1.AdminService/", + "/zitadel.user.v2.UserService/", + "/zitadel.settings.v2.SettingsService/", "/zitadel.user.v2beta.UserService/", "/zitadel.settings.v2beta.SettingsService/", "/zitadel.auth.v1.AuthService/", diff --git a/internal/api/grpc/server/middleware/execution_interceptor_test.go b/internal/api/grpc/server/middleware/execution_interceptor_test.go index bbc87c374f..f59fd00441 100644 --- a/internal/api/grpc/server/middleware/execution_interceptor_test.go +++ b/internal/api/grpc/server/middleware/execution_interceptor_test.go @@ -131,7 +131,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { executionTargets: []execution.Target{ &mockExecutionTarget{ InstanceID: "instance", - ExecutionID: "request./zitadel.session.v2beta.SessionService/SetSession", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", TargetID: "target", TargetType: domain.TargetTypeCall, Timeout: time.Minute, @@ -153,7 +153,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { executionTargets: []execution.Target{ &mockExecutionTarget{ InstanceID: "instance", - ExecutionID: "request./zitadel.session.v2beta.SessionService/SetSession", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", TargetID: "target", TargetType: domain.TargetTypeCall, Timeout: time.Minute, @@ -181,7 +181,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { executionTargets: []execution.Target{ &mockExecutionTarget{ InstanceID: "instance", - ExecutionID: "request./zitadel.session.v2beta.SessionService/SetSession", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", TargetID: "target", TargetType: domain.TargetTypeCall, Timeout: time.Minute, @@ -211,7 +211,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { executionTargets: []execution.Target{ &mockExecutionTarget{ InstanceID: "instance", - ExecutionID: "request./zitadel.session.v2beta.SessionService/SetSession", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", TargetID: "target", TargetType: domain.TargetTypeCall, Timeout: time.Second, @@ -240,7 +240,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { executionTargets: []execution.Target{ &mockExecutionTarget{ InstanceID: "instance", - ExecutionID: "request./zitadel.session.v2beta.SessionService/SetSession", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", TargetID: "target", TargetType: domain.TargetTypeCall, Timeout: time.Second, @@ -264,7 +264,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { executionTargets: []execution.Target{ &mockExecutionTarget{ InstanceID: "instance", - ExecutionID: "request./zitadel.session.v2beta.SessionService/SetSession", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", TargetID: "target", TargetType: domain.TargetTypeCall, Timeout: time.Minute, @@ -293,7 +293,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { executionTargets: []execution.Target{ &mockExecutionTarget{ InstanceID: "instance", - ExecutionID: "request./zitadel.session.v2beta.SessionService/SetSession", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", TargetID: "target", TargetType: domain.TargetTypeAsync, Timeout: time.Second, @@ -321,7 +321,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { executionTargets: []execution.Target{ &mockExecutionTarget{ InstanceID: "instance", - ExecutionID: "request./zitadel.session.v2beta.SessionService/SetSession", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", TargetID: "target", TargetType: domain.TargetTypeAsync, Timeout: time.Minute, @@ -349,7 +349,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { executionTargets: []execution.Target{ &mockExecutionTarget{ InstanceID: "instance", - ExecutionID: "request./zitadel.session.v2beta.SessionService/SetSession", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", TargetID: "target", TargetType: domain.TargetTypeWebhook, Timeout: time.Minute, @@ -377,7 +377,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { executionTargets: []execution.Target{ &mockExecutionTarget{ InstanceID: "instance", - ExecutionID: "request./zitadel.session.v2beta.SessionService/SetSession", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", TargetID: "target", TargetType: domain.TargetTypeWebhook, Timeout: time.Second, @@ -406,7 +406,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { executionTargets: []execution.Target{ &mockExecutionTarget{ InstanceID: "instance", - ExecutionID: "request./zitadel.session.v2beta.SessionService/SetSession", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", TargetID: "target", TargetType: domain.TargetTypeWebhook, Timeout: time.Minute, @@ -435,7 +435,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { executionTargets: []execution.Target{ &mockExecutionTarget{ InstanceID: "instance", - ExecutionID: "request./zitadel.session.v2beta.SessionService/SetSession", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", TargetID: "target1", TargetType: domain.TargetTypeCall, Timeout: time.Minute, @@ -443,7 +443,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { }, &mockExecutionTarget{ InstanceID: "instance", - ExecutionID: "request./zitadel.session.v2beta.SessionService/SetSession", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", TargetID: "target2", TargetType: domain.TargetTypeCall, Timeout: time.Minute, @@ -451,7 +451,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { }, &mockExecutionTarget{ InstanceID: "instance", - ExecutionID: "request./zitadel.session.v2beta.SessionService/SetSession", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", TargetID: "target3", TargetType: domain.TargetTypeCall, Timeout: time.Minute, @@ -493,7 +493,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { executionTargets: []execution.Target{ &mockExecutionTarget{ InstanceID: "instance", - ExecutionID: "request./zitadel.session.v2beta.SessionService/SetSession", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", TargetID: "target1", TargetType: domain.TargetTypeCall, Timeout: time.Minute, @@ -501,7 +501,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { }, &mockExecutionTarget{ InstanceID: "instance", - ExecutionID: "request./zitadel.session.v2beta.SessionService/SetSession", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", TargetID: "target2", TargetType: domain.TargetTypeCall, Timeout: time.Second, @@ -509,7 +509,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { }, &mockExecutionTarget{ InstanceID: "instance", - ExecutionID: "request./zitadel.session.v2beta.SessionService/SetSession", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", TargetID: "target3", TargetType: domain.TargetTypeCall, Timeout: time.Second, @@ -687,7 +687,7 @@ func Test_executeTargetsForGRPCFullMethod_response(t *testing.T) { executionTargets: []execution.Target{ &mockExecutionTarget{ InstanceID: "instance", - ExecutionID: "request./zitadel.session.v2beta.SessionService/SetSession", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", TargetID: "target", TargetType: domain.TargetTypeCall, Timeout: time.Minute, @@ -716,7 +716,7 @@ func Test_executeTargetsForGRPCFullMethod_response(t *testing.T) { executionTargets: []execution.Target{ &mockExecutionTarget{ InstanceID: "instance", - ExecutionID: "response./zitadel.session.v2beta.SessionService/SetSession", + ExecutionID: "response./zitadel.session.v2.SessionService/SetSession", TargetID: "target", TargetType: domain.TargetTypeCall, Timeout: time.Minute, diff --git a/internal/api/grpc/session/v2/server.go b/internal/api/grpc/session/v2/server.go index 550d013ad5..e94336bf47 100644 --- a/internal/api/grpc/session/v2/server.go +++ b/internal/api/grpc/session/v2/server.go @@ -7,7 +7,7 @@ import ( "github.com/zitadel/zitadel/internal/api/grpc/server" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/query" - session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/session/v2" ) var _ session.SessionServiceServer = (*Server)(nil) diff --git a/internal/api/grpc/session/v2/session.go b/internal/api/grpc/session/v2/session.go index 7af87798e6..aa25fa0ae3 100644 --- a/internal/api/grpc/session/v2/session.go +++ b/internal/api/grpc/session/v2/session.go @@ -18,7 +18,7 @@ import ( "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/zerrors" objpb "github.com/zitadel/zitadel/pkg/grpc/object" - session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/session/v2" ) var ( diff --git a/internal/api/grpc/session/v2/session_integration_test.go b/internal/api/grpc/session/v2/session_integration_test.go index 92d3b0baf7..0871f92994 100644 --- a/internal/api/grpc/session/v2/session_integration_test.go +++ b/internal/api/grpc/session/v2/session_integration_test.go @@ -23,9 +23,9 @@ import ( "github.com/zitadel/zitadel/internal/integration" mgmt "github.com/zitadel/zitadel/pkg/grpc/management" - object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" - session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta" - user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/object/v2" + "github.com/zitadel/zitadel/pkg/grpc/session/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) var ( @@ -860,7 +860,7 @@ func TestServer_SetSession_expired(t *testing.T) { // ensure session expires and does not work anymore time.Sleep(20 * time.Second) - _, err = Tester.Client.SessionV2.SetSession(CTX, &session.SetSessionRequest{ + _, err = Client.SetSession(CTX, &session.SetSessionRequest{ SessionId: createResp.GetSessionId(), Lifetime: durationpb.New(20 * time.Second), }) @@ -944,7 +944,7 @@ func Test_ZITADEL_API_missing_authentication(t *testing.T) { require.NoError(t, err) ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("Bearer %s", createResp.GetSessionToken())) - sessionResp, err := Tester.Client.SessionV2.GetSession(ctx, &session.GetSessionRequest{SessionId: createResp.GetSessionId()}) + sessionResp, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: createResp.GetSessionId()}) require.Error(t, err) require.Nil(t, sessionResp) } @@ -953,7 +953,7 @@ func Test_ZITADEL_API_success(t *testing.T) { id, token, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, User.GetUserId()) ctx := Tester.WithAuthorizationToken(context.Background(), token) - sessionResp, err := Tester.Client.SessionV2.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) + sessionResp, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) require.NoError(t, err) webAuthN := sessionResp.GetSession().GetFactors().GetWebAuthN() @@ -966,17 +966,17 @@ func Test_ZITADEL_API_session_not_found(t *testing.T) { // test session token works ctx := Tester.WithAuthorizationToken(context.Background(), token) - _, err := Tester.Client.SessionV2.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) + _, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) require.NoError(t, err) //terminate the session and test it does not work anymore - _, err = Tester.Client.SessionV2.DeleteSession(CTX, &session.DeleteSessionRequest{ + _, err = Client.DeleteSession(CTX, &session.DeleteSessionRequest{ SessionId: id, SessionToken: gu.Ptr(token), }) require.NoError(t, err) ctx = Tester.WithAuthorizationToken(context.Background(), token) - _, err = Tester.Client.SessionV2.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) + _, err = Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) require.Error(t, err) } @@ -985,12 +985,12 @@ func Test_ZITADEL_API_session_expired(t *testing.T) { // test session token works ctx := Tester.WithAuthorizationToken(context.Background(), token) - _, err := Tester.Client.SessionV2.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) + _, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) require.NoError(t, err) // ensure session expires and does not work anymore time.Sleep(20 * time.Second) - sessionResp, err := Tester.Client.SessionV2.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) + sessionResp, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) require.Error(t, err) require.Nil(t, sessionResp) } diff --git a/internal/api/grpc/session/v2/session_test.go b/internal/api/grpc/session/v2/session_test.go index c088b5b886..917be882f8 100644 --- a/internal/api/grpc/session/v2/session_test.go +++ b/internal/api/grpc/session/v2/session_test.go @@ -18,8 +18,8 @@ import ( "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/zerrors" objpb "github.com/zitadel/zitadel/pkg/grpc/object" - object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" - session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/object/v2" + "github.com/zitadel/zitadel/pkg/grpc/session/v2" ) var ( diff --git a/internal/api/grpc/session/v2beta/server.go b/internal/api/grpc/session/v2beta/server.go new file mode 100644 index 0000000000..550d013ad5 --- /dev/null +++ b/internal/api/grpc/session/v2beta/server.go @@ -0,0 +1,51 @@ +package session + +import ( + "google.golang.org/grpc" + + "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" + session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta" +) + +var _ session.SessionServiceServer = (*Server)(nil) + +type Server struct { + session.UnimplementedSessionServiceServer + command *command.Commands + query *query.Queries +} + +type Config struct{} + +func CreateServer( + command *command.Commands, + query *query.Queries, +) *Server { + return &Server{ + command: command, + query: query, + } +} + +func (s *Server) RegisterServer(grpcServer *grpc.Server) { + session.RegisterSessionServiceServer(grpcServer, s) +} + +func (s *Server) AppName() string { + return session.SessionService_ServiceDesc.ServiceName +} + +func (s *Server) MethodPrefix() string { + return session.SessionService_ServiceDesc.ServiceName +} + +func (s *Server) AuthMethods() authz.MethodMapping { + return session.SessionService_AuthMethods +} + +func (s *Server) RegisterGateway() server.RegisterGatewayFunc { + return session.RegisterSessionServiceHandler +} diff --git a/internal/api/grpc/session/v2beta/session.go b/internal/api/grpc/session/v2beta/session.go new file mode 100644 index 0000000000..7e67a4b3ff --- /dev/null +++ b/internal/api/grpc/session/v2beta/session.go @@ -0,0 +1,500 @@ +package session + +import ( + "context" + "net" + "net/http" + "time" + + "github.com/muhlemmer/gu" + "golang.org/x/text/language" + "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/authz" + object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + objpb "github.com/zitadel/zitadel/pkg/grpc/object" + session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta" +) + +var ( + timestampComparisons = map[objpb.TimestampQueryMethod]query.TimestampComparison{ + objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_EQUALS: query.TimestampEquals, + objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_GREATER: query.TimestampGreater, + objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_GREATER_OR_EQUALS: query.TimestampGreaterOrEquals, + objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_LESS: query.TimestampLess, + objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_LESS_OR_EQUALS: query.TimestampLessOrEquals, + } +) + +func (s *Server) GetSession(ctx context.Context, req *session.GetSessionRequest) (*session.GetSessionResponse, error) { + res, err := s.query.SessionByID(ctx, true, req.GetSessionId(), req.GetSessionToken()) + if err != nil { + return nil, err + } + return &session.GetSessionResponse{ + 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, userAgent, lifetime, err := s.createSessionRequestToCommand(ctx, req) + if err != nil { + return nil, err + } + challengeResponse, cmds, err := s.challengesToCommand(req.GetChallenges(), checks) + if err != nil { + return nil, err + } + + set, err := s.command.CreateSession(ctx, cmds, metadata, userAgent, lifetime) + if err != nil { + return nil, err + } + + return &session.CreateSessionResponse{ + Details: object.DomainToDetailsPb(set.ObjectDetails), + SessionId: set.ID, + SessionToken: set.NewToken, + Challenges: challengeResponse, + }, 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 + } + challengeResponse, cmds, err := s.challengesToCommand(req.GetChallenges(), checks) + if err != nil { + return nil, err + } + + set, err := s.command.UpdateSession(ctx, req.GetSessionId(), cmds, req.GetMetadata(), req.GetLifetime().AsDuration()) + if err != nil { + return nil, err + } + return &session.SetSessionResponse{ + Details: object.DomainToDetailsPb(set.ObjectDetails), + SessionToken: set.NewToken, + Challenges: challengeResponse, + }, 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, + UserAgent: userAgentToPb(s.UserAgent), + ExpirationDate: expirationToPb(s.Expiration), + } +} + +func userAgentToPb(ua domain.UserAgent) *session.UserAgent { + if ua.IsEmpty() { + return nil + } + + out := &session.UserAgent{ + FingerprintId: ua.FingerprintID, + Description: ua.Description, + } + if ua.IP != nil { + out.Ip = gu.Ptr(ua.IP.String()) + } + if ua.Header == nil { + return out + } + out.Header = make(map[string]*session.UserAgent_HeaderValues, len(ua.Header)) + for k, v := range ua.Header { + out.Header[k] = &session.UserAgent_HeaderValues{ + Values: v, + } + } + return out +} + +func expirationToPb(expiration time.Time) *timestamppb.Timestamp { + if expiration.IsZero() { + return nil + } + return timestamppb.New(expiration) +} + +func factorsToPb(s *query.Session) *session.Factors { + user := userFactorToPb(s.UserFactor) + if user == nil { + return nil + } + return &session.Factors{ + User: user, + Password: passwordFactorToPb(s.PasswordFactor), + WebAuthN: webAuthNFactorToPb(s.WebAuthNFactor), + Intent: intentFactorToPb(s.IntentFactor), + Totp: totpFactorToPb(s.TOTPFactor), + OtpSms: otpFactorToPb(s.OTPSMSFactor), + OtpEmail: otpFactorToPb(s.OTPEmailFactor), + } +} + +func passwordFactorToPb(factor query.SessionPasswordFactor) *session.PasswordFactor { + if factor.PasswordCheckedAt.IsZero() { + return nil + } + return &session.PasswordFactor{ + VerifiedAt: timestamppb.New(factor.PasswordCheckedAt), + } +} + +func intentFactorToPb(factor query.SessionIntentFactor) *session.IntentFactor { + if factor.IntentCheckedAt.IsZero() { + return nil + } + return &session.IntentFactor{ + VerifiedAt: timestamppb.New(factor.IntentCheckedAt), + } +} + +func webAuthNFactorToPb(factor query.SessionWebAuthNFactor) *session.WebAuthNFactor { + if factor.WebAuthNCheckedAt.IsZero() { + return nil + } + return &session.WebAuthNFactor{ + VerifiedAt: timestamppb.New(factor.WebAuthNCheckedAt), + UserVerified: factor.UserVerified, + } +} + +func totpFactorToPb(factor query.SessionTOTPFactor) *session.TOTPFactor { + if factor.TOTPCheckedAt.IsZero() { + return nil + } + return &session.TOTPFactor{ + VerifiedAt: timestamppb.New(factor.TOTPCheckedAt), + } +} + +func otpFactorToPb(factor query.SessionOTPFactor) *session.OTPFactor { + if factor.OTPCheckedAt.IsZero() { + return nil + } + return &session.OTPFactor{ + VerifiedAt: timestamppb.New(factor.OTPCheckedAt), + } +} + +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, + OrganizationId: factor.ResourceOwner, + } +} + +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, + SortingColumn: fieldNameToSessionColumn(req.GetSortingColumn()), + }, + Queries: queries, + }, nil +} + +func sessionQueriesToQuery(ctx context.Context, queries []*session.SearchQuery) (_ []query.SearchQuery, err error) { + q := make([]query.SearchQuery, len(queries)+1) + for i, v := range queries { + q[i], err = sessionQueryToQuery(v) + 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(sq *session.SearchQuery) (query.SearchQuery, error) { + switch q := sq.Query.(type) { + case *session.SearchQuery_IdsQuery: + return idsQueryToQuery(q.IdsQuery) + case *session.SearchQuery_UserIdQuery: + return query.NewUserIDSearchQuery(q.UserIdQuery.GetId()) + case *session.SearchQuery_CreationDateQuery: + return creationDateQueryToQuery(q.CreationDateQuery) + default: + return nil, zerrors.ThrowInvalidArgument(nil, "GRPC-Sfefs", "List.Query.Invalid") + } +} + +func idsQueryToQuery(q *session.IDsQuery) (query.SearchQuery, error) { + return query.NewSessionIDsSearchQuery(q.Ids) +} + +func creationDateQueryToQuery(q *session.CreationDateQuery) (query.SearchQuery, error) { + comparison := timestampComparisons[q.GetMethod()] + return query.NewCreationDateQuery(q.GetCreationDate().AsTime(), comparison) +} + +func fieldNameToSessionColumn(field session.SessionFieldName) query.Column { + switch field { + case session.SessionFieldName_SESSION_FIELD_NAME_CREATION_DATE: + return query.SessionColumnCreationDate + case session.SessionFieldName_SESSION_FIELD_NAME_UNSPECIFIED: + // Handle all remaining cases so the linter succeeds + return query.Column{} + default: + return query.Column{} + } +} + +func (s *Server) createSessionRequestToCommand(ctx context.Context, req *session.CreateSessionRequest) ([]command.SessionCommand, map[string][]byte, *domain.UserAgent, time.Duration, error) { + checks, err := s.checksToCommand(ctx, req.Checks) + if err != nil { + return nil, nil, nil, 0, err + } + return checks, req.GetMetadata(), userAgentToCommand(req.GetUserAgent()), req.GetLifetime().AsDuration(), nil +} + +func userAgentToCommand(userAgent *session.UserAgent) *domain.UserAgent { + if userAgent == nil { + return nil + } + out := &domain.UserAgent{ + FingerprintID: userAgent.FingerprintId, + IP: net.ParseIP(userAgent.GetIp()), + Description: userAgent.Description, + } + if len(userAgent.Header) > 0 { + out.Header = make(http.Header, len(userAgent.Header)) + for k, values := range userAgent.Header { + out.Header[k] = values.GetValues() + } + } + return out +} + +func (s *Server) setSessionRequestToCommand(ctx context.Context, req *session.SetSessionRequest) ([]command.SessionCommand, 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.SessionCommand, error) { + checkUser, err := userCheck(checks.GetUser()) + if err != nil { + return nil, err + } + sessionChecks := make([]command.SessionCommand, 0, 7) + if checkUser != nil { + user, err := checkUser.search(ctx, s.query) + if err != nil { + return nil, err + } + if !user.State.IsEnabled() { + return nil, zerrors.ThrowPreconditionFailed(nil, "SESSION-Gj4ko", "Errors.User.NotActive") + } + + var preferredLanguage *language.Tag + if user.Human != nil && !user.Human.PreferredLanguage.IsRoot() { + preferredLanguage = &user.Human.PreferredLanguage + } + sessionChecks = append(sessionChecks, command.CheckUser(user.ID, user.ResourceOwner, preferredLanguage)) + } + if password := checks.GetPassword(); password != nil { + sessionChecks = append(sessionChecks, command.CheckPassword(password.GetPassword())) + } + if intent := checks.GetIdpIntent(); intent != nil { + sessionChecks = append(sessionChecks, command.CheckIntent(intent.GetIdpIntentId(), intent.GetIdpIntentToken())) + } + if passkey := checks.GetWebAuthN(); passkey != nil { + sessionChecks = append(sessionChecks, s.command.CheckWebAuthN(passkey.GetCredentialAssertionData())) + } + if totp := checks.GetTotp(); totp != nil { + sessionChecks = append(sessionChecks, command.CheckTOTP(totp.GetCode())) + } + if otp := checks.GetOtpSms(); otp != nil { + sessionChecks = append(sessionChecks, command.CheckOTPSMS(otp.GetCode())) + } + if otp := checks.GetOtpEmail(); otp != nil { + sessionChecks = append(sessionChecks, command.CheckOTPEmail(otp.GetCode())) + } + return sessionChecks, nil +} + +func (s *Server) challengesToCommand(challenges *session.RequestChallenges, cmds []command.SessionCommand) (*session.Challenges, []command.SessionCommand, error) { + if challenges == nil { + return nil, cmds, nil + } + resp := new(session.Challenges) + if req := challenges.GetWebAuthN(); req != nil { + challenge, cmd := s.createWebAuthNChallengeCommand(req) + resp.WebAuthN = challenge + cmds = append(cmds, cmd) + } + if req := challenges.GetOtpSms(); req != nil { + challenge, cmd := s.createOTPSMSChallengeCommand(req) + resp.OtpSms = challenge + cmds = append(cmds, cmd) + } + if req := challenges.GetOtpEmail(); req != nil { + challenge, cmd, err := s.createOTPEmailChallengeCommand(req) + if err != nil { + return nil, nil, err + } + resp.OtpEmail = challenge + cmds = append(cmds, cmd) + } + return resp, cmds, nil +} + +func (s *Server) createWebAuthNChallengeCommand(req *session.RequestChallenges_WebAuthN) (*session.Challenges_WebAuthN, command.SessionCommand) { + challenge := &session.Challenges_WebAuthN{ + PublicKeyCredentialRequestOptions: new(structpb.Struct), + } + userVerification := userVerificationRequirementToDomain(req.GetUserVerificationRequirement()) + return challenge, s.command.CreateWebAuthNChallenge(userVerification, req.GetDomain(), challenge.PublicKeyCredentialRequestOptions) +} + +func userVerificationRequirementToDomain(req session.UserVerificationRequirement) domain.UserVerificationRequirement { + switch req { + case session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_UNSPECIFIED: + return domain.UserVerificationRequirementUnspecified + case session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_REQUIRED: + return domain.UserVerificationRequirementRequired + case session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_PREFERRED: + return domain.UserVerificationRequirementPreferred + case session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_DISCOURAGED: + return domain.UserVerificationRequirementDiscouraged + default: + return domain.UserVerificationRequirementUnspecified + } +} + +func (s *Server) createOTPSMSChallengeCommand(req *session.RequestChallenges_OTPSMS) (*string, command.SessionCommand) { + if req.GetReturnCode() { + challenge := new(string) + return challenge, s.command.CreateOTPSMSChallengeReturnCode(challenge) + } + + return nil, s.command.CreateOTPSMSChallenge() + +} + +func (s *Server) createOTPEmailChallengeCommand(req *session.RequestChallenges_OTPEmail) (*string, command.SessionCommand, error) { + switch t := req.GetDeliveryType().(type) { + case *session.RequestChallenges_OTPEmail_SendCode_: + cmd, err := s.command.CreateOTPEmailChallengeURLTemplate(t.SendCode.GetUrlTemplate()) + if err != nil { + return nil, nil, err + } + return nil, cmd, nil + case *session.RequestChallenges_OTPEmail_ReturnCode_: + challenge := new(string) + return challenge, s.command.CreateOTPEmailChallengeReturnCode(challenge), nil + case nil: + return nil, s.command.CreateOTPEmailChallenge(), nil + default: + return nil, nil, zerrors.ThrowUnimplementedf(nil, "SESSION-k3ng0", "delivery_type oneOf %T in OTPEmailChallenge not implemented", t) + } +} + +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, zerrors.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) { + return userSearchByLoginName{loginName}, 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) +} + +type userSearchByLoginName struct { + loginName string +} + +func (u userSearchByLoginName) search(ctx context.Context, q *query.Queries) (*query.User, error) { + return q.GetUserByLoginName(ctx, true, u.loginName) +} diff --git a/internal/api/grpc/session/v2beta/session_integration_test.go b/internal/api/grpc/session/v2beta/session_integration_test.go new file mode 100644 index 0000000000..94ecdf5410 --- /dev/null +++ b/internal/api/grpc/session/v2beta/session_integration_test.go @@ -0,0 +1,996 @@ +//go:build integration + +package session_test + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/pquerna/otp/totp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zitadel/logging" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/integration" + mgmt "github.com/zitadel/zitadel/pkg/grpc/management" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" + session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +var ( + CTX context.Context + IAMOwnerCTX context.Context + Tester *integration.Tester + Client session.SessionServiceClient + User *user.AddHumanUserResponse + DeactivatedUser *user.AddHumanUserResponse + LockedUser *user.AddHumanUserResponse +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, errCtx, cancel := integration.Contexts(5 * time.Minute) + defer cancel() + + Tester = integration.NewTester(ctx) + defer Tester.Done() + Client = Tester.Client.SessionV2beta + + CTX, _ = Tester.WithAuthorization(ctx, integration.OrgOwner), errCtx + IAMOwnerCTX = Tester.WithAuthorization(ctx, integration.IAMOwner) + User = createFullUser(CTX) + DeactivatedUser = createDeactivatedUser(CTX) + LockedUser = createLockedUser(CTX) + return m.Run() + }()) +} + +func createFullUser(ctx context.Context) *user.AddHumanUserResponse { + userResp := Tester.CreateHumanUser(ctx) + Tester.Client.UserV2.VerifyEmail(ctx, &user.VerifyEmailRequest{ + UserId: userResp.GetUserId(), + VerificationCode: userResp.GetEmailCode(), + }) + Tester.Client.UserV2.VerifyPhone(ctx, &user.VerifyPhoneRequest{ + UserId: userResp.GetUserId(), + VerificationCode: userResp.GetPhoneCode(), + }) + Tester.SetUserPassword(ctx, userResp.GetUserId(), integration.UserPassword, false) + Tester.RegisterUserPasskey(ctx, userResp.GetUserId()) + return userResp +} + +func createDeactivatedUser(ctx context.Context) *user.AddHumanUserResponse { + userResp := Tester.CreateHumanUser(ctx) + _, err := Tester.Client.UserV2.DeactivateUser(ctx, &user.DeactivateUserRequest{UserId: userResp.GetUserId()}) + logging.OnError(err).Fatal("deactivate human user") + return userResp +} + +func createLockedUser(ctx context.Context) *user.AddHumanUserResponse { + userResp := Tester.CreateHumanUser(ctx) + _, err := Tester.Client.UserV2.LockUser(ctx, &user.LockUserRequest{UserId: userResp.GetUserId()}) + logging.OnError(err).Fatal("lock human user") + return userResp +} + +func verifyCurrentSession(t testing.TB, id, token string, sequence uint64, window time.Duration, metadata map[string][]byte, userAgent *session.UserAgent, expirationWindow time.Duration, userID string, factors ...wantFactor) *session.Session { + t.Helper() + require.NotEmpty(t, id) + require.NotEmpty(t, token) + + resp, err := Client.GetSession(CTX, &session.GetSessionRequest{ + SessionId: id, + SessionToken: &token, + }) + require.NoError(t, err) + s := resp.GetSession() + + assert.Equal(t, id, s.GetId()) + assert.WithinRange(t, s.GetCreationDate().AsTime(), time.Now().Add(-window), time.Now().Add(window)) + assert.WithinRange(t, s.GetChangeDate().AsTime(), time.Now().Add(-window), time.Now().Add(window)) + assert.Equal(t, sequence, s.GetSequence()) + assert.Equal(t, metadata, s.GetMetadata()) + + if !proto.Equal(userAgent, s.GetUserAgent()) { + t.Errorf("user agent =\n%v\nwant\n%v", s.GetUserAgent(), userAgent) + } + if expirationWindow == 0 { + assert.Nil(t, s.GetExpirationDate()) + } else { + assert.WithinRange(t, s.GetExpirationDate().AsTime(), time.Now().Add(-expirationWindow), time.Now().Add(expirationWindow)) + } + + verifyFactors(t, s.GetFactors(), window, userID, factors) + return s +} + +type wantFactor int + +const ( + wantUserFactor wantFactor = iota + wantPasswordFactor + wantWebAuthNFactor + wantWebAuthNFactorUserVerified + wantTOTPFactor + wantIntentFactor + wantOTPSMSFactor + wantOTPEmailFactor +) + +func verifyFactors(t testing.TB, factors *session.Factors, window time.Duration, userID string, want []wantFactor) { + for _, w := range want { + switch w { + case wantUserFactor: + uf := factors.GetUser() + assert.NotNil(t, uf) + assert.WithinRange(t, uf.GetVerifiedAt().AsTime(), time.Now().Add(-window), time.Now().Add(window)) + assert.Equal(t, userID, uf.GetId()) + case wantPasswordFactor: + pf := factors.GetPassword() + assert.NotNil(t, pf) + assert.WithinRange(t, pf.GetVerifiedAt().AsTime(), time.Now().Add(-window), time.Now().Add(window)) + case wantWebAuthNFactor: + pf := factors.GetWebAuthN() + assert.NotNil(t, pf) + assert.WithinRange(t, pf.GetVerifiedAt().AsTime(), time.Now().Add(-window), time.Now().Add(window)) + assert.False(t, pf.GetUserVerified()) + case wantWebAuthNFactorUserVerified: + pf := factors.GetWebAuthN() + assert.NotNil(t, pf) + assert.WithinRange(t, pf.GetVerifiedAt().AsTime(), time.Now().Add(-window), time.Now().Add(window)) + assert.True(t, pf.GetUserVerified()) + case wantTOTPFactor: + pf := factors.GetTotp() + assert.NotNil(t, pf) + assert.WithinRange(t, pf.GetVerifiedAt().AsTime(), time.Now().Add(-window), time.Now().Add(window)) + case wantIntentFactor: + pf := factors.GetIntent() + assert.NotNil(t, pf) + assert.WithinRange(t, pf.GetVerifiedAt().AsTime(), time.Now().Add(-window), time.Now().Add(window)) + case wantOTPSMSFactor: + pf := factors.GetOtpSms() + assert.NotNil(t, pf) + assert.WithinRange(t, pf.GetVerifiedAt().AsTime(), time.Now().Add(-window), time.Now().Add(window)) + case wantOTPEmailFactor: + pf := factors.GetOtpEmail() + assert.NotNil(t, pf) + assert.WithinRange(t, pf.GetVerifiedAt().AsTime(), time.Now().Add(-window), time.Now().Add(window)) + } + } +} + +func TestServer_CreateSession(t *testing.T) { + tests := []struct { + name string + req *session.CreateSessionRequest + want *session.CreateSessionResponse + wantErr bool + wantFactors []wantFactor + wantUserAgent *session.UserAgent + wantExpirationWindow time.Duration + }{ + { + name: "empty session", + req: &session.CreateSessionRequest{ + Metadata: map[string][]byte{"foo": []byte("bar")}, + }, + want: &session.CreateSessionResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + }, + }, + { + name: "user agent", + req: &session.CreateSessionRequest{ + Metadata: map[string][]byte{"foo": []byte("bar")}, + UserAgent: &session.UserAgent{ + FingerprintId: gu.Ptr("fingerPrintID"), + Ip: gu.Ptr("1.2.3.4"), + Description: gu.Ptr("Description"), + Header: map[string]*session.UserAgent_HeaderValues{ + "foo": {Values: []string{"foo", "bar"}}, + }, + }, + }, + want: &session.CreateSessionResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + }, + wantUserAgent: &session.UserAgent{ + FingerprintId: gu.Ptr("fingerPrintID"), + Ip: gu.Ptr("1.2.3.4"), + Description: gu.Ptr("Description"), + Header: map[string]*session.UserAgent_HeaderValues{ + "foo": {Values: []string{"foo", "bar"}}, + }, + }, + }, + { + name: "negative lifetime", + req: &session.CreateSessionRequest{ + Metadata: map[string][]byte{"foo": []byte("bar")}, + Lifetime: durationpb.New(-5 * time.Minute), + }, + wantErr: true, + }, + { + name: "lifetime", + req: &session.CreateSessionRequest{ + Metadata: map[string][]byte{"foo": []byte("bar")}, + Lifetime: durationpb.New(5 * time.Minute), + }, + want: &session.CreateSessionResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + }, + wantExpirationWindow: 5 * time.Minute, + }, + { + name: "with user", + req: &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: User.GetUserId(), + }, + }, + }, + Metadata: map[string][]byte{"foo": []byte("bar")}, + }, + want: &session.CreateSessionResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + }, + wantFactors: []wantFactor{wantUserFactor}, + }, + { + name: "deactivated user", + req: &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: DeactivatedUser.GetUserId(), + }, + }, + }, + }, + wantErr: true, + }, + { + name: "locked user", + req: &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: LockedUser.GetUserId(), + }, + }, + }, + }, + wantErr: true, + }, + { + name: "password without user error", + req: &session.CreateSessionRequest{ + Checks: &session.Checks{ + Password: &session.CheckPassword{ + Password: "Difficult", + }, + }, + }, + wantErr: true, + }, + { + name: "passkey without user error", + req: &session.CreateSessionRequest{ + Challenges: &session.RequestChallenges{ + WebAuthN: &session.RequestChallenges_WebAuthN{ + Domain: Tester.Config.ExternalDomain, + UserVerificationRequirement: session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_REQUIRED, + }, + }, + }, + wantErr: true, + }, + { + name: "passkey without domain (not registered) error", + req: &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: User.GetUserId(), + }, + }, + }, + Challenges: &session.RequestChallenges{ + WebAuthN: &session.RequestChallenges_WebAuthN{ + UserVerificationRequirement: session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_REQUIRED, + }, + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.CreateSession(CTX, tt.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + integration.AssertDetails(t, tt.want, got) + + verifyCurrentSession(t, got.GetSessionId(), got.GetSessionToken(), got.GetDetails().GetSequence(), time.Minute, tt.req.GetMetadata(), tt.wantUserAgent, tt.wantExpirationWindow, User.GetUserId(), tt.wantFactors...) + }) + } +} + +func TestServer_CreateSession_lock_user(t *testing.T) { + // create a separate org so we don't interfere with any other test + org := Tester.CreateOrganization(IAMOwnerCTX, + fmt.Sprintf("TestServer_CreateSession_lock_user_%d", time.Now().UnixNano()), + fmt.Sprintf("%d@mouse.com", time.Now().UnixNano()), + ) + userID := org.CreatedAdmins[0].GetUserId() + Tester.SetUserPassword(IAMOwnerCTX, userID, integration.UserPassword, false) + + // enable password lockout + maxAttempts := 2 + ctxOrg := metadata.AppendToOutgoingContext(IAMOwnerCTX, "x-zitadel-orgid", org.GetOrganizationId()) + _, err := Tester.Client.Mgmt.AddCustomLockoutPolicy(ctxOrg, &mgmt.AddCustomLockoutPolicyRequest{ + MaxPasswordAttempts: uint32(maxAttempts), + }) + require.NoError(t, err) + + for i := 0; i <= maxAttempts; i++ { + _, err := Client.CreateSession(CTX, &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: userID, + }, + }, + Password: &session.CheckPassword{ + Password: "invalid", + }, + }, + }) + assert.Error(t, err) + statusCode := status.Code(err) + expectedCode := codes.InvalidArgument + // as soon as we hit the limit the user is locked and following request will + // already deny any check with a precondition failed since the user is locked + if i >= maxAttempts { + expectedCode = codes.FailedPrecondition + } + assert.Equal(t, expectedCode, statusCode) + } +} + +func TestServer_CreateSession_webauthn(t *testing.T) { + // create new session with user and request the webauthn challenge + createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: User.GetUserId(), + }, + }, + }, + Challenges: &session.RequestChallenges{ + WebAuthN: &session.RequestChallenges_WebAuthN{ + Domain: Tester.Config.ExternalDomain, + UserVerificationRequirement: session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_REQUIRED, + }, + }, + }) + require.NoError(t, err) + verifyCurrentSession(t, createResp.GetSessionId(), createResp.GetSessionToken(), createResp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId()) + + assertionData, err := Tester.WebAuthN.CreateAssertionResponse(createResp.GetChallenges().GetWebAuthN().GetPublicKeyCredentialRequestOptions(), true) + require.NoError(t, err) + + // update the session with webauthn assertion data + updateResp, err := Client.SetSession(CTX, &session.SetSessionRequest{ + SessionId: createResp.GetSessionId(), + Checks: &session.Checks{ + WebAuthN: &session.CheckWebAuthN{ + CredentialAssertionData: assertionData, + }, + }, + }) + require.NoError(t, err) + verifyCurrentSession(t, createResp.GetSessionId(), updateResp.GetSessionToken(), updateResp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId(), wantUserFactor, wantWebAuthNFactorUserVerified) +} + +func TestServer_CreateSession_successfulIntent(t *testing.T) { + idpID := Tester.AddGenericOAuthProvider(t, CTX) + createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: User.GetUserId(), + }, + }, + }, + }) + require.NoError(t, err) + verifyCurrentSession(t, createResp.GetSessionId(), createResp.GetSessionToken(), createResp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId()) + + intentID, token, _, _ := Tester.CreateSuccessfulOAuthIntent(t, CTX, idpID, User.GetUserId(), "id") + updateResp, err := Client.SetSession(CTX, &session.SetSessionRequest{ + SessionId: createResp.GetSessionId(), + Checks: &session.Checks{ + IdpIntent: &session.CheckIDPIntent{ + IdpIntentId: intentID, + IdpIntentToken: token, + }, + }, + }) + require.NoError(t, err) + verifyCurrentSession(t, createResp.GetSessionId(), updateResp.GetSessionToken(), updateResp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId(), wantUserFactor, wantIntentFactor) +} + +func TestServer_CreateSession_successfulIntent_instant(t *testing.T) { + idpID := Tester.AddGenericOAuthProvider(t, CTX) + + intentID, token, _, _ := Tester.CreateSuccessfulOAuthIntent(t, CTX, idpID, User.GetUserId(), "id") + createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: User.GetUserId(), + }, + }, + IdpIntent: &session.CheckIDPIntent{ + IdpIntentId: intentID, + IdpIntentToken: token, + }, + }, + }) + require.NoError(t, err) + verifyCurrentSession(t, createResp.GetSessionId(), createResp.GetSessionToken(), createResp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId(), wantUserFactor, wantIntentFactor) +} + +func TestServer_CreateSession_successfulIntentUnknownUserID(t *testing.T) { + idpID := Tester.AddGenericOAuthProvider(t, CTX) + + // successful intent without known / linked user + idpUserID := "id" + intentID, token, _, _ := Tester.CreateSuccessfulOAuthIntent(t, CTX, idpID, "", idpUserID) + + // link the user (with info from intent) + Tester.CreateUserIDPlink(CTX, User.GetUserId(), idpUserID, idpID, User.GetUserId()) + + // session with intent check must now succeed + createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: User.GetUserId(), + }, + }, + IdpIntent: &session.CheckIDPIntent{ + IdpIntentId: intentID, + IdpIntentToken: token, + }, + }, + }) + require.NoError(t, err) + verifyCurrentSession(t, createResp.GetSessionId(), createResp.GetSessionToken(), createResp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId(), wantUserFactor, wantIntentFactor) +} + +func TestServer_CreateSession_startedIntentFalseToken(t *testing.T) { + idpID := Tester.AddGenericOAuthProvider(t, CTX) + + createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: User.GetUserId(), + }, + }, + }, + }) + require.NoError(t, err) + verifyCurrentSession(t, createResp.GetSessionId(), createResp.GetSessionToken(), createResp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId()) + + intentID := Tester.CreateIntent(t, CTX, idpID) + _, err = Client.SetSession(CTX, &session.SetSessionRequest{ + SessionId: createResp.GetSessionId(), + Checks: &session.Checks{ + IdpIntent: &session.CheckIDPIntent{ + IdpIntentId: intentID, + IdpIntentToken: "false", + }, + }, + }) + require.Error(t, err) +} + +func registerTOTP(ctx context.Context, t *testing.T, userID string) (secret string) { + resp, err := Tester.Client.UserV2.RegisterTOTP(ctx, &user.RegisterTOTPRequest{ + UserId: userID, + }) + require.NoError(t, err) + secret = resp.GetSecret() + code, err := totp.GenerateCode(secret, time.Now()) + require.NoError(t, err) + + _, err = Tester.Client.UserV2.VerifyTOTPRegistration(ctx, &user.VerifyTOTPRegistrationRequest{ + UserId: userID, + Code: code, + }) + require.NoError(t, err) + return secret +} + +func registerOTPSMS(ctx context.Context, t *testing.T, userID string) { + _, err := Tester.Client.UserV2.AddOTPSMS(ctx, &user.AddOTPSMSRequest{ + UserId: userID, + }) + require.NoError(t, err) +} + +func registerOTPEmail(ctx context.Context, t *testing.T, userID string) { + _, err := Tester.Client.UserV2.AddOTPEmail(ctx, &user.AddOTPEmailRequest{ + UserId: userID, + }) + require.NoError(t, err) +} + +func TestServer_SetSession_flow_totp(t *testing.T) { + userExisting := createFullUser(CTX) + + // create new, empty session + createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{}) + require.NoError(t, err) + sessionToken := createResp.GetSessionToken() + verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, createResp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, "") + + t.Run("check user", func(t *testing.T) { + resp, err := Client.SetSession(CTX, &session.SetSessionRequest{ + SessionId: createResp.GetSessionId(), + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: userExisting.GetUserId(), + }, + }, + }, + }) + require.NoError(t, err) + sessionToken = resp.GetSessionToken() + verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, resp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, userExisting.GetUserId(), wantUserFactor) + }) + + t.Run("check webauthn, user verified (passkey)", func(t *testing.T) { + resp, err := Client.SetSession(CTX, &session.SetSessionRequest{ + SessionId: createResp.GetSessionId(), + Challenges: &session.RequestChallenges{ + WebAuthN: &session.RequestChallenges_WebAuthN{ + Domain: Tester.Config.ExternalDomain, + UserVerificationRequirement: session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_REQUIRED, + }, + }, + }) + require.NoError(t, err) + verifyCurrentSession(t, createResp.GetSessionId(), resp.GetSessionToken(), resp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, userExisting.GetUserId()) + sessionToken = resp.GetSessionToken() + + assertionData, err := Tester.WebAuthN.CreateAssertionResponse(resp.GetChallenges().GetWebAuthN().GetPublicKeyCredentialRequestOptions(), true) + require.NoError(t, err) + + resp, err = Client.SetSession(CTX, &session.SetSessionRequest{ + SessionId: createResp.GetSessionId(), + Checks: &session.Checks{ + WebAuthN: &session.CheckWebAuthN{ + CredentialAssertionData: assertionData, + }, + }, + }) + require.NoError(t, err) + sessionToken = resp.GetSessionToken() + verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, resp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, userExisting.GetUserId(), wantUserFactor, wantWebAuthNFactorUserVerified) + }) + + userAuthCtx := Tester.WithAuthorizationToken(CTX, sessionToken) + Tester.RegisterUserU2F(userAuthCtx, userExisting.GetUserId()) + totpSecret := registerTOTP(userAuthCtx, t, userExisting.GetUserId()) + registerOTPSMS(userAuthCtx, t, userExisting.GetUserId()) + registerOTPEmail(userAuthCtx, t, userExisting.GetUserId()) + + t.Run("check TOTP", func(t *testing.T) { + code, err := totp.GenerateCode(totpSecret, time.Now()) + require.NoError(t, err) + resp, err := Client.SetSession(CTX, &session.SetSessionRequest{ + SessionId: createResp.GetSessionId(), + Checks: &session.Checks{ + Totp: &session.CheckTOTP{ + Code: code, + }, + }, + }) + require.NoError(t, err) + sessionToken = resp.GetSessionToken() + verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, resp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, userExisting.GetUserId(), wantUserFactor, wantTOTPFactor) + }) + + userImport := Tester.CreateHumanUserWithTOTP(CTX, totpSecret) + createRespImport, err := Client.CreateSession(CTX, &session.CreateSessionRequest{}) + require.NoError(t, err) + sessionTokenImport := createRespImport.GetSessionToken() + verifyCurrentSession(t, createRespImport.GetSessionId(), sessionTokenImport, createRespImport.GetDetails().GetSequence(), time.Minute, nil, nil, 0, "") + + t.Run("check user", func(t *testing.T) { + resp, err := Client.SetSession(CTX, &session.SetSessionRequest{ + SessionId: createRespImport.GetSessionId(), + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: userImport.GetUserId(), + }, + }, + }, + }) + require.NoError(t, err) + sessionTokenImport = resp.GetSessionToken() + verifyCurrentSession(t, createRespImport.GetSessionId(), sessionTokenImport, resp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, userImport.GetUserId(), wantUserFactor) + }) + t.Run("check TOTP", func(t *testing.T) { + code, err := totp.GenerateCode(totpSecret, time.Now()) + require.NoError(t, err) + resp, err := Client.SetSession(CTX, &session.SetSessionRequest{ + SessionId: createRespImport.GetSessionId(), + Checks: &session.Checks{ + Totp: &session.CheckTOTP{ + Code: code, + }, + }, + }) + require.NoError(t, err) + sessionTokenImport = resp.GetSessionToken() + verifyCurrentSession(t, createRespImport.GetSessionId(), sessionTokenImport, resp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, userImport.GetUserId(), wantUserFactor, wantTOTPFactor) + }) +} + +func TestServer_SetSession_flow(t *testing.T) { + // create new, empty session + createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{}) + require.NoError(t, err) + sessionToken := createResp.GetSessionToken() + verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, createResp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId()) + + t.Run("check user", func(t *testing.T) { + resp, err := Client.SetSession(CTX, &session.SetSessionRequest{ + SessionId: createResp.GetSessionId(), + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: User.GetUserId(), + }, + }, + }, + }) + require.NoError(t, err) + sessionToken = resp.GetSessionToken() + verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, resp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId(), wantUserFactor) + }) + + t.Run("check webauthn, user verified (passkey)", func(t *testing.T) { + resp, err := Client.SetSession(CTX, &session.SetSessionRequest{ + SessionId: createResp.GetSessionId(), + Challenges: &session.RequestChallenges{ + WebAuthN: &session.RequestChallenges_WebAuthN{ + Domain: Tester.Config.ExternalDomain, + UserVerificationRequirement: session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_REQUIRED, + }, + }, + }) + require.NoError(t, err) + verifyCurrentSession(t, createResp.GetSessionId(), resp.GetSessionToken(), resp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId()) + sessionToken = resp.GetSessionToken() + + assertionData, err := Tester.WebAuthN.CreateAssertionResponse(resp.GetChallenges().GetWebAuthN().GetPublicKeyCredentialRequestOptions(), true) + require.NoError(t, err) + + resp, err = Client.SetSession(CTX, &session.SetSessionRequest{ + SessionId: createResp.GetSessionId(), + Checks: &session.Checks{ + WebAuthN: &session.CheckWebAuthN{ + CredentialAssertionData: assertionData, + }, + }, + }) + require.NoError(t, err) + sessionToken = resp.GetSessionToken() + verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, resp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId(), wantUserFactor, wantWebAuthNFactorUserVerified) + }) + + userAuthCtx := Tester.WithAuthorizationToken(CTX, sessionToken) + Tester.RegisterUserU2F(userAuthCtx, User.GetUserId()) + totpSecret := registerTOTP(userAuthCtx, t, User.GetUserId()) + registerOTPSMS(userAuthCtx, t, User.GetUserId()) + registerOTPEmail(userAuthCtx, t, User.GetUserId()) + + t.Run("check webauthn, user not verified (U2F)", func(t *testing.T) { + + for _, userVerificationRequirement := range []session.UserVerificationRequirement{ + session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_PREFERRED, + session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_DISCOURAGED, + } { + t.Run(userVerificationRequirement.String(), func(t *testing.T) { + resp, err := Client.SetSession(CTX, &session.SetSessionRequest{ + SessionId: createResp.GetSessionId(), + Challenges: &session.RequestChallenges{ + WebAuthN: &session.RequestChallenges_WebAuthN{ + Domain: Tester.Config.ExternalDomain, + UserVerificationRequirement: userVerificationRequirement, + }, + }, + }) + require.NoError(t, err) + verifyCurrentSession(t, createResp.GetSessionId(), resp.GetSessionToken(), resp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId()) + sessionToken = resp.GetSessionToken() + + assertionData, err := Tester.WebAuthN.CreateAssertionResponse(resp.GetChallenges().GetWebAuthN().GetPublicKeyCredentialRequestOptions(), false) + require.NoError(t, err) + + resp, err = Client.SetSession(CTX, &session.SetSessionRequest{ + SessionId: createResp.GetSessionId(), + Checks: &session.Checks{ + WebAuthN: &session.CheckWebAuthN{ + CredentialAssertionData: assertionData, + }, + }, + }) + require.NoError(t, err) + sessionToken = resp.GetSessionToken() + verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, resp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId(), wantUserFactor, wantWebAuthNFactor) + }) + } + }) + + t.Run("check TOTP", func(t *testing.T) { + code, err := totp.GenerateCode(totpSecret, time.Now()) + require.NoError(t, err) + resp, err := Client.SetSession(CTX, &session.SetSessionRequest{ + SessionId: createResp.GetSessionId(), + Checks: &session.Checks{ + Totp: &session.CheckTOTP{ + Code: code, + }, + }, + }) + require.NoError(t, err) + sessionToken = resp.GetSessionToken() + verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, resp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId(), wantUserFactor, wantWebAuthNFactor, wantTOTPFactor) + }) + + t.Run("check OTP SMS", func(t *testing.T) { + resp, err := Client.SetSession(CTX, &session.SetSessionRequest{ + SessionId: createResp.GetSessionId(), + Challenges: &session.RequestChallenges{ + OtpSms: &session.RequestChallenges_OTPSMS{ReturnCode: true}, + }, + }) + require.NoError(t, err) + verifyCurrentSession(t, createResp.GetSessionId(), resp.GetSessionToken(), resp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId()) + sessionToken = resp.GetSessionToken() + + otp := resp.GetChallenges().GetOtpSms() + require.NotEmpty(t, otp) + + resp, err = Client.SetSession(CTX, &session.SetSessionRequest{ + SessionId: createResp.GetSessionId(), + Checks: &session.Checks{ + OtpSms: &session.CheckOTP{ + Code: otp, + }, + }, + }) + require.NoError(t, err) + sessionToken = resp.GetSessionToken() + verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, resp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId(), wantUserFactor, wantWebAuthNFactor, wantOTPSMSFactor) + }) + + t.Run("check OTP Email", func(t *testing.T) { + resp, err := Client.SetSession(CTX, &session.SetSessionRequest{ + SessionId: createResp.GetSessionId(), + Challenges: &session.RequestChallenges{ + OtpEmail: &session.RequestChallenges_OTPEmail{ + DeliveryType: &session.RequestChallenges_OTPEmail_ReturnCode_{}, + }, + }, + }) + require.NoError(t, err) + verifyCurrentSession(t, createResp.GetSessionId(), resp.GetSessionToken(), resp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId()) + sessionToken = resp.GetSessionToken() + + otp := resp.GetChallenges().GetOtpEmail() + require.NotEmpty(t, otp) + + resp, err = Client.SetSession(CTX, &session.SetSessionRequest{ + SessionId: createResp.GetSessionId(), + Checks: &session.Checks{ + OtpEmail: &session.CheckOTP{ + Code: otp, + }, + }, + }) + require.NoError(t, err) + sessionToken = resp.GetSessionToken() + verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, resp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId(), wantUserFactor, wantWebAuthNFactor, wantOTPEmailFactor) + }) +} + +func TestServer_SetSession_expired(t *testing.T) { + createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{ + Lifetime: durationpb.New(20 * time.Second), + }) + require.NoError(t, err) + + // test session token works + _, err = Client.SetSession(CTX, &session.SetSessionRequest{ + SessionId: createResp.GetSessionId(), + Lifetime: durationpb.New(20 * time.Second), + }) + require.NoError(t, err) + + // ensure session expires and does not work anymore + time.Sleep(20 * time.Second) + _, err = Client.SetSession(CTX, &session.SetSessionRequest{ + SessionId: createResp.GetSessionId(), + Lifetime: durationpb.New(20 * time.Second), + }) + require.Error(t, err) +} + +func TestServer_DeleteSession_token(t *testing.T) { + createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{}) + require.NoError(t, err) + + _, err = Client.DeleteSession(CTX, &session.DeleteSessionRequest{ + SessionId: createResp.GetSessionId(), + SessionToken: gu.Ptr("invalid"), + }) + require.Error(t, err) + + _, err = Client.DeleteSession(CTX, &session.DeleteSessionRequest{ + SessionId: createResp.GetSessionId(), + SessionToken: gu.Ptr(createResp.GetSessionToken()), + }) + require.NoError(t, err) +} + +func TestServer_DeleteSession_own_session(t *testing.T) { + // create two users for the test and a session each to get tokens for authorization + user1 := Tester.CreateHumanUser(CTX) + Tester.SetUserPassword(CTX, user1.GetUserId(), integration.UserPassword, false) + _, token1, _, _ := Tester.CreatePasswordSession(t, CTX, user1.GetUserId(), integration.UserPassword) + + user2 := Tester.CreateHumanUser(CTX) + Tester.SetUserPassword(CTX, user2.GetUserId(), integration.UserPassword, false) + _, token2, _, _ := Tester.CreatePasswordSession(t, CTX, user2.GetUserId(), integration.UserPassword) + + // create a new session for the first user + createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: user1.GetUserId(), + }, + }, + }, + }) + require.NoError(t, err) + + // delete the new (user1) session must not be possible with user (has no permission) + _, err = Client.DeleteSession(Tester.WithAuthorizationToken(context.Background(), token2), &session.DeleteSessionRequest{ + SessionId: createResp.GetSessionId(), + }) + require.Error(t, err) + + // delete the new (user1) session by themselves + _, err = Client.DeleteSession(Tester.WithAuthorizationToken(context.Background(), token1), &session.DeleteSessionRequest{ + SessionId: createResp.GetSessionId(), + }) + require.NoError(t, err) +} + +func TestServer_DeleteSession_with_permission(t *testing.T) { + createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: User.GetUserId(), + }, + }, + }, + }) + require.NoError(t, err) + + // delete the new session by ORG_OWNER + _, err = Client.DeleteSession(Tester.WithAuthorization(context.Background(), integration.OrgOwner), &session.DeleteSessionRequest{ + SessionId: createResp.GetSessionId(), + }) + require.NoError(t, err) +} + +func Test_ZITADEL_API_missing_authentication(t *testing.T) { + // create new, empty session + createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{}) + require.NoError(t, err) + + ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("Bearer %s", createResp.GetSessionToken())) + sessionResp, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: createResp.GetSessionId()}) + require.Error(t, err) + require.Nil(t, sessionResp) +} + +func Test_ZITADEL_API_success(t *testing.T) { + id, token, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, User.GetUserId()) + + ctx := Tester.WithAuthorizationToken(context.Background(), token) + sessionResp, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) + require.NoError(t, err) + + webAuthN := sessionResp.GetSession().GetFactors().GetWebAuthN() + require.NotNil(t, id, webAuthN.GetVerifiedAt().AsTime()) + require.True(t, webAuthN.GetUserVerified()) +} + +func Test_ZITADEL_API_session_not_found(t *testing.T) { + id, token, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, User.GetUserId()) + + // test session token works + ctx := Tester.WithAuthorizationToken(context.Background(), token) + _, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) + require.NoError(t, err) + + //terminate the session and test it does not work anymore + _, err = Client.DeleteSession(CTX, &session.DeleteSessionRequest{ + SessionId: id, + SessionToken: gu.Ptr(token), + }) + require.NoError(t, err) + ctx = Tester.WithAuthorizationToken(context.Background(), token) + _, err = Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) + require.Error(t, err) +} + +func Test_ZITADEL_API_session_expired(t *testing.T) { + id, token, _, _ := Tester.CreateVerifiedWebAuthNSessionWithLifetime(t, CTX, User.GetUserId(), 20*time.Second) + + // test session token works + ctx := Tester.WithAuthorizationToken(context.Background(), token) + _, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) + require.NoError(t, err) + + // ensure session expires and does not work anymore + time.Sleep(20 * time.Second) + sessionResp, err := Client.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) + require.Error(t, err) + require.Nil(t, sessionResp) +} diff --git a/internal/api/grpc/session/v2beta/session_test.go b/internal/api/grpc/session/v2beta/session_test.go new file mode 100644 index 0000000000..c088b5b886 --- /dev/null +++ b/internal/api/grpc/session/v2beta/session_test.go @@ -0,0 +1,739 @@ +package session + +import ( + "context" + "net" + "net/http" + "testing" + "time" + + "github.com/muhlemmer/gu" + "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" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + objpb "github.com/zitadel/zitadel/pkg/grpc/object" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" + session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta" +) + +var ( + creationDate = time.Date(2023, 10, 10, 14, 15, 0, 0, time.UTC) +) + +func Test_sessionsToPb(t *testing.T) { + now := time.Now() + past := now.Add(-time.Hour) + + sessions := []*query.Session{ + { // no factor, with user agent and expiration + ID: "999", + CreationDate: now, + ChangeDate: now, + Sequence: 123, + State: domain.SessionStateActive, + ResourceOwner: "me", + Creator: "he", + Metadata: map[string][]byte{"hello": []byte("world")}, + UserAgent: domain.UserAgent{ + FingerprintID: gu.Ptr("fingerprintID"), + Description: gu.Ptr("description"), + IP: net.IPv4(1, 2, 3, 4), + Header: http.Header{"foo": []string{"foo", "bar"}}, + }, + Expiration: now, + }, + { // 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", + ResourceOwner: "org1", + }, + Metadata: map[string][]byte{"hello": []byte("world")}, + }, + { // password 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", + ResourceOwner: "org1", + }, + PasswordFactor: query.SessionPasswordFactor{ + PasswordCheckedAt: past, + }, + Metadata: map[string][]byte{"hello": []byte("world")}, + }, + { // webAuthN 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", + ResourceOwner: "org1", + }, + WebAuthNFactor: query.SessionWebAuthNFactor{ + WebAuthNCheckedAt: past, + UserVerified: true, + }, + Metadata: map[string][]byte{"hello": []byte("world")}, + }, + { // totp 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", + ResourceOwner: "org1", + }, + TOTPFactor: query.SessionTOTPFactor{ + TOTPCheckedAt: past, + }, + Metadata: map[string][]byte{"hello": []byte("world")}, + }, + } + + want := []*session.Session{ + { // no factor, with user agent and expiration + Id: "999", + CreationDate: timestamppb.New(now), + ChangeDate: timestamppb.New(now), + Sequence: 123, + Factors: nil, + Metadata: map[string][]byte{"hello": []byte("world")}, + UserAgent: &session.UserAgent{ + FingerprintId: gu.Ptr("fingerprintID"), + Description: gu.Ptr("description"), + Ip: gu.Ptr("1.2.3.4"), + Header: map[string]*session.UserAgent_HeaderValues{ + "foo": {Values: []string{"foo", "bar"}}, + }, + }, + ExpirationDate: timestamppb.New(now), + }, + { // 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", + OrganizationId: "org1", + }, + }, + Metadata: map[string][]byte{"hello": []byte("world")}, + }, + { // password 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", + OrganizationId: "org1", + }, + Password: &session.PasswordFactor{ + VerifiedAt: timestamppb.New(past), + }, + }, + Metadata: map[string][]byte{"hello": []byte("world")}, + }, + { // webAuthN 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", + OrganizationId: "org1", + }, + WebAuthN: &session.WebAuthNFactor{ + VerifiedAt: timestamppb.New(past), + UserVerified: true, + }, + }, + Metadata: map[string][]byte{"hello": []byte("world")}, + }, + { // totp 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", + OrganizationId: "org1", + }, + Totp: &session.TOTPFactor{ + 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[i]) + } + } +} + +func Test_userAgentToPb(t *testing.T) { + type args struct { + ua domain.UserAgent + } + tests := []struct { + name string + args args + want *session.UserAgent + }{ + { + name: "empty", + args: args{domain.UserAgent{}}, + }, + { + name: "fingerprint id and description", + args: args{domain.UserAgent{ + FingerprintID: gu.Ptr("fingerPrintID"), + Description: gu.Ptr("description"), + }}, + want: &session.UserAgent{ + FingerprintId: gu.Ptr("fingerPrintID"), + Description: gu.Ptr("description"), + }, + }, + { + name: "with ip", + args: args{domain.UserAgent{ + FingerprintID: gu.Ptr("fingerPrintID"), + Description: gu.Ptr("description"), + IP: net.IPv4(1, 2, 3, 4), + }}, + want: &session.UserAgent{ + FingerprintId: gu.Ptr("fingerPrintID"), + Description: gu.Ptr("description"), + Ip: gu.Ptr("1.2.3.4"), + }, + }, + { + name: "with header", + args: args{domain.UserAgent{ + FingerprintID: gu.Ptr("fingerPrintID"), + Description: gu.Ptr("description"), + Header: http.Header{ + "foo": []string{"foo", "bar"}, + "hello": []string{"world"}, + }, + }}, + want: &session.UserAgent{ + FingerprintId: gu.Ptr("fingerPrintID"), + Description: gu.Ptr("description"), + Header: map[string]*session.UserAgent_HeaderValues{ + "foo": {Values: []string{"foo", "bar"}}, + "hello": {Values: []string{"world"}}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := userAgentToPb(tt.args.ua) + assert.Equal(t, tt.want, got) + }) + } +} + +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 mustNewTimestampQuery(t testing.TB, column query.Column, ts time.Time, compare query.TimestampComparison) query.SearchQuery { + q, err := query.NewTimestampQuery(column, ts, 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: "default request with sorting column", + args: args{ + ctx: authz.NewMockContext("123", "456", "789"), + req: &session.ListSessionsRequest{ + SortingColumn: session.SessionFieldName_SESSION_FIELD_NAME_CREATION_DATE, + }, + }, + want: &query.SessionsSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: 0, + Limit: 0, + SortingColumn: query.SessionColumnCreationDate, + 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"}, + }, + }}, + {Query: &session.SearchQuery_UserIdQuery{ + UserIdQuery: &session.UserIDQuery{ + Id: "10", + }, + }}, + {Query: &session.SearchQuery_CreationDateQuery{ + CreationDateQuery: &session.CreationDateQuery{ + CreationDate: timestamppb.New(creationDate), + Method: objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_GREATER, + }, + }}, + }, + }, + }, + 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.SessionColumnUserID, "10", query.TextEquals), + mustNewTimestampQuery(t, query.SessionColumnCreationDate, creationDate, query.TimestampGreater), + 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: zerrors.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: zerrors.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: zerrors.ThrowInvalidArgument(nil, "GRPC-Sfefs", "List.Query.Invalid"), + }, + { + name: "ids 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), + }, + { + name: "user id query", + args: args{&session.SearchQuery{ + Query: &session.SearchQuery_UserIdQuery{ + UserIdQuery: &session.UserIDQuery{ + Id: "10", + }, + }, + }}, + want: mustNewTextQuery(t, query.SessionColumnUserID, "10", query.TextEquals), + }, + { + name: "creation date query", + args: args{&session.SearchQuery{ + Query: &session.SearchQuery_CreationDateQuery{ + CreationDateQuery: &session.CreationDateQuery{ + CreationDate: timestamppb.New(creationDate), + Method: objpb.TimestampQueryMethod_TIMESTAMP_QUERY_METHOD_LESS, + }, + }, + }}, + want: mustNewTimestampQuery(t, query.SessionColumnCreationDate, creationDate, query.TimestampLess), + }, + { + name: "creation date query with default method", + args: args{&session.SearchQuery{ + Query: &session.SearchQuery_CreationDateQuery{ + CreationDateQuery: &session.CreationDateQuery{ + CreationDate: timestamppb.New(creationDate), + }, + }, + }}, + want: mustNewTimestampQuery(t, query.SessionColumnCreationDate, creationDate, query.TimestampEquals), + }, + } + 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 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{"bar"}, + }, + { + name: "unimplemented error", + args: args{&session.CheckUser{ + Search: nil, + }}, + wantErr: zerrors.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) + }) + } +} + +func Test_userVerificationRequirementToDomain(t *testing.T) { + type args struct { + req session.UserVerificationRequirement + } + tests := []struct { + args args + want domain.UserVerificationRequirement + }{ + { + args: args{session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_UNSPECIFIED}, + want: domain.UserVerificationRequirementUnspecified, + }, + { + args: args{session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_REQUIRED}, + want: domain.UserVerificationRequirementRequired, + }, + { + args: args{session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_PREFERRED}, + want: domain.UserVerificationRequirementPreferred, + }, + { + args: args{session.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_DISCOURAGED}, + want: domain.UserVerificationRequirementDiscouraged, + }, + { + args: args{999}, + want: domain.UserVerificationRequirementUnspecified, + }, + } + for _, tt := range tests { + t.Run(tt.args.req.String(), func(t *testing.T) { + got := userVerificationRequirementToDomain(tt.args.req) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_userAgentToCommand(t *testing.T) { + type args struct { + userAgent *session.UserAgent + } + tests := []struct { + name string + args args + want *domain.UserAgent + }{ + { + name: "nil", + args: args{nil}, + want: nil, + }, + { + name: "all fields", + args: args{&session.UserAgent{ + FingerprintId: gu.Ptr("fp1"), + Ip: gu.Ptr("1.2.3.4"), + Description: gu.Ptr("firefox"), + Header: map[string]*session.UserAgent_HeaderValues{ + "hello": { + Values: []string{"foo", "bar"}, + }, + }, + }}, + want: &domain.UserAgent{ + FingerprintID: gu.Ptr("fp1"), + IP: net.ParseIP("1.2.3.4"), + Description: gu.Ptr("firefox"), + Header: http.Header{ + "hello": []string{"foo", "bar"}, + }, + }, + }, + { + name: "invalid ip", + args: args{&session.UserAgent{ + FingerprintId: gu.Ptr("fp1"), + Ip: gu.Ptr("oops"), + Description: gu.Ptr("firefox"), + Header: map[string]*session.UserAgent_HeaderValues{ + "hello": { + Values: []string{"foo", "bar"}, + }, + }, + }}, + want: &domain.UserAgent{ + FingerprintID: gu.Ptr("fp1"), + IP: nil, + Description: gu.Ptr("firefox"), + Header: http.Header{ + "hello": []string{"foo", "bar"}, + }, + }, + }, + { + name: "nil fields", + args: args{&session.UserAgent{}}, + want: &domain.UserAgent{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := userAgentToCommand(tt.args.userAgent) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/api/grpc/settings/v2/server.go b/internal/api/grpc/settings/v2/server.go index f001549595..0391d01188 100644 --- a/internal/api/grpc/settings/v2/server.go +++ b/internal/api/grpc/settings/v2/server.go @@ -10,7 +10,7 @@ import ( "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/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/settings/v2" ) var _ settings.SettingsServiceServer = (*Server)(nil) diff --git a/internal/api/grpc/settings/v2/server_integration_test.go b/internal/api/grpc/settings/v2/server_integration_test.go index 703e8cb971..611194a5ce 100644 --- a/internal/api/grpc/settings/v2/server_integration_test.go +++ b/internal/api/grpc/settings/v2/server_integration_test.go @@ -9,7 +9,7 @@ import ( "time" "github.com/zitadel/zitadel/internal/integration" - settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/settings/v2" ) var ( diff --git a/internal/api/grpc/settings/v2/settings.go b/internal/api/grpc/settings/v2/settings.go index 9b6546645a..3e48ab0c04 100644 --- a/internal/api/grpc/settings/v2/settings.go +++ b/internal/api/grpc/settings/v2/settings.go @@ -10,8 +10,8 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/i18n" "github.com/zitadel/zitadel/internal/query" - object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" - settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta" + object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2" + "github.com/zitadel/zitadel/pkg/grpc/settings/v2" ) func (s *Server) GetLoginSettings(ctx context.Context, req *settings.GetLoginSettingsRequest) (*settings.GetLoginSettingsResponse, error) { diff --git a/internal/api/grpc/settings/v2/settings_converter.go b/internal/api/grpc/settings/v2/settings_converter.go index 912df689aa..848ea3e14a 100644 --- a/internal/api/grpc/settings/v2/settings_converter.go +++ b/internal/api/grpc/settings/v2/settings_converter.go @@ -8,7 +8,7 @@ import ( "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" - settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/settings/v2" ) func loginSettingsToPb(current *query.LoginPolicy) *settings.LoginSettings { diff --git a/internal/api/grpc/settings/v2/settings_converter_test.go b/internal/api/grpc/settings/v2/settings_converter_test.go index 99c60f2628..75785c47b8 100644 --- a/internal/api/grpc/settings/v2/settings_converter_test.go +++ b/internal/api/grpc/settings/v2/settings_converter_test.go @@ -16,7 +16,7 @@ import ( "github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" - settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/settings/v2" ) var ignoreTypes = []protoreflect.FullName{"google.protobuf.Duration"} diff --git a/internal/api/grpc/settings/v2/settings_integration_test.go b/internal/api/grpc/settings/v2/settings_integration_test.go index 3accc0d63f..c9f5f7bd8f 100644 --- a/internal/api/grpc/settings/v2/settings_integration_test.go +++ b/internal/api/grpc/settings/v2/settings_integration_test.go @@ -11,8 +11,8 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/integration" - object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" - settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta" + object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2" + "github.com/zitadel/zitadel/pkg/grpc/settings/v2" ) func TestServer_GetSecuritySettings(t *testing.T) { diff --git a/internal/api/grpc/settings/v2beta/server.go b/internal/api/grpc/settings/v2beta/server.go new file mode 100644 index 0000000000..f001549595 --- /dev/null +++ b/internal/api/grpc/settings/v2beta/server.go @@ -0,0 +1,57 @@ +package settings + +import ( + "context" + + "google.golang.org/grpc" + + "github.com/zitadel/zitadel/internal/api/assets" + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/grpc/server" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/query" + settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta" +) + +var _ settings.SettingsServiceServer = (*Server)(nil) + +type Server struct { + settings.UnimplementedSettingsServiceServer + command *command.Commands + query *query.Queries + assetsAPIDomain func(context.Context) string +} + +type Config struct{} + +func CreateServer( + command *command.Commands, + query *query.Queries, + externalSecure bool, +) *Server { + return &Server{ + command: command, + query: query, + assetsAPIDomain: assets.AssetAPI(externalSecure), + } +} + +func (s *Server) RegisterServer(grpcServer *grpc.Server) { + settings.RegisterSettingsServiceServer(grpcServer, s) +} + +func (s *Server) AppName() string { + return settings.SettingsService_ServiceDesc.ServiceName +} + +func (s *Server) MethodPrefix() string { + return settings.SettingsService_ServiceDesc.ServiceName +} + +func (s *Server) AuthMethods() authz.MethodMapping { + return settings.SettingsService_AuthMethods +} + +func (s *Server) RegisterGateway() server.RegisterGatewayFunc { + return settings.RegisterSettingsServiceHandler +} diff --git a/internal/api/grpc/settings/v2beta/server_integration_test.go b/internal/api/grpc/settings/v2beta/server_integration_test.go new file mode 100644 index 0000000000..34afe5733d --- /dev/null +++ b/internal/api/grpc/settings/v2beta/server_integration_test.go @@ -0,0 +1,34 @@ +//go:build integration + +package settings_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/zitadel/zitadel/internal/integration" + settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta" +) + +var ( + CTX, AdminCTX context.Context + Tester *integration.Tester + Client settings.SettingsServiceClient +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, _, cancel := integration.Contexts(3 * time.Minute) + defer cancel() + + Tester = integration.NewTester(ctx) + defer Tester.Done() + + CTX = ctx + AdminCTX = Tester.WithAuthorization(ctx, integration.IAMOwner) + Client = Tester.Client.SettingsV2beta + return m.Run() + }()) +} diff --git a/internal/api/grpc/settings/v2beta/settings.go b/internal/api/grpc/settings/v2beta/settings.go new file mode 100644 index 0000000000..677d8f1c15 --- /dev/null +++ b/internal/api/grpc/settings/v2beta/settings.go @@ -0,0 +1,161 @@ +package settings + +import ( + "context" + + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/authz" + object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/i18n" + "github.com/zitadel/zitadel/internal/query" + object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" + settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta" +) + +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: passwordComplexitySettingsToPb(current), + Details: &object_pb.Details{ + Sequence: current.Sequence, + ChangeDate: timestamppb.New(current.ChangeDate), + ResourceOwner: current.ResourceOwner, + }, + }, nil +} + +func (s *Server) GetPasswordExpirySettings(ctx context.Context, req *settings.GetPasswordExpirySettingsRequest) (*settings.GetPasswordExpirySettingsResponse, error) { + current, err := s.query.PasswordAgePolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) + if err != nil { + return nil, err + } + return &settings.GetPasswordExpirySettingsResponse{ + Settings: passwordExpirySettingsToPb(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())) + 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) { + instance := authz.GetInstance(ctx) + return &settings.GetGeneralSettingsResponse{ + SupportedLanguages: domain.LanguagesToStrings(i18n.SupportedLanguages()), + DefaultOrgId: instance.DefaultOrganisationID(), + DefaultLanguage: instance.DefaultLanguage().String(), + }, nil +} + +func (s *Server) GetSecuritySettings(ctx context.Context, req *settings.GetSecuritySettingsRequest) (*settings.GetSecuritySettingsResponse, error) { + policy, err := s.query.SecurityPolicy(ctx) + if err != nil { + return nil, err + } + return &settings.GetSecuritySettingsResponse{ + Settings: securityPolicyToSettingsPb(policy), + }, nil +} + +func (s *Server) SetSecuritySettings(ctx context.Context, req *settings.SetSecuritySettingsRequest) (*settings.SetSecuritySettingsResponse, error) { + details, err := s.command.SetSecurityPolicy(ctx, securitySettingsToCommand(req)) + if err != nil { + return nil, err + } + return &settings.SetSecuritySettingsResponse{ + Details: object.DomainToDetailsPb(details), + }, nil +} diff --git a/internal/api/grpc/settings/v2beta/settings_converter.go b/internal/api/grpc/settings/v2beta/settings_converter.go new file mode 100644 index 0000000000..2b20e738e1 --- /dev/null +++ b/internal/api/grpc/settings/v2beta/settings_converter.go @@ -0,0 +1,245 @@ +package settings + +import ( + "time" + + "google.golang.org/protobuf/types/known/durationpb" + + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta" +) + +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, + ForceMfaLocalOnly: current.ForceMFALocalOnly, + 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(time.Duration(current.PasswordCheckLifetime)), + ExternalLoginCheckLifetime: durationpb.New(time.Duration(current.ExternalLoginCheckLifetime)), + MfaInitSkipLifetime: durationpb.New(time.Duration(current.MFAInitSkipLifetime)), + SecondFactorCheckLifetime: durationpb.New(time.Duration(current.SecondFactorCheckLifetime)), + MultiFactorCheckLifetime: durationpb.New(time.Duration(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.SecondFactorTypeTOTP: + return settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP + case domain.SecondFactorTypeU2F: + return settings.SecondFactorType_SECOND_FACTOR_TYPE_U2F + case domain.SecondFactorTypeOTPEmail: + return settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP_EMAIL + case domain.SecondFactorTypeOTPSMS: + return settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP_SMS + 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 passwordComplexitySettingsToPb(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 passwordExpirySettingsToPb(current *query.PasswordAgePolicy) *settings.PasswordExpirySettings { + return &settings.PasswordExpirySettings{ + MaxAgeDays: current.MaxAgeDays, + ExpireWarnDays: current.ExpireWarnDays, + 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), + ThemeMode: themeModeToPb(current.ThemeMode), + } +} + +func themeModeToPb(themeMode domain.LabelPolicyThemeMode) settings.ThemeMode { + switch themeMode { + case domain.LabelPolicyThemeAuto: + return settings.ThemeMode_THEME_MODE_AUTO + case domain.LabelPolicyThemeLight: + return settings.ThemeMode_THEME_MODE_LIGHT + case domain.LabelPolicyThemeDark: + return settings.ThemeMode_THEME_MODE_DARK + default: + return settings.ThemeMode_THEME_MODE_AUTO + } +} + +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), + DocsLink: current.DocsLink, + CustomLink: current.CustomLink, + CustomLinkText: current.CustomLinkText, + } +} + +func lockoutSettingsToPb(current *query.LockoutPolicy) *settings.LockoutSettings { + return &settings.LockoutSettings{ + MaxPasswordAttempts: current.MaxPasswordAttempts, + MaxOtpAttempts: current.MaxOTPAttempts, + 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 + case domain.IDPTypeSAML: + return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_SAML + case domain.IDPTypeApple: + // Handle all remaining cases so the linter succeeds + return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_UNSPECIFIED + default: + return settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_UNSPECIFIED + } +} + +func securityPolicyToSettingsPb(policy *query.SecurityPolicy) *settings.SecuritySettings { + return &settings.SecuritySettings{ + EmbeddedIframe: &settings.EmbeddedIframeSettings{ + Enabled: policy.EnableIframeEmbedding, + AllowedOrigins: policy.AllowedOrigins, + }, + EnableImpersonation: policy.EnableImpersonation, + } +} + +func securitySettingsToCommand(req *settings.SetSecuritySettingsRequest) *command.SecurityPolicy { + return &command.SecurityPolicy{ + EnableIframeEmbedding: req.GetEmbeddedIframe().GetEnabled(), + AllowedOrigins: req.GetEmbeddedIframe().GetAllowedOrigins(), + EnableImpersonation: req.GetEnableImpersonation(), + } +} diff --git a/internal/api/grpc/settings/v2beta/settings_converter_test.go b/internal/api/grpc/settings/v2beta/settings_converter_test.go new file mode 100644 index 0000000000..99c60f2628 --- /dev/null +++ b/internal/api/grpc/settings/v2beta/settings_converter_test.go @@ -0,0 +1,517 @@ +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/api/grpc" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta" +) + +var ignoreTypes = []protoreflect.FullName{"google.protobuf.Duration"} + +func Test_loginSettingsToPb(t *testing.T) { + arg := &query.LoginPolicy{ + AllowUsernamePassword: true, + AllowRegister: true, + AllowExternalIDPs: true, + ForceMFA: true, + ForceMFALocalOnly: true, + PasswordlessType: domain.PasswordlessTypeAllowed, + HidePasswordReset: true, + IgnoreUnknownUsernames: true, + AllowDomainDiscovery: true, + DisableLoginWithEmail: true, + DisableLoginWithPhone: true, + DefaultRedirectURI: "example.com", + PasswordCheckLifetime: database.Duration(time.Hour), + ExternalLoginCheckLifetime: database.Duration(time.Minute), + MFAInitSkipLifetime: database.Duration(time.Millisecond), + SecondFactorCheckLifetime: database.Duration(time.Microsecond), + MultiFactorCheckLifetime: database.Duration(time.Nanosecond), + SecondFactors: []domain.SecondFactorType{ + domain.SecondFactorTypeTOTP, + domain.SecondFactorTypeU2F, + domain.SecondFactorTypeOTPEmail, + domain.SecondFactorTypeOTPSMS, + }, + MultiFactors: []domain.MultiFactorType{ + domain.MultiFactorTypeU2FWithPIN, + }, + IsDefault: true, + } + + want := &settings.LoginSettings{ + AllowUsernamePassword: true, + AllowRegister: true, + AllowExternalIdp: true, + ForceMfa: true, + ForceMfaLocalOnly: 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, + settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP_EMAIL, + settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP_SMS, + }, + MultiFactors: []settings.MultiFactorType{ + settings.MultiFactorType_MULTI_FACTOR_TYPE_U2F_WITH_VERIFICATION, + }, + ResourceOwnerType: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE, + } + + got := loginSettingsToPb(arg) + grpc.AllFieldsSet(t, got.ProtoReflect(), ignoreTypes...) + 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.SecondFactorTypeTOTP}, + want: settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP, + }, + { + args: args{domain.SecondFactorTypeU2F}, + want: settings.SecondFactorType_SECOND_FACTOR_TYPE_U2F, + }, + { + args: args{domain.SecondFactorTypeOTPSMS}, + want: settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP_SMS, + }, + { + args: args{domain.SecondFactorTypeOTPEmail}, + want: settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP_EMAIL, + }, + { + 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_passwordComplexitySettingsToPb(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 := passwordComplexitySettingsToPb(arg) + grpc.AllFieldsSet(t, got.ProtoReflect(), ignoreTypes...) + if !proto.Equal(got, want) { + t.Errorf("passwordComplexitySettingsToPb() =\n%v\nwant\n%v", got, want) + } +} + +func Test_passwordExpirySettingsToPb(t *testing.T) { + arg := &query.PasswordAgePolicy{ + ExpireWarnDays: 80, + MaxAgeDays: 90, + IsDefault: true, + } + want := &settings.PasswordExpirySettings{ + ExpireWarnDays: 80, + MaxAgeDays: 90, + ResourceOwnerType: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE, + } + + got := passwordExpirySettingsToPb(arg) + grpc.AllFieldsSet(t, got.ProtoReflect(), ignoreTypes...) + if !proto.Equal(got, want) { + t.Errorf("passwordExpirySettingsToPb() =\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, + ThemeMode: domain.LabelPolicyThemeDark, + 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, + ThemeMode: settings.ThemeMode_THEME_MODE_DARK, + } + + got := brandingSettingsToPb(arg, "http://example.com") + grpc.AllFieldsSet(t, got.ProtoReflect(), ignoreTypes...) + 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) + grpc.AllFieldsSet(t, got.ProtoReflect(), ignoreTypes...) + 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, + DocsLink: "http://example.com/docs", + CustomLink: "http://example.com/custom", + CustomLinkText: "Custom", + } + want := &settings.LegalAndSupportSettings{ + TosLink: "http://example.com/tos", + PrivacyPolicyLink: "http://example.com/pricacy", + HelpLink: "http://example.com/help", + SupportEmail: "support@zitadel.com", + DocsLink: "http://example.com/docs", + CustomLink: "http://example.com/custom", + CustomLinkText: "Custom", + ResourceOwnerType: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE, + } + got := legalAndSupportSettingsToPb(arg) + grpc.AllFieldsSet(t, got.ProtoReflect(), ignoreTypes...) + 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, + MaxOTPAttempts: 22, + IsDefault: true, + } + want := &settings.LockoutSettings{ + MaxPasswordAttempts: 22, + MaxOtpAttempts: 22, + ResourceOwnerType: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE, + } + got := lockoutSettingsToPb(arg) + grpc.AllFieldsSet(t, got.ProtoReflect(), ignoreTypes...) + 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 { + grpc.AllFieldsSet(t, v.ProtoReflect(), ignoreTypes...) + 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{domain.IDPTypeSAML}, + want: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_SAML, + }, + { + 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) + } + }) + } +} + +func Test_securityPolicyToSettingsPb(t *testing.T) { + want := &settings.SecuritySettings{ + EmbeddedIframe: &settings.EmbeddedIframeSettings{ + Enabled: true, + AllowedOrigins: []string{"foo", "bar"}, + }, + EnableImpersonation: true, + } + got := securityPolicyToSettingsPb(&query.SecurityPolicy{ + EnableIframeEmbedding: true, + AllowedOrigins: []string{"foo", "bar"}, + EnableImpersonation: true, + }) + assert.Equal(t, want, got) +} + +func Test_securitySettingsToCommand(t *testing.T) { + want := &command.SecurityPolicy{ + EnableIframeEmbedding: true, + AllowedOrigins: []string{"foo", "bar"}, + EnableImpersonation: true, + } + got := securitySettingsToCommand(&settings.SetSecuritySettingsRequest{ + EmbeddedIframe: &settings.EmbeddedIframeSettings{ + Enabled: true, + AllowedOrigins: []string{"foo", "bar"}, + }, + EnableImpersonation: true, + }) + assert.Equal(t, want, got) +} diff --git a/internal/api/grpc/settings/v2beta/settings_integration_test.go b/internal/api/grpc/settings/v2beta/settings_integration_test.go new file mode 100644 index 0000000000..3accc0d63f --- /dev/null +++ b/internal/api/grpc/settings/v2beta/settings_integration_test.go @@ -0,0 +1,174 @@ +//go:build integration + +package settings_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/integration" + object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" + settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta" +) + +func TestServer_GetSecuritySettings(t *testing.T) { + _, err := Client.SetSecuritySettings(AdminCTX, &settings.SetSecuritySettingsRequest{ + EmbeddedIframe: &settings.EmbeddedIframeSettings{ + Enabled: true, + AllowedOrigins: []string{"foo", "bar"}, + }, + EnableImpersonation: true, + }) + require.NoError(t, err) + + tests := []struct { + name string + ctx context.Context + want *settings.GetSecuritySettingsResponse + wantErr bool + }{ + { + name: "permission error", + ctx: Tester.WithAuthorization(CTX, integration.OrgOwner), + wantErr: true, + }, + { + name: "success", + ctx: AdminCTX, + want: &settings.GetSecuritySettingsResponse{ + Settings: &settings.SecuritySettings{ + EmbeddedIframe: &settings.EmbeddedIframeSettings{ + Enabled: true, + AllowedOrigins: []string{"foo", "bar"}, + }, + EnableImpersonation: true, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := Client.GetSecuritySettings(tt.ctx, &settings.GetSecuritySettingsRequest{}) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + got, want := resp.GetSettings(), tt.want.GetSettings() + assert.Equal(t, want.GetEmbeddedIframe().GetEnabled(), got.GetEmbeddedIframe().GetEnabled(), "enable iframe embedding") + assert.Equal(t, want.GetEmbeddedIframe().GetAllowedOrigins(), got.GetEmbeddedIframe().GetAllowedOrigins(), "allowed origins") + assert.Equal(t, want.GetEnableImpersonation(), got.GetEnableImpersonation(), "enable impersonation") + }) + } +} + +func TestServer_SetSecuritySettings(t *testing.T) { + type args struct { + ctx context.Context + req *settings.SetSecuritySettingsRequest + } + tests := []struct { + name string + args args + want *settings.SetSecuritySettingsResponse + wantErr bool + }{ + { + name: "permission error", + args: args{ + ctx: Tester.WithAuthorization(CTX, integration.OrgOwner), + req: &settings.SetSecuritySettingsRequest{ + EmbeddedIframe: &settings.EmbeddedIframeSettings{ + Enabled: true, + AllowedOrigins: []string{"foo.com", "bar.com"}, + }, + EnableImpersonation: true, + }, + }, + wantErr: true, + }, + { + name: "success allowed origins", + args: args{ + ctx: AdminCTX, + req: &settings.SetSecuritySettingsRequest{ + EmbeddedIframe: &settings.EmbeddedIframeSettings{ + AllowedOrigins: []string{"foo.com", "bar.com"}, + }, + }, + }, + want: &settings.SetSecuritySettingsResponse{ + Details: &object_pb.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + }, + }, + { + name: "success enable iframe embedding", + args: args{ + ctx: AdminCTX, + req: &settings.SetSecuritySettingsRequest{ + EmbeddedIframe: &settings.EmbeddedIframeSettings{ + Enabled: true, + }, + }, + }, + want: &settings.SetSecuritySettingsResponse{ + Details: &object_pb.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + }, + }, + { + name: "success impersonation", + args: args{ + ctx: AdminCTX, + req: &settings.SetSecuritySettingsRequest{ + EnableImpersonation: true, + }, + }, + want: &settings.SetSecuritySettingsResponse{ + Details: &object_pb.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + }, + }, + { + name: "success all", + args: args{ + ctx: AdminCTX, + req: &settings.SetSecuritySettingsRequest{ + EmbeddedIframe: &settings.EmbeddedIframeSettings{ + Enabled: true, + AllowedOrigins: []string{"foo.com", "bar.com"}, + }, + EnableImpersonation: true, + }, + }, + want: &settings.SetSecuritySettingsResponse{ + Details: &object_pb.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.SetSecuritySettings(tt.args.ctx, tt.args.req) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + integration.AssertDetails(t, tt.want, got) + }) + } +} diff --git a/internal/api/grpc/user/converter.go b/internal/api/grpc/user/converter.go index 621d445672..85592b0f12 100644 --- a/internal/api/grpc/user/converter.go +++ b/internal/api/grpc/user/converter.go @@ -5,7 +5,6 @@ import ( "github.com/zitadel/zitadel/internal/api/grpc/object" "github.com/zitadel/zitadel/internal/domain" - "github.com/zitadel/zitadel/internal/eventstore/v1/models" "github.com/zitadel/zitadel/internal/query" user_pb "github.com/zitadel/zitadel/pkg/grpc/user" ) @@ -255,22 +254,6 @@ func UserAuthMethodToWebAuthNTokenPb(token *query.AuthMethod) *user_pb.WebAuthNT } } -func ExternalIDPViewsToExternalIDPs(externalIDPs []*query.IDPUserLink) []*domain.UserIDPLink { - idps := make([]*domain.UserIDPLink, len(externalIDPs)) - for i, idp := range externalIDPs { - idps[i] = &domain.UserIDPLink{ - ObjectRoot: models.ObjectRoot{ - AggregateID: idp.UserID, - ResourceOwner: idp.ResourceOwner, - }, - IDPConfigID: idp.IDPID, - ExternalUserID: idp.ProvidedUserID, - DisplayName: idp.ProvidedUsername, - } - } - return idps -} - func TypeToPb(userType domain.UserType) user_pb.Type { switch userType { case domain.UserTypeHuman: diff --git a/internal/api/grpc/user/schema/v3alpha/schema_integration_test.go b/internal/api/grpc/user/schema/v3alpha/schema_integration_test.go index f84c338ab4..5cf279144d 100644 --- a/internal/api/grpc/user/schema/v3alpha/schema_integration_test.go +++ b/internal/api/grpc/user/schema/v3alpha/schema_integration_test.go @@ -17,8 +17,8 @@ import ( "github.com/zitadel/zitadel/internal/api/grpc" "github.com/zitadel/zitadel/internal/integration" - feature "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta" - object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/feature/v2" + "github.com/zitadel/zitadel/pkg/grpc/object/v2" schema "github.com/zitadel/zitadel/pkg/grpc/user/schema/v3alpha" ) diff --git a/internal/api/grpc/user/v2/email.go b/internal/api/grpc/user/v2/email.go index 38cc73c75c..6d0871b26e 100644 --- a/internal/api/grpc/user/v2/email.go +++ b/internal/api/grpc/user/v2/email.go @@ -7,8 +7,8 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/zerrors" - object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" - user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/object/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) func (s *Server) SetEmail(ctx context.Context, req *user.SetEmailRequest) (resp *user.SetEmailResponse, err error) { diff --git a/internal/api/grpc/user/v2/email_integration_test.go b/internal/api/grpc/user/v2/email_integration_test.go index 4034a5e7da..2264934f25 100644 --- a/internal/api/grpc/user/v2/email_integration_test.go +++ b/internal/api/grpc/user/v2/email_integration_test.go @@ -12,9 +12,10 @@ import ( "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/timestamppb" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2" + "github.com/zitadel/zitadel/internal/integration" - object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" - user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" ) func TestServer_SetEmail(t *testing.T) { diff --git a/internal/api/grpc/user/v2/idp_link.go b/internal/api/grpc/user/v2/idp_link.go new file mode 100644 index 0000000000..5567ab24a2 --- /dev/null +++ b/internal/api/grpc/user/v2/idp_link.go @@ -0,0 +1,94 @@ +package user + +import ( + "context" + + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +func (s *Server) AddIDPLink(ctx context.Context, req *user.AddIDPLinkRequest) (_ *user.AddIDPLinkResponse, err error) { + details, err := s.command.AddUserIDPLink(ctx, req.UserId, "", &command.AddLink{ + IDPID: req.GetIdpLink().GetIdpId(), + DisplayName: req.GetIdpLink().GetUserName(), + IDPExternalID: req.GetIdpLink().GetUserId(), + }) + if err != nil { + return nil, err + } + return &user.AddIDPLinkResponse{ + Details: object.DomainToDetailsPb(details), + }, nil +} + +func (s *Server) ListIDPLinks(ctx context.Context, req *user.ListIDPLinksRequest) (_ *user.ListIDPLinksResponse, err error) { + queries, err := ListLinkedIDPsRequestToQuery(req) + if err != nil { + return nil, err + } + res, err := s.query.IDPUserLinks(ctx, queries, false) + if err != nil { + return nil, err + } + res.RemoveNoPermission(ctx, s.checkPermission) + return &user.ListIDPLinksResponse{ + Result: IDPLinksToPb(res.Links), + Details: object.ToListDetails(res.SearchResponse), + }, nil +} + +func ListLinkedIDPsRequestToQuery(req *user.ListIDPLinksRequest) (*query.IDPUserLinksSearchQuery, error) { + offset, limit, asc := object.ListQueryToQuery(req.Query) + userQuery, err := query.NewIDPUserLinksUserIDSearchQuery(req.UserId) + if err != nil { + return nil, err + } + return &query.IDPUserLinksSearchQuery{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + }, + Queries: []query.SearchQuery{userQuery}, + }, nil +} + +func IDPLinksToPb(res []*query.IDPUserLink) []*user.IDPLink { + links := make([]*user.IDPLink, len(res)) + for i, link := range res { + links[i] = IDPLinkToPb(link) + } + return links +} + +func IDPLinkToPb(link *query.IDPUserLink) *user.IDPLink { + return &user.IDPLink{ + IdpId: link.IDPID, + UserId: link.ProvidedUserID, + UserName: link.ProvidedUsername, + } +} + +func (s *Server) RemoveIDPLink(ctx context.Context, req *user.RemoveIDPLinkRequest) (*user.RemoveIDPLinkResponse, error) { + objectDetails, err := s.command.RemoveUserIDPLink(ctx, RemoveIDPLinkRequestToDomain(ctx, req)) + if err != nil { + return nil, err + } + return &user.RemoveIDPLinkResponse{ + Details: object.DomainToDetailsPb(objectDetails), + }, nil +} + +func RemoveIDPLinkRequestToDomain(ctx context.Context, req *user.RemoveIDPLinkRequest) *domain.UserIDPLink { + return &domain.UserIDPLink{ + ObjectRoot: models.ObjectRoot{ + AggregateID: req.UserId, + }, + IDPConfigID: req.IdpId, + ExternalUserID: req.LinkedUserId, + } +} diff --git a/internal/api/grpc/user/v2/idp_link_integration_test.go b/internal/api/grpc/user/v2/idp_link_integration_test.go new file mode 100644 index 0000000000..6b85c80f98 --- /dev/null +++ b/internal/api/grpc/user/v2/idp_link_integration_test.go @@ -0,0 +1,360 @@ +//go:build integration + +package user_test + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/object/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +func TestServer_AddIDPLink(t *testing.T) { + idpID := Tester.AddGenericOAuthProvider(t, CTX) + type args struct { + ctx context.Context + req *user.AddIDPLinkRequest + } + tests := []struct { + name string + args args + want *user.AddIDPLinkResponse + wantErr bool + }{ + { + name: "user does not exist", + args: args{ + CTX, + &user.AddIDPLinkRequest{ + UserId: "userID", + IdpLink: &user.IDPLink{ + IdpId: idpID, + UserId: "userID", + UserName: "username", + }, + }, + }, + want: nil, + wantErr: true, + }, + { + name: "idp does not exist", + args: args{ + CTX, + &user.AddIDPLinkRequest{ + UserId: Tester.Users[integration.FirstInstanceUsersKey][integration.OrgOwner].ID, + IdpLink: &user.IDPLink{ + IdpId: "idpID", + UserId: "userID", + UserName: "username", + }, + }, + }, + want: nil, + wantErr: true, + }, + { + name: "add link", + args: args{ + CTX, + &user.AddIDPLinkRequest{ + UserId: Tester.Users[integration.FirstInstanceUsersKey][integration.OrgOwner].ID, + IdpLink: &user.IDPLink{ + IdpId: idpID, + UserId: "userID", + UserName: "username", + }, + }, + }, + want: &user.AddIDPLinkResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.AddIDPLink(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_ListIDPLinks(t *testing.T) { + orgResp := Tester.CreateOrganization(IamCTX, fmt.Sprintf("ListIDPLinks%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())) + + instanceIdpID := Tester.AddGenericOAuthProvider(t, IamCTX) + userInstanceResp := Tester.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, fmt.Sprintf("%d@listidplinks.com", time.Now().UnixNano())) + Tester.CreateUserIDPlink(IamCTX, userInstanceResp.GetUserId(), "external_instance", instanceIdpID, "externalUsername_instance") + + orgIdpID := Tester.AddOrgGenericOAuthProvider(t, IamCTX, orgResp.OrganizationId) + userOrgResp := Tester.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, fmt.Sprintf("%d@listidplinks.com", time.Now().UnixNano())) + Tester.CreateUserIDPlink(IamCTX, userOrgResp.GetUserId(), "external_org", orgIdpID, "externalUsername_org") + + userMultipleResp := Tester.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, fmt.Sprintf("%d@listidplinks.com", time.Now().UnixNano())) + Tester.CreateUserIDPlink(IamCTX, userMultipleResp.GetUserId(), "external_multi", instanceIdpID, "externalUsername_multi") + Tester.CreateUserIDPlink(IamCTX, userMultipleResp.GetUserId(), "external_multi", orgIdpID, "externalUsername_multi") + + type args struct { + ctx context.Context + req *user.ListIDPLinksRequest + } + tests := []struct { + name string + args args + want *user.ListIDPLinksResponse + wantErr bool + }{ + { + name: "list links, no permission", + args: args{ + UserCTX, + &user.ListIDPLinksRequest{ + UserId: userOrgResp.GetUserId(), + }, + }, + want: &user.ListIDPLinksResponse{ + Details: &object.ListDetails{ + TotalResult: 0, + Timestamp: timestamppb.Now(), + }, + Result: []*user.IDPLink{}, + }, + }, + { + name: "list links, no permission, org", + args: args{ + CTX, + &user.ListIDPLinksRequest{ + UserId: userOrgResp.GetUserId(), + }, + }, + want: &user.ListIDPLinksResponse{ + Details: &object.ListDetails{ + TotalResult: 0, + Timestamp: timestamppb.Now(), + }, + Result: []*user.IDPLink{}, + }, + }, + { + name: "list idp links, org, ok", + args: args{ + IamCTX, + &user.ListIDPLinksRequest{ + UserId: userOrgResp.GetUserId(), + }, + }, + want: &user.ListIDPLinksResponse{ + Details: &object.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + Result: []*user.IDPLink{ + { + IdpId: orgIdpID, + UserId: "external_org", + UserName: "externalUsername_org", + }, + }, + }, + }, + { + name: "list idp links, instance, ok", + args: args{ + IamCTX, + &user.ListIDPLinksRequest{ + UserId: userInstanceResp.GetUserId(), + }, + }, + want: &user.ListIDPLinksResponse{ + Details: &object.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + Result: []*user.IDPLink{ + { + IdpId: instanceIdpID, + UserId: "external_instance", + UserName: "externalUsername_instance", + }, + }, + }, + }, + { + name: "list idp links, multi, ok", + args: args{ + IamCTX, + &user.ListIDPLinksRequest{ + UserId: userMultipleResp.GetUserId(), + }, + }, + want: &user.ListIDPLinksResponse{ + Details: &object.ListDetails{ + TotalResult: 2, + Timestamp: timestamppb.Now(), + }, + Result: []*user.IDPLink{ + { + IdpId: instanceIdpID, + UserId: "external_multi", + UserName: "externalUsername_multi", + }, + { + IdpId: orgIdpID, + UserId: "external_multi", + UserName: "externalUsername_multi", + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + retryDuration := time.Minute + if ctxDeadline, ok := CTX.Deadline(); ok { + retryDuration = time.Until(ctxDeadline) + } + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, listErr := Client.ListIDPLinks(tt.args.ctx, tt.args.req) + assertErr := assert.NoError + if tt.wantErr { + assertErr = assert.Error + } + assertErr(ttt, listErr) + if listErr != nil { + return + } + // always first check length, otherwise its failed anyway + assert.Len(ttt, got.Result, len(tt.want.Result)) + for i := range tt.want.Result { + assert.Contains(ttt, got.Result, tt.want.Result[i]) + } + integration.AssertListDetails(t, tt.want, got) + }, retryDuration, time.Millisecond*100, "timeout waiting for expected idplinks result") + }) + } +} + +func TestServer_RemoveIDPLink(t *testing.T) { + orgResp := Tester.CreateOrganization(IamCTX, fmt.Sprintf("ListIDPLinks%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())) + + instanceIdpID := Tester.AddGenericOAuthProvider(t, IamCTX) + userInstanceResp := Tester.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, fmt.Sprintf("%d@listidplinks.com", time.Now().UnixNano())) + Tester.CreateUserIDPlink(IamCTX, userInstanceResp.GetUserId(), "external_instance", instanceIdpID, "externalUsername_instance") + + orgIdpID := Tester.AddOrgGenericOAuthProvider(t, IamCTX, orgResp.OrganizationId) + userOrgResp := Tester.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, fmt.Sprintf("%d@listidplinks.com", time.Now().UnixNano())) + Tester.CreateUserIDPlink(IamCTX, userOrgResp.GetUserId(), "external_org", orgIdpID, "externalUsername_org") + + userNoLinkResp := Tester.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, fmt.Sprintf("%d@listidplinks.com", time.Now().UnixNano())) + + type args struct { + ctx context.Context + req *user.RemoveIDPLinkRequest + } + tests := []struct { + name string + args args + want *user.RemoveIDPLinkResponse + wantErr bool + }{ + { + name: "remove link, no permission", + args: args{ + UserCTX, + &user.RemoveIDPLinkRequest{ + UserId: userOrgResp.GetUserId(), + IdpId: orgIdpID, + LinkedUserId: "external_org", + }, + }, + wantErr: true, + }, + { + name: "remove link, no permission, org", + args: args{ + CTX, + &user.RemoveIDPLinkRequest{ + UserId: userOrgResp.GetUserId(), + IdpId: orgIdpID, + LinkedUserId: "external_org", + }, + }, + wantErr: true, + }, + { + name: "remove link, org, ok", + args: args{ + IamCTX, + &user.RemoveIDPLinkRequest{ + UserId: userOrgResp.GetUserId(), + IdpId: orgIdpID, + LinkedUserId: "external_org", + }, + }, + want: &user.RemoveIDPLinkResponse{ + Details: &object.Details{ + ResourceOwner: orgResp.GetOrganizationId(), + ChangeDate: timestamppb.Now(), + }, + }, + }, + { + name: "remove link, instance, ok", + args: args{ + IamCTX, + &user.RemoveIDPLinkRequest{ + UserId: userInstanceResp.GetUserId(), + IdpId: instanceIdpID, + LinkedUserId: "external_instance", + }, + }, + want: &user.RemoveIDPLinkResponse{ + Details: &object.Details{ + ResourceOwner: orgResp.GetOrganizationId(), + ChangeDate: timestamppb.Now(), + }, + }, + }, + { + name: "remove link, no link, error", + args: args{ + IamCTX, + &user.RemoveIDPLinkRequest{ + UserId: userNoLinkResp.GetUserId(), + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.RemoveIDPLink(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + integration.AssertDetails(t, tt.want, got) + }) + } +} diff --git a/internal/api/grpc/user/v2/otp.go b/internal/api/grpc/user/v2/otp.go index 0eae8c6bdd..e2fe6b794d 100644 --- a/internal/api/grpc/user/v2/otp.go +++ b/internal/api/grpc/user/v2/otp.go @@ -4,7 +4,7 @@ import ( "context" "github.com/zitadel/zitadel/internal/api/grpc/object/v2" - user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) func (s *Server) AddOTPSMS(ctx context.Context, req *user.AddOTPSMSRequest) (*user.AddOTPSMSResponse, error) { diff --git a/internal/api/grpc/user/v2/otp_integration_test.go b/internal/api/grpc/user/v2/otp_integration_test.go index 7f4c4a0f43..52b30fbd38 100644 --- a/internal/api/grpc/user/v2/otp_integration_test.go +++ b/internal/api/grpc/user/v2/otp_integration_test.go @@ -9,9 +9,10 @@ import ( "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/timestamppb" + "github.com/zitadel/zitadel/pkg/grpc/object/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" + "github.com/zitadel/zitadel/internal/integration" - object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" - user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" ) func TestServer_AddOTPSMS(t *testing.T) { diff --git a/internal/api/grpc/user/v2/passkey.go b/internal/api/grpc/user/v2/passkey.go index 58c89ae22e..bf539e252b 100644 --- a/internal/api/grpc/user/v2/passkey.go +++ b/internal/api/grpc/user/v2/passkey.go @@ -7,9 +7,10 @@ import ( "github.com/zitadel/zitadel/internal/api/grpc/object/v2" "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/zerrors" - object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" - user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" + object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) func (s *Server) RegisterPasskey(ctx context.Context, req *user.RegisterPasskeyRequest) (resp *user.RegisterPasskeyResponse, err error) { @@ -116,3 +117,68 @@ func passkeyCodeDetailsToPb(details *domain.PasskeyCodeDetails, err error) (*use }, }, nil } + +func (s *Server) RemovePasskey(ctx context.Context, req *user.RemovePasskeyRequest) (*user.RemovePasskeyResponse, error) { + objectDetails, err := s.command.HumanRemovePasswordless(ctx, req.GetUserId(), req.GetPasskeyId(), "") + if err != nil { + return nil, err + } + return &user.RemovePasskeyResponse{ + Details: object.DomainToDetailsPb(objectDetails), + }, nil +} + +func (s *Server) ListPasskeys(ctx context.Context, req *user.ListPasskeysRequest) (*user.ListPasskeysResponse, error) { + query := new(query.UserAuthMethodSearchQueries) + err := query.AppendUserIDQuery(req.UserId) + if err != nil { + return nil, err + } + err = query.AppendAuthMethodQuery(domain.UserAuthMethodTypePasswordless) + if err != nil { + return nil, err + } + err = query.AppendStateQuery(domain.MFAStateReady) + if err != nil { + return nil, err + } + authMethods, err := s.query.SearchUserAuthMethods(ctx, query, false) + authMethods.RemoveNoPermission(ctx, s.checkPermission) + if err != nil { + return nil, err + } + return &user.ListPasskeysResponse{ + Details: object.ToListDetails(authMethods.SearchResponse), + Result: authMethodsToPasskeyPb(authMethods), + }, nil +} + +func authMethodsToPasskeyPb(methods *query.AuthMethods) []*user.Passkey { + t := make([]*user.Passkey, len(methods.AuthMethods)) + for i, token := range methods.AuthMethods { + t[i] = authMethodToPasskeyPb(token) + } + return t +} + +func authMethodToPasskeyPb(token *query.AuthMethod) *user.Passkey { + return &user.Passkey{ + Id: token.TokenID, + State: mfaStateToPb(token.State), + Name: token.Name, + } +} + +func mfaStateToPb(state domain.MFAState) user.AuthFactorState { + switch state { + case domain.MFAStateNotReady: + return user.AuthFactorState_AUTH_FACTOR_STATE_NOT_READY + case domain.MFAStateReady: + return user.AuthFactorState_AUTH_FACTOR_STATE_READY + case domain.MFAStateUnspecified, domain.MFAStateRemoved: + // Handle all remaining cases so the linter succeeds + return user.AuthFactorState_AUTH_FACTOR_STATE_UNSPECIFIED + default: + return user.AuthFactorState_AUTH_FACTOR_STATE_UNSPECIFIED + } +} diff --git a/internal/api/grpc/user/v2/passkey_integration_test.go b/internal/api/grpc/user/v2/passkey_integration_test.go index 230a744a64..027005c438 100644 --- a/internal/api/grpc/user/v2/passkey_integration_test.go +++ b/internal/api/grpc/user/v2/passkey_integration_test.go @@ -5,6 +5,7 @@ package user_test import ( "context" "testing" + "time" "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" @@ -12,9 +13,10 @@ import ( "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" + "github.com/zitadel/zitadel/pkg/grpc/object/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" + "github.com/zitadel/zitadel/internal/integration" - object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" - user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" ) func TestServer_RegisterPasskey(t *testing.T) { @@ -138,19 +140,7 @@ func TestServer_RegisterPasskey(t *testing.T) { } func TestServer_VerifyPasskeyRegistration(t *testing.T) { - userID := Tester.CreateHumanUser(CTX).GetUserId() - reg, err := Client.CreatePasskeyRegistrationLink(CTX, &user.CreatePasskeyRegistrationLinkRequest{ - UserId: userID, - Medium: &user.CreatePasskeyRegistrationLinkRequest_ReturnCode{}, - }) - require.NoError(t, err) - pkr, err := Client.RegisterPasskey(CTX, &user.RegisterPasskeyRequest{ - UserId: userID, - Code: reg.GetCode(), - }) - require.NoError(t, err) - require.NotEmpty(t, pkr.GetPasskeyId()) - require.NotEmpty(t, pkr.GetPublicKeyCredentialCreationOptions()) + userID, pkr := userWithPasskeyRegistered(t) attestationResponse, err := Tester.WebAuthN.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions()) require.NoError(t, err) @@ -317,3 +307,291 @@ func TestServer_CreatePasskeyRegistrationLink(t *testing.T) { }) } } + +func userWithPasskeyRegistered(t *testing.T) (string, *user.RegisterPasskeyResponse) { + userID := Tester.CreateHumanUser(CTX).GetUserId() + return userID, passkeyRegister(t, userID) +} + +func userWithPasskeyVerified(t *testing.T) (string, string) { + userID, pkr := userWithPasskeyRegistered(t) + return userID, passkeyVerify(t, userID, pkr) +} + +func passkeyRegister(t *testing.T, userID string) *user.RegisterPasskeyResponse { + reg, err := Client.CreatePasskeyRegistrationLink(CTX, &user.CreatePasskeyRegistrationLinkRequest{ + UserId: userID, + Medium: &user.CreatePasskeyRegistrationLinkRequest_ReturnCode{}, + }) + require.NoError(t, err) + pkr, err := Client.RegisterPasskey(CTX, &user.RegisterPasskeyRequest{ + UserId: userID, + Code: reg.GetCode(), + }) + require.NoError(t, err) + require.NotEmpty(t, pkr.GetPasskeyId()) + require.NotEmpty(t, pkr.GetPublicKeyCredentialCreationOptions()) + return pkr +} + +func passkeyVerify(t *testing.T, userID string, pkr *user.RegisterPasskeyResponse) string { + attestationResponse, err := Tester.WebAuthN.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions()) + require.NoError(t, err) + + _, err = Client.VerifyPasskeyRegistration(CTX, &user.VerifyPasskeyRegistrationRequest{ + UserId: userID, + PasskeyId: pkr.GetPasskeyId(), + PublicKeyCredential: attestationResponse, + PasskeyName: "nice name", + }) + require.NoError(t, err) + return pkr.GetPasskeyId() +} + +func TestServer_RemovePasskey(t *testing.T) { + userIDWithout := Tester.CreateHumanUser(CTX).GetUserId() + userIDRegistered, pkrRegistered := userWithPasskeyRegistered(t) + userIDVerified, passkeyIDVerified := userWithPasskeyVerified(t) + userIDVerifiedPermission, passkeyIDVerifiedPermission := userWithPasskeyVerified(t) + + type args struct { + ctx context.Context + req *user.RemovePasskeyRequest + } + tests := []struct { + name string + args args + want *user.RemovePasskeyResponse + wantErr bool + }{ + { + name: "missing user id", + args: args{ + ctx: IamCTX, + req: &user.RemovePasskeyRequest{ + PasskeyId: "123", + }, + }, + wantErr: true, + }, + { + name: "missing passkey id", + args: args{ + ctx: IamCTX, + req: &user.RemovePasskeyRequest{ + UserId: "123", + }, + }, + wantErr: true, + }, + { + name: "success, registered", + args: args{ + ctx: IamCTX, + req: &user.RemovePasskeyRequest{ + UserId: userIDRegistered, + PasskeyId: pkrRegistered.GetPasskeyId(), + }, + }, + want: &user.RemovePasskeyResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "no passkey, error", + args: args{ + ctx: IamCTX, + req: &user.RemovePasskeyRequest{ + UserId: userIDWithout, + PasskeyId: pkrRegistered.GetPasskeyId(), + }, + }, + wantErr: true, + }, + { + name: "success, verified", + args: args{ + ctx: IamCTX, + req: &user.RemovePasskeyRequest{ + UserId: userIDVerified, + PasskeyId: passkeyIDVerified, + }, + }, + want: &user.RemovePasskeyResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "verified, permission error", + args: args{ + ctx: UserCTX, + req: &user.RemovePasskeyRequest{ + UserId: userIDVerifiedPermission, + PasskeyId: passkeyIDVerifiedPermission, + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.RemovePasskey(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.NotNil(t, got) + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_ListPasskeys(t *testing.T) { + userIDWithout := Tester.CreateHumanUser(CTX).GetUserId() + userIDRegistered, _ := userWithPasskeyRegistered(t) + userIDVerified, passkeyIDVerified := userWithPasskeyVerified(t) + + userIDMulti, passkeyIDMulti1 := userWithPasskeyVerified(t) + passkeyIDMulti2 := passkeyVerify(t, userIDMulti, passkeyRegister(t, userIDMulti)) + + type args struct { + ctx context.Context + req *user.ListPasskeysRequest + } + tests := []struct { + name string + args args + want *user.ListPasskeysResponse + wantErr bool + }{ + { + name: "list passkeys, no permission", + args: args{ + UserCTX, + &user.ListPasskeysRequest{ + UserId: userIDVerified, + }, + }, + want: &user.ListPasskeysResponse{ + Details: &object.ListDetails{ + TotalResult: 0, + Timestamp: timestamppb.Now(), + }, + Result: []*user.Passkey{}, + }, + }, + { + name: "list passkeys, none", + args: args{ + UserCTX, + &user.ListPasskeysRequest{ + UserId: userIDWithout, + }, + }, + want: &user.ListPasskeysResponse{ + Details: &object.ListDetails{ + TotalResult: 0, + Timestamp: timestamppb.Now(), + }, + Result: []*user.Passkey{}, + }, + }, + { + name: "list passkeys, registered", + args: args{ + UserCTX, + &user.ListPasskeysRequest{ + UserId: userIDRegistered, + }, + }, + want: &user.ListPasskeysResponse{ + Details: &object.ListDetails{ + TotalResult: 0, + Timestamp: timestamppb.Now(), + }, + Result: []*user.Passkey{}, + }, + }, + { + name: "list passkeys, ok", + args: args{ + IamCTX, + &user.ListPasskeysRequest{ + UserId: userIDVerified, + }, + }, + want: &user.ListPasskeysResponse{ + Details: &object.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + Result: []*user.Passkey{ + { + Id: passkeyIDVerified, + State: user.AuthFactorState_AUTH_FACTOR_STATE_READY, + Name: "nice name", + }, + }, + }, + }, + { + name: "list idp links, multi, ok", + args: args{ + IamCTX, + &user.ListPasskeysRequest{ + UserId: userIDMulti, + }, + }, + want: &user.ListPasskeysResponse{ + Details: &object.ListDetails{ + TotalResult: 2, + Timestamp: timestamppb.Now(), + }, + Result: []*user.Passkey{ + { + Id: passkeyIDMulti1, + State: user.AuthFactorState_AUTH_FACTOR_STATE_READY, + Name: "nice name", + }, + { + Id: passkeyIDMulti2, + State: user.AuthFactorState_AUTH_FACTOR_STATE_READY, + Name: "nice name", + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + retryDuration := time.Minute + if ctxDeadline, ok := CTX.Deadline(); ok { + retryDuration = time.Until(ctxDeadline) + } + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, listErr := Client.ListPasskeys(tt.args.ctx, tt.args.req) + assertErr := assert.NoError + if tt.wantErr { + assertErr = assert.Error + } + assertErr(ttt, listErr) + if listErr != nil { + return + } + // always first check length, otherwise its failed anyway + assert.Len(ttt, got.Result, len(tt.want.Result)) + for i := range tt.want.Result { + assert.Contains(ttt, got.Result, tt.want.Result[i]) + } + integration.AssertListDetails(t, tt.want, got) + }, retryDuration, time.Millisecond*100, "timeout waiting for expected idplinks result") + }) + } +} diff --git a/internal/api/grpc/user/v2/passkey_test.go b/internal/api/grpc/user/v2/passkey_test.go index 7d45c41756..7facfc74e0 100644 --- a/internal/api/grpc/user/v2/passkey_test.go +++ b/internal/api/grpc/user/v2/passkey_test.go @@ -14,8 +14,8 @@ import ( "github.com/zitadel/zitadel/internal/api/grpc" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/zerrors" - object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" - user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/object/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) func Test_passkeyAuthenticatorToDomain(t *testing.T) { diff --git a/internal/api/grpc/user/v2/password.go b/internal/api/grpc/user/v2/password.go index a7d6a55f2e..55cf225c4b 100644 --- a/internal/api/grpc/user/v2/password.go +++ b/internal/api/grpc/user/v2/password.go @@ -6,7 +6,7 @@ import ( "github.com/zitadel/zitadel/internal/api/grpc/object/v2" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/zerrors" - user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) func (s *Server) PasswordReset(ctx context.Context, req *user.PasswordResetRequest) (_ *user.PasswordResetResponse, err error) { diff --git a/internal/api/grpc/user/v2/password_integration_test.go b/internal/api/grpc/user/v2/password_integration_test.go index 03b18a5fa7..f97d0467d7 100644 --- a/internal/api/grpc/user/v2/password_integration_test.go +++ b/internal/api/grpc/user/v2/password_integration_test.go @@ -11,9 +11,10 @@ import ( "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/timestamppb" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2" + "github.com/zitadel/zitadel/internal/integration" - object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" - user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" ) func TestServer_RequestPasswordReset(t *testing.T) { diff --git a/internal/api/grpc/user/v2/password_test.go b/internal/api/grpc/user/v2/password_test.go index 5ce9930b39..f3c35b090b 100644 --- a/internal/api/grpc/user/v2/password_test.go +++ b/internal/api/grpc/user/v2/password_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/zitadel/zitadel/internal/domain" - user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) func Test_notificationTypeToDomain(t *testing.T) { diff --git a/internal/api/grpc/user/v2/phone.go b/internal/api/grpc/user/v2/phone.go index eac7eb4e31..fdd5a140c1 100644 --- a/internal/api/grpc/user/v2/phone.go +++ b/internal/api/grpc/user/v2/phone.go @@ -7,8 +7,8 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/zerrors" - object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" - user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/object/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) func (s *Server) SetPhone(ctx context.Context, req *user.SetPhoneRequest) (resp *user.SetPhoneResponse, err error) { diff --git a/internal/api/grpc/user/v2/phone_integration_test.go b/internal/api/grpc/user/v2/phone_integration_test.go index e2c670c6bd..d67b59d0b4 100644 --- a/internal/api/grpc/user/v2/phone_integration_test.go +++ b/internal/api/grpc/user/v2/phone_integration_test.go @@ -13,9 +13,10 @@ import ( "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/timestamppb" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2" + "github.com/zitadel/zitadel/internal/integration" - object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" - user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" ) func TestServer_SetPhone(t *testing.T) { diff --git a/internal/api/grpc/user/v2/query.go b/internal/api/grpc/user/v2/query.go index 2aaf0ad8ef..95262a66ae 100644 --- a/internal/api/grpc/user/v2/query.go +++ b/internal/api/grpc/user/v2/query.go @@ -11,7 +11,7 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/zerrors" - user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) func (s *Server) GetUserByID(ctx context.Context, req *user.GetUserByIDRequest) (_ *user.GetUserByIDResponse, err error) { diff --git a/internal/api/grpc/user/v2/query_integration_test.go b/internal/api/grpc/user/v2/query_integration_test.go index b4b770481b..f7fcf8fbe5 100644 --- a/internal/api/grpc/user/v2/query_integration_test.go +++ b/internal/api/grpc/user/v2/query_integration_test.go @@ -13,9 +13,10 @@ import ( "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/timestamppb" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2" + "github.com/zitadel/zitadel/internal/integration" - object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" - user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" ) func TestServer_GetUserByID(t *testing.T) { diff --git a/internal/api/grpc/user/v2/server.go b/internal/api/grpc/user/v2/server.go index 93af47f58b..9272ea27ee 100644 --- a/internal/api/grpc/user/v2/server.go +++ b/internal/api/grpc/user/v2/server.go @@ -11,7 +11,7 @@ import ( "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" - user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) var _ user.UserServiceServer = (*Server)(nil) diff --git a/internal/api/grpc/user/v2/totp.go b/internal/api/grpc/user/v2/totp.go index e426f5788d..9e2d028d72 100644 --- a/internal/api/grpc/user/v2/totp.go +++ b/internal/api/grpc/user/v2/totp.go @@ -5,7 +5,7 @@ import ( "github.com/zitadel/zitadel/internal/api/grpc/object/v2" "github.com/zitadel/zitadel/internal/domain" - user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) func (s *Server) RegisterTOTP(ctx context.Context, req *user.RegisterTOTPRequest) (*user.RegisterTOTPResponse, error) { diff --git a/internal/api/grpc/user/v2/totp_integration_test.go b/internal/api/grpc/user/v2/totp_integration_test.go index 489c189b03..474aed95b8 100644 --- a/internal/api/grpc/user/v2/totp_integration_test.go +++ b/internal/api/grpc/user/v2/totp_integration_test.go @@ -12,9 +12,10 @@ import ( "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/timestamppb" + "github.com/zitadel/zitadel/pkg/grpc/object/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" + "github.com/zitadel/zitadel/internal/integration" - object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" - user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" ) func TestServer_RegisterTOTP(t *testing.T) { diff --git a/internal/api/grpc/user/v2/totp_test.go b/internal/api/grpc/user/v2/totp_test.go index 81a54675f2..27ce6fb469 100644 --- a/internal/api/grpc/user/v2/totp_test.go +++ b/internal/api/grpc/user/v2/totp_test.go @@ -10,8 +10,8 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/domain" - object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" - user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/object/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) func Test_totpDetailsToPb(t *testing.T) { diff --git a/internal/api/grpc/user/v2/u2f.go b/internal/api/grpc/user/v2/u2f.go index f13d21736e..60c0f5ab07 100644 --- a/internal/api/grpc/user/v2/u2f.go +++ b/internal/api/grpc/user/v2/u2f.go @@ -6,7 +6,7 @@ import ( "github.com/zitadel/zitadel/internal/api/grpc/object/v2" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/zerrors" - user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) func (s *Server) RegisterU2F(ctx context.Context, req *user.RegisterU2FRequest) (*user.RegisterU2FResponse, error) { @@ -40,3 +40,13 @@ func (s *Server) VerifyU2FRegistration(ctx context.Context, req *user.VerifyU2FR Details: object.DomainToDetailsPb(objectDetails), }, nil } + +func (s *Server) RemoveU2F(ctx context.Context, req *user.RemoveU2FRequest) (*user.RemoveU2FResponse, error) { + objectDetails, err := s.command.HumanRemoveU2F(ctx, req.GetUserId(), req.GetU2FId(), "") + if err != nil { + return nil, err + } + return &user.RemoveU2FResponse{ + Details: object.DomainToDetailsPb(objectDetails), + }, nil +} diff --git a/internal/api/grpc/user/v2/u2f_integration_test.go b/internal/api/grpc/user/v2/u2f_integration_test.go index 3b7fbd293c..c4d4c33071 100644 --- a/internal/api/grpc/user/v2/u2f_integration_test.go +++ b/internal/api/grpc/user/v2/u2f_integration_test.go @@ -11,9 +11,10 @@ import ( "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" + "github.com/zitadel/zitadel/pkg/grpc/object/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" + "github.com/zitadel/zitadel/internal/integration" - object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" - user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" ) func TestServer_RegisterU2F(t *testing.T) { @@ -106,16 +107,7 @@ func TestServer_RegisterU2F(t *testing.T) { } func TestServer_VerifyU2FRegistration(t *testing.T) { - userID := Tester.CreateHumanUser(CTX).GetUserId() - Tester.RegisterUserPasskey(CTX, userID) - _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID) - ctx := Tester.WithAuthorizationToken(CTX, sessionToken) - - pkr, err := Client.RegisterU2F(ctx, &user.RegisterU2FRequest{ - UserId: userID, - }) - require.NoError(t, err) - require.NotEmpty(t, pkr.GetPublicKeyCredentialCreationOptions()) + ctx, userID, pkr := ctxFromNewUserWithRegisteredU2F(t) attestationResponse, err := Tester.WebAuthN.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions()) require.NoError(t, err) @@ -188,3 +180,138 @@ func TestServer_VerifyU2FRegistration(t *testing.T) { }) } } + +func ctxFromNewUserWithRegisteredU2F(t *testing.T) (context.Context, string, *user.RegisterU2FResponse) { + userID := Tester.CreateHumanUser(CTX).GetUserId() + Tester.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID) + ctx := Tester.WithAuthorizationToken(CTX, sessionToken) + + pkr, err := Client.RegisterU2F(ctx, &user.RegisterU2FRequest{ + UserId: userID, + }) + require.NoError(t, err) + require.NotEmpty(t, pkr.GetPublicKeyCredentialCreationOptions()) + return ctx, userID, pkr +} + +func ctxFromNewUserWithVerifiedU2F(t *testing.T) (context.Context, string, string) { + ctx, userID, pkr := ctxFromNewUserWithRegisteredU2F(t) + + attestationResponse, err := Tester.WebAuthN.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions()) + require.NoError(t, err) + + _, err = Client.VerifyU2FRegistration(ctx, &user.VerifyU2FRegistrationRequest{ + UserId: userID, + U2FId: pkr.GetU2FId(), + PublicKeyCredential: attestationResponse, + TokenName: "nice name", + }) + require.NoError(t, err) + return ctx, userID, pkr.GetU2FId() +} + +func TestServer_RemoveU2F(t *testing.T) { + userIDWithout := Tester.CreateHumanUser(CTX).GetUserId() + ctxRegistered, userIDRegistered, pkrRegistered := ctxFromNewUserWithRegisteredU2F(t) + _, userIDVerified, u2fVerified := ctxFromNewUserWithVerifiedU2F(t) + _, userIDVerifiedPermission, u2fVerifiedPermission := ctxFromNewUserWithVerifiedU2F(t) + + type args struct { + ctx context.Context + req *user.RemoveU2FRequest + } + tests := []struct { + name string + args args + want *user.RemoveU2FResponse + wantErr bool + }{ + { + name: "missing user id", + args: args{ + ctx: ctxRegistered, + req: &user.RemoveU2FRequest{ + U2FId: "123", + }, + }, + wantErr: true, + }, + { + name: "missing u2f id", + args: args{ + ctx: ctxRegistered, + req: &user.RemoveU2FRequest{ + UserId: "123", + }, + }, + wantErr: true, + }, + { + name: "success, registered", + args: args{ + ctx: ctxRegistered, + req: &user.RemoveU2FRequest{ + UserId: userIDRegistered, + U2FId: pkrRegistered.GetU2FId(), + }, + }, + want: &user.RemoveU2FResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "no u2f, error", + args: args{ + ctx: IamCTX, + req: &user.RemoveU2FRequest{ + UserId: userIDWithout, + U2FId: pkrRegistered.GetU2FId(), + }, + }, + wantErr: true, + }, + { + name: "success, IAMOwner permission, verified", + args: args{ + ctx: IamCTX, + req: &user.RemoveU2FRequest{ + UserId: userIDVerified, + U2FId: u2fVerified, + }, + }, + want: &user.RemoveU2FResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "verified, permission error", + args: args{ + ctx: UserCTX, + req: &user.RemoveU2FRequest{ + UserId: userIDVerifiedPermission, + U2FId: u2fVerifiedPermission, + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.RemoveU2F(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.NotNil(t, got) + integration.AssertDetails(t, tt.want, got) + }) + } +} diff --git a/internal/api/grpc/user/v2/u2f_test.go b/internal/api/grpc/user/v2/u2f_test.go index 087837ce3c..73366ab29b 100644 --- a/internal/api/grpc/user/v2/u2f_test.go +++ b/internal/api/grpc/user/v2/u2f_test.go @@ -13,8 +13,8 @@ import ( "github.com/zitadel/zitadel/internal/api/grpc" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/zerrors" - object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" - user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/object/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) func Test_u2fRegistrationDetailsToPb(t *testing.T) { diff --git a/internal/api/grpc/user/v2/user.go b/internal/api/grpc/user/v2/user.go index dd96f3107a..9ad83dfea3 100644 --- a/internal/api/grpc/user/v2/user.go +++ b/internal/api/grpc/user/v2/user.go @@ -18,11 +18,12 @@ import ( "github.com/zitadel/zitadel/internal/idp/providers/ldap" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/zerrors" - object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" - user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" + object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) func (s *Server) AddHumanUser(ctx context.Context, req *user.AddHumanUserRequest) (_ *user.AddHumanUserResponse, err error) { + human, err := AddUserRequestToAddHuman(req) if err != nil { return nil, err @@ -259,20 +260,6 @@ func SetHumanPasswordToPassword(password *user.SetPassword) *command.Password { } } -func (s *Server) AddIDPLink(ctx context.Context, req *user.AddIDPLinkRequest) (_ *user.AddIDPLinkResponse, err error) { - details, err := s.command.AddUserIDPLink(ctx, req.UserId, "", &command.AddLink{ - IDPID: req.GetIdpLink().GetIdpId(), - DisplayName: req.GetIdpLink().GetUserName(), - IDPExternalID: req.GetIdpLink().GetUserId(), - }) - if err != nil { - return nil, err - } - return &user.AddIDPLinkResponse{ - Details: object.DomainToDetailsPb(details), - }, nil -} - func (s *Server) DeleteUser(ctx context.Context, req *user.DeleteUserRequest) (_ *user.DeleteUserResponse, err error) { memberships, grants, err := s.removeUserDependencies(ctx, req.GetUserId()) if err != nil { diff --git a/internal/api/grpc/user/v2/user_integration_test.go b/internal/api/grpc/user/v2/user_integration_test.go index 824029724e..e762c10181 100644 --- a/internal/api/grpc/user/v2/user_integration_test.go +++ b/internal/api/grpc/user/v2/user_integration_test.go @@ -18,12 +18,13 @@ import ( "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" + "github.com/zitadel/zitadel/pkg/grpc/object/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" + "github.com/zitadel/zitadel/internal/api/grpc" "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/pkg/grpc/idp" mgmt "github.com/zitadel/zitadel/pkg/grpc/management" - object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" - user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" ) var ( @@ -1797,86 +1798,6 @@ func TestServer_DeleteUser(t *testing.T) { } } -func TestServer_AddIDPLink(t *testing.T) { - idpID := Tester.AddGenericOAuthProvider(t, CTX) - type args struct { - ctx context.Context - req *user.AddIDPLinkRequest - } - tests := []struct { - name string - args args - want *user.AddIDPLinkResponse - wantErr bool - }{ - { - name: "user does not exist", - args: args{ - CTX, - &user.AddIDPLinkRequest{ - UserId: "userID", - IdpLink: &user.IDPLink{ - IdpId: idpID, - UserId: "userID", - UserName: "username", - }, - }, - }, - want: nil, - wantErr: true, - }, - { - name: "idp does not exist", - args: args{ - CTX, - &user.AddIDPLinkRequest{ - UserId: Tester.Users[integration.FirstInstanceUsersKey][integration.OrgOwner].ID, - IdpLink: &user.IDPLink{ - IdpId: "idpID", - UserId: "userID", - UserName: "username", - }, - }, - }, - want: nil, - wantErr: true, - }, - { - name: "add link", - args: args{ - CTX, - &user.AddIDPLinkRequest{ - UserId: Tester.Users[integration.FirstInstanceUsersKey][integration.OrgOwner].ID, - IdpLink: &user.IDPLink{ - IdpId: idpID, - UserId: "userID", - UserName: "username", - }, - }, - }, - want: &user.AddIDPLinkResponse{ - Details: &object.Details{ - ChangeDate: timestamppb.Now(), - ResourceOwner: Tester.Organisation.ID, - }, - }, - wantErr: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := Client.AddIDPLink(tt.args.ctx, tt.args.req) - if tt.wantErr { - require.Error(t, err) - } else { - require.NoError(t, err) - } - - integration.AssertDetails(t, tt.want, got) - }) - } -} - func TestServer_StartIdentityProviderIntent(t *testing.T) { idpID := Tester.AddGenericOAuthProvider(t, CTX) orgIdpID := Tester.AddOrgGenericOAuthProvider(t, CTX, Tester.Organisation.ID) diff --git a/internal/api/grpc/user/v2/user_test.go b/internal/api/grpc/user/v2/user_test.go index 9e398e83ff..9e7a5a5ab0 100644 --- a/internal/api/grpc/user/v2/user_test.go +++ b/internal/api/grpc/user/v2/user_test.go @@ -17,8 +17,8 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/zerrors" - object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" - user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" + object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) func Test_idpIntentToIDPIntentPb(t *testing.T) { diff --git a/internal/api/grpc/user/v2beta/email.go b/internal/api/grpc/user/v2beta/email.go new file mode 100644 index 0000000000..38cc73c75c --- /dev/null +++ b/internal/api/grpc/user/v2beta/email.go @@ -0,0 +1,86 @@ +package user + +import ( + "context" + + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/zerrors" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" +) + +func (s *Server) SetEmail(ctx context.Context, req *user.SetEmailRequest) (resp *user.SetEmailResponse, err error) { + var email *domain.Email + + switch v := req.GetVerification().(type) { + case *user.SetEmailRequest_SendCode: + email, err = s.command.ChangeUserEmailURLTemplate(ctx, req.GetUserId(), req.GetEmail(), s.userCodeAlg, v.SendCode.GetUrlTemplate()) + case *user.SetEmailRequest_ReturnCode: + email, err = s.command.ChangeUserEmailReturnCode(ctx, req.GetUserId(), req.GetEmail(), s.userCodeAlg) + case *user.SetEmailRequest_IsVerified: + email, err = s.command.ChangeUserEmailVerified(ctx, req.GetUserId(), req.GetEmail()) + case nil: + email, err = s.command.ChangeUserEmail(ctx, req.GetUserId(), req.GetEmail(), s.userCodeAlg) + default: + err = zerrors.ThrowUnimplementedf(nil, "USERv2-Ahng0", "verification oneOf %T in method SetEmail not implemented", v) + } + if err != nil { + return nil, err + } + + return &user.SetEmailResponse{ + Details: &object.Details{ + Sequence: email.Sequence, + ChangeDate: timestamppb.New(email.ChangeDate), + ResourceOwner: email.ResourceOwner, + }, + VerificationCode: email.PlainCode, + }, nil +} + +func (s *Server) ResendEmailCode(ctx context.Context, req *user.ResendEmailCodeRequest) (resp *user.ResendEmailCodeResponse, err error) { + var email *domain.Email + + switch v := req.GetVerification().(type) { + case *user.ResendEmailCodeRequest_SendCode: + email, err = s.command.ResendUserEmailCodeURLTemplate(ctx, req.GetUserId(), s.userCodeAlg, v.SendCode.GetUrlTemplate()) + case *user.ResendEmailCodeRequest_ReturnCode: + email, err = s.command.ResendUserEmailReturnCode(ctx, req.GetUserId(), s.userCodeAlg) + case nil: + email, err = s.command.ResendUserEmailCode(ctx, req.GetUserId(), s.userCodeAlg) + default: + err = zerrors.ThrowUnimplementedf(nil, "USERv2-faj0l0nj5x", "verification oneOf %T in method ResendEmailCode not implemented", v) + } + if err != nil { + return nil, err + } + + return &user.ResendEmailCodeResponse{ + Details: &object.Details{ + Sequence: email.Sequence, + ChangeDate: timestamppb.New(email.ChangeDate), + ResourceOwner: email.ResourceOwner, + }, + VerificationCode: email.PlainCode, + }, nil +} + +func (s *Server) VerifyEmail(ctx context.Context, req *user.VerifyEmailRequest) (*user.VerifyEmailResponse, error) { + details, err := s.command.VerifyUserEmail(ctx, + req.GetUserId(), + req.GetVerificationCode(), + s.userCodeAlg, + ) + if err != nil { + return nil, err + } + return &user.VerifyEmailResponse{ + Details: &object.Details{ + Sequence: details.Sequence, + ChangeDate: timestamppb.New(details.EventDate), + ResourceOwner: details.ResourceOwner, + }, + }, nil +} diff --git a/internal/api/grpc/user/v2beta/email_integration_test.go b/internal/api/grpc/user/v2beta/email_integration_test.go new file mode 100644 index 0000000000..4034a5e7da --- /dev/null +++ b/internal/api/grpc/user/v2beta/email_integration_test.go @@ -0,0 +1,297 @@ +//go:build integration + +package user_test + +import ( + "fmt" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/integration" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" +) + +func TestServer_SetEmail(t *testing.T) { + userID := Tester.CreateHumanUser(CTX).GetUserId() + + tests := []struct { + name string + req *user.SetEmailRequest + want *user.SetEmailResponse + wantErr bool + }{ + { + name: "user not existing", + req: &user.SetEmailRequest{ + UserId: "xxx", + Email: "default-verifier@mouse.com", + }, + wantErr: true, + }, + { + 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_ResendEmailCode(t *testing.T) { + userID := Tester.CreateHumanUser(CTX).GetUserId() + verifiedUserID := Tester.CreateHumanUserVerified(CTX, Tester.Organisation.ID, fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())).GetUserId() + + tests := []struct { + name string + req *user.ResendEmailCodeRequest + want *user.ResendEmailCodeResponse + wantErr bool + }{ + { + name: "user not existing", + req: &user.ResendEmailCodeRequest{ + UserId: "xxx", + }, + wantErr: true, + }, + { + name: "user no code", + req: &user.ResendEmailCodeRequest{ + UserId: verifiedUserID, + }, + wantErr: true, + }, + { + name: "resend", + req: &user.ResendEmailCodeRequest{ + UserId: userID, + }, + want: &user.ResendEmailCodeResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "custom url template", + req: &user.ResendEmailCodeRequest{ + UserId: userID, + Verification: &user.ResendEmailCodeRequest_SendCode{ + SendCode: &user.SendEmailVerificationCode{ + UrlTemplate: gu.Ptr("https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}"), + }, + }, + }, + want: &user.ResendEmailCodeResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "template error", + req: &user.ResendEmailCodeRequest{ + UserId: userID, + Verification: &user.ResendEmailCodeRequest_SendCode{ + SendCode: &user.SendEmailVerificationCode{ + UrlTemplate: gu.Ptr("{{"), + }, + }, + }, + wantErr: true, + }, + { + name: "return code", + req: &user.ResendEmailCodeRequest{ + UserId: userID, + Verification: &user.ResendEmailCodeRequest_ReturnCode{ + ReturnCode: &user.ReturnEmailVerificationCode{}, + }, + }, + want: &user.ResendEmailCodeResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + VerificationCode: gu.Ptr("xxx"), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.ResendEmailCode(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 := Tester.CreateHumanUser(CTX) + tests := []struct { + name string + req *user.VerifyEmailRequest + want *user.VerifyEmailResponse + wantErr bool + }{ + { + name: "wrong code", + req: &user.VerifyEmailRequest{ + UserId: userResp.GetUserId(), + VerificationCode: "xxx", + }, + wantErr: true, + }, + { + name: "wrong user", + req: &user.VerifyEmailRequest{ + UserId: "xxx", + VerificationCode: userResp.GetEmailCode(), + }, + wantErr: true, + }, + { + name: "verify user", + req: &user.VerifyEmailRequest{ + UserId: userResp.GetUserId(), + VerificationCode: userResp.GetEmailCode(), + }, + want: &user.VerifyEmailResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.VerifyEmail(CTX, tt.req) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + integration.AssertDetails(t, tt.want, got) + }) + } +} diff --git a/internal/api/grpc/user/v2beta/otp.go b/internal/api/grpc/user/v2beta/otp.go new file mode 100644 index 0000000000..c11aa4c1a4 --- /dev/null +++ b/internal/api/grpc/user/v2beta/otp.go @@ -0,0 +1,42 @@ +package user + +import ( + "context" + + object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" +) + +func (s *Server) AddOTPSMS(ctx context.Context, req *user.AddOTPSMSRequest) (*user.AddOTPSMSResponse, error) { + details, err := s.command.AddHumanOTPSMS(ctx, req.GetUserId(), "") + if err != nil { + return nil, err + } + return &user.AddOTPSMSResponse{Details: object.DomainToDetailsPb(details)}, nil + +} + +func (s *Server) RemoveOTPSMS(ctx context.Context, req *user.RemoveOTPSMSRequest) (*user.RemoveOTPSMSResponse, error) { + objectDetails, err := s.command.RemoveHumanOTPSMS(ctx, req.GetUserId(), "") + if err != nil { + return nil, err + } + return &user.RemoveOTPSMSResponse{Details: object.DomainToDetailsPb(objectDetails)}, nil +} + +func (s *Server) AddOTPEmail(ctx context.Context, req *user.AddOTPEmailRequest) (*user.AddOTPEmailResponse, error) { + details, err := s.command.AddHumanOTPEmail(ctx, req.GetUserId(), "") + if err != nil { + return nil, err + } + return &user.AddOTPEmailResponse{Details: object.DomainToDetailsPb(details)}, nil + +} + +func (s *Server) RemoveOTPEmail(ctx context.Context, req *user.RemoveOTPEmailRequest) (*user.RemoveOTPEmailResponse, error) { + objectDetails, err := s.command.RemoveHumanOTPEmail(ctx, req.GetUserId(), "") + if err != nil { + return nil, err + } + return &user.RemoveOTPEmailResponse{Details: object.DomainToDetailsPb(objectDetails)}, nil +} diff --git a/internal/api/grpc/user/v2beta/otp_integration_test.go b/internal/api/grpc/user/v2beta/otp_integration_test.go new file mode 100644 index 0000000000..a6d671c645 --- /dev/null +++ b/internal/api/grpc/user/v2beta/otp_integration_test.go @@ -0,0 +1,362 @@ +//go:build integration + +package user_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/integration" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" +) + +func TestServer_AddOTPSMS(t *testing.T) { + userID := Tester.CreateHumanUser(CTX).GetUserId() + Tester.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID) + + otherUser := Tester.CreateHumanUser(CTX).GetUserId() + Tester.RegisterUserPasskey(CTX, otherUser) + _, sessionTokenOtherUser, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, otherUser) + + userVerified := Tester.CreateHumanUser(CTX) + _, err := Client.VerifyPhone(CTX, &user.VerifyPhoneRequest{ + UserId: userVerified.GetUserId(), + VerificationCode: userVerified.GetPhoneCode(), + }) + require.NoError(t, err) + Tester.RegisterUserPasskey(CTX, userVerified.GetUserId()) + _, sessionTokenVerified, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userVerified.GetUserId()) + + userVerified2 := Tester.CreateHumanUser(CTX) + _, err = Client.VerifyPhone(CTX, &user.VerifyPhoneRequest{ + UserId: userVerified2.GetUserId(), + VerificationCode: userVerified2.GetPhoneCode(), + }) + require.NoError(t, err) + + type args struct { + ctx context.Context + req *user.AddOTPSMSRequest + } + tests := []struct { + name string + args args + want *user.AddOTPSMSResponse + wantErr bool + }{ + { + name: "missing user id", + args: args{ + ctx: CTX, + req: &user.AddOTPSMSRequest{}, + }, + wantErr: true, + }, + { + name: "user mismatch", + args: args{ + ctx: Tester.WithAuthorizationToken(context.Background(), sessionTokenOtherUser), + req: &user.AddOTPSMSRequest{ + UserId: userID, + }, + }, + wantErr: true, + }, + { + name: "phone not verified", + args: args{ + ctx: Tester.WithAuthorizationToken(context.Background(), sessionToken), + req: &user.AddOTPSMSRequest{ + UserId: userID, + }, + }, + wantErr: true, + }, + { + name: "add success", + args: args{ + ctx: Tester.WithAuthorizationToken(context.Background(), sessionTokenVerified), + req: &user.AddOTPSMSRequest{ + UserId: userVerified.GetUserId(), + }, + }, + want: &user.AddOTPSMSResponse{ + Details: &object.Details{ + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "add success, admin", + args: args{ + ctx: CTX, + req: &user.AddOTPSMSRequest{ + UserId: userVerified2.GetUserId(), + }, + }, + want: &user.AddOTPSMSResponse{ + Details: &object.Details{ + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.AddOTPSMS(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.NotNil(t, got) + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_RemoveOTPSMS(t *testing.T) { + userID := Tester.CreateHumanUser(CTX).GetUserId() + Tester.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID) + + userVerified := Tester.CreateHumanUser(CTX) + Tester.RegisterUserPasskey(CTX, userVerified.GetUserId()) + _, sessionTokenVerified, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userVerified.GetUserId()) + userVerifiedCtx := Tester.WithAuthorizationToken(context.Background(), sessionTokenVerified) + _, err := Client.VerifyPhone(userVerifiedCtx, &user.VerifyPhoneRequest{ + UserId: userVerified.GetUserId(), + VerificationCode: userVerified.GetPhoneCode(), + }) + require.NoError(t, err) + _, err = Client.AddOTPSMS(userVerifiedCtx, &user.AddOTPSMSRequest{UserId: userVerified.GetUserId()}) + require.NoError(t, err) + + type args struct { + ctx context.Context + req *user.RemoveOTPSMSRequest + } + tests := []struct { + name string + args args + want *user.RemoveOTPSMSResponse + wantErr bool + }{ + { + name: "not added", + args: args{ + ctx: Tester.WithAuthorizationToken(context.Background(), sessionToken), + req: &user.RemoveOTPSMSRequest{ + UserId: userID, + }, + }, + wantErr: true, + }, + { + name: "success", + args: args{ + ctx: userVerifiedCtx, + req: &user.RemoveOTPSMSRequest{ + UserId: userVerified.GetUserId(), + }, + }, + want: &user.RemoveOTPSMSResponse{ + Details: &object.Details{ + ResourceOwner: Tester.Organisation.ResourceOwner, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.RemoveOTPSMS(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.NotNil(t, got) + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_AddOTPEmail(t *testing.T) { + userID := Tester.CreateHumanUser(CTX).GetUserId() + Tester.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID) + + otherUser := Tester.CreateHumanUser(CTX).GetUserId() + Tester.RegisterUserPasskey(CTX, otherUser) + _, sessionTokenOtherUser, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, otherUser) + + userVerified := Tester.CreateHumanUser(CTX) + _, err := Client.VerifyEmail(CTX, &user.VerifyEmailRequest{ + UserId: userVerified.GetUserId(), + VerificationCode: userVerified.GetEmailCode(), + }) + require.NoError(t, err) + Tester.RegisterUserPasskey(CTX, userVerified.GetUserId()) + _, sessionTokenVerified, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userVerified.GetUserId()) + + userVerified2 := Tester.CreateHumanUser(CTX) + _, err = Client.VerifyEmail(CTX, &user.VerifyEmailRequest{ + UserId: userVerified2.GetUserId(), + VerificationCode: userVerified2.GetEmailCode(), + }) + require.NoError(t, err) + + type args struct { + ctx context.Context + req *user.AddOTPEmailRequest + } + tests := []struct { + name string + args args + want *user.AddOTPEmailResponse + wantErr bool + }{ + { + name: "missing user id", + args: args{ + ctx: CTX, + req: &user.AddOTPEmailRequest{}, + }, + wantErr: true, + }, + { + name: "user mismatch", + args: args{ + ctx: Tester.WithAuthorizationToken(context.Background(), sessionTokenOtherUser), + req: &user.AddOTPEmailRequest{ + UserId: userID, + }, + }, + wantErr: true, + }, + { + name: "email not verified", + args: args{ + ctx: Tester.WithAuthorizationToken(context.Background(), sessionToken), + req: &user.AddOTPEmailRequest{ + UserId: userID, + }, + }, + wantErr: true, + }, + { + name: "add success", + args: args{ + ctx: Tester.WithAuthorizationToken(context.Background(), sessionTokenVerified), + req: &user.AddOTPEmailRequest{ + UserId: userVerified.GetUserId(), + }, + }, + want: &user.AddOTPEmailResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "add success, admin", + args: args{ + ctx: CTX, + req: &user.AddOTPEmailRequest{ + UserId: userVerified2.GetUserId(), + }, + }, + want: &user.AddOTPEmailResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.AddOTPEmail(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.NotNil(t, got) + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_RemoveOTPEmail(t *testing.T) { + userID := Tester.CreateHumanUser(CTX).GetUserId() + Tester.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID) + + userVerified := Tester.CreateHumanUser(CTX) + Tester.RegisterUserPasskey(CTX, userVerified.GetUserId()) + _, sessionTokenVerified, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userVerified.GetUserId()) + userVerifiedCtx := Tester.WithAuthorizationToken(context.Background(), sessionTokenVerified) + _, err := Client.VerifyEmail(userVerifiedCtx, &user.VerifyEmailRequest{ + UserId: userVerified.GetUserId(), + VerificationCode: userVerified.GetEmailCode(), + }) + require.NoError(t, err) + _, err = Client.AddOTPEmail(userVerifiedCtx, &user.AddOTPEmailRequest{UserId: userVerified.GetUserId()}) + require.NoError(t, err) + + type args struct { + ctx context.Context + req *user.RemoveOTPEmailRequest + } + tests := []struct { + name string + args args + want *user.RemoveOTPEmailResponse + wantErr bool + }{ + { + name: "not added", + args: args{ + ctx: Tester.WithAuthorizationToken(context.Background(), sessionToken), + req: &user.RemoveOTPEmailRequest{ + UserId: userID, + }, + }, + wantErr: true, + }, + { + name: "success", + args: args{ + ctx: userVerifiedCtx, + req: &user.RemoveOTPEmailRequest{ + UserId: userVerified.GetUserId(), + }, + }, + want: &user.RemoveOTPEmailResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ResourceOwner, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.RemoveOTPEmail(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.NotNil(t, got) + integration.AssertDetails(t, tt.want, got) + }) + } +} diff --git a/internal/api/grpc/user/v2beta/passkey.go b/internal/api/grpc/user/v2beta/passkey.go new file mode 100644 index 0000000000..2df267f3fd --- /dev/null +++ b/internal/api/grpc/user/v2beta/passkey.go @@ -0,0 +1,118 @@ +package user + +import ( + "context" + + "google.golang.org/protobuf/types/known/structpb" + + object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/zerrors" + object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" +) + +func (s *Server) RegisterPasskey(ctx context.Context, req *user.RegisterPasskeyRequest) (resp *user.RegisterPasskeyResponse, err error) { + var ( + authenticator = passkeyAuthenticatorToDomain(req.GetAuthenticator()) + ) + if code := req.GetCode(); code != nil { + return passkeyRegistrationDetailsToPb( + s.command.RegisterUserPasskeyWithCode(ctx, req.GetUserId(), "", authenticator, code.Id, code.Code, req.GetDomain(), s.userCodeAlg), + ) + } + return passkeyRegistrationDetailsToPb( + s.command.RegisterUserPasskey(ctx, req.GetUserId(), "", req.GetDomain(), authenticator), + ) +} + +func passkeyAuthenticatorToDomain(pa user.PasskeyAuthenticator) domain.AuthenticatorAttachment { + switch pa { + case user.PasskeyAuthenticator_PASSKEY_AUTHENTICATOR_UNSPECIFIED: + return domain.AuthenticatorAttachmentUnspecified + case user.PasskeyAuthenticator_PASSKEY_AUTHENTICATOR_PLATFORM: + return domain.AuthenticatorAttachmentPlattform + case user.PasskeyAuthenticator_PASSKEY_AUTHENTICATOR_CROSS_PLATFORM: + return domain.AuthenticatorAttachmentCrossPlattform + default: + return domain.AuthenticatorAttachmentUnspecified + } +} + +func webAuthNRegistrationDetailsToPb(details *domain.WebAuthNRegistrationDetails, err error) (*object_pb.Details, *structpb.Struct, error) { + if err != nil { + return nil, nil, err + } + options := new(structpb.Struct) + if err := options.UnmarshalJSON(details.PublicKeyCredentialCreationOptions); err != nil { + return nil, nil, zerrors.ThrowInternal(err, "USERv2-Dohr6", "Errors.Internal") + } + return object.DomainToDetailsPb(details.ObjectDetails), options, nil +} + +func passkeyRegistrationDetailsToPb(details *domain.WebAuthNRegistrationDetails, err error) (*user.RegisterPasskeyResponse, error) { + objectDetails, options, err := webAuthNRegistrationDetailsToPb(details, err) + if err != nil { + return nil, err + } + return &user.RegisterPasskeyResponse{ + Details: objectDetails, + PasskeyId: details.ID, + PublicKeyCredentialCreationOptions: options, + }, nil +} + +func (s *Server) VerifyPasskeyRegistration(ctx context.Context, req *user.VerifyPasskeyRegistrationRequest) (*user.VerifyPasskeyRegistrationResponse, error) { + pkc, err := req.GetPublicKeyCredential().MarshalJSON() + if err != nil { + return nil, zerrors.ThrowInternal(err, "USERv2-Pha2o", "Errors.Internal") + } + objectDetails, err := s.command.HumanHumanPasswordlessSetup(ctx, req.GetUserId(), "", req.GetPasskeyName(), "", pkc) + if err != nil { + return nil, err + } + return &user.VerifyPasskeyRegistrationResponse{ + Details: object.DomainToDetailsPb(objectDetails), + }, nil +} + +func (s *Server) CreatePasskeyRegistrationLink(ctx context.Context, req *user.CreatePasskeyRegistrationLinkRequest) (resp *user.CreatePasskeyRegistrationLinkResponse, err error) { + switch medium := req.Medium.(type) { + case nil: + return passkeyDetailsToPb( + s.command.AddUserPasskeyCode(ctx, req.GetUserId(), "", s.userCodeAlg), + ) + case *user.CreatePasskeyRegistrationLinkRequest_SendLink: + return passkeyDetailsToPb( + s.command.AddUserPasskeyCodeURLTemplate(ctx, req.GetUserId(), "", s.userCodeAlg, medium.SendLink.GetUrlTemplate()), + ) + case *user.CreatePasskeyRegistrationLinkRequest_ReturnCode: + return passkeyCodeDetailsToPb( + s.command.AddUserPasskeyCodeReturn(ctx, req.GetUserId(), "", s.userCodeAlg), + ) + default: + return nil, zerrors.ThrowUnimplementedf(nil, "USERv2-gaD8y", "verification oneOf %T in method CreatePasskeyRegistrationLink not implemented", medium) + } +} + +func passkeyDetailsToPb(details *domain.ObjectDetails, err error) (*user.CreatePasskeyRegistrationLinkResponse, error) { + if err != nil { + return nil, err + } + return &user.CreatePasskeyRegistrationLinkResponse{ + Details: object.DomainToDetailsPb(details), + }, nil +} + +func passkeyCodeDetailsToPb(details *domain.PasskeyCodeDetails, err error) (*user.CreatePasskeyRegistrationLinkResponse, error) { + if err != nil { + return nil, err + } + return &user.CreatePasskeyRegistrationLinkResponse{ + Details: object.DomainToDetailsPb(details.ObjectDetails), + Code: &user.PasskeyRegistrationCode{ + Id: details.CodeID, + Code: details.Code, + }, + }, nil +} diff --git a/internal/api/grpc/user/v2beta/passkey_integration_test.go b/internal/api/grpc/user/v2beta/passkey_integration_test.go new file mode 100644 index 0000000000..230a744a64 --- /dev/null +++ b/internal/api/grpc/user/v2beta/passkey_integration_test.go @@ -0,0 +1,319 @@ +//go:build integration + +package user_test + +import ( + "context" + "testing" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/integration" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" +) + +func TestServer_RegisterPasskey(t *testing.T) { + userID := Tester.CreateHumanUser(CTX).GetUserId() + reg, err := Client.CreatePasskeyRegistrationLink(CTX, &user.CreatePasskeyRegistrationLinkRequest{ + UserId: userID, + Medium: &user.CreatePasskeyRegistrationLinkRequest_ReturnCode{}, + }) + require.NoError(t, err) + + // We also need a user session + Tester.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID) + + type args struct { + ctx context.Context + req *user.RegisterPasskeyRequest + } + tests := []struct { + name string + args args + want *user.RegisterPasskeyResponse + wantErr bool + }{ + { + name: "missing user id", + args: args{ + ctx: CTX, + req: &user.RegisterPasskeyRequest{}, + }, + wantErr: true, + }, + { + name: "register code", + args: args{ + ctx: CTX, + req: &user.RegisterPasskeyRequest{ + UserId: userID, + Code: reg.GetCode(), + Authenticator: user.PasskeyAuthenticator_PASSKEY_AUTHENTICATOR_PLATFORM, + }, + }, + want: &user.RegisterPasskeyResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "reuse code (not allowed)", + args: args{ + ctx: CTX, + req: &user.RegisterPasskeyRequest{ + UserId: userID, + Code: reg.GetCode(), + Authenticator: user.PasskeyAuthenticator_PASSKEY_AUTHENTICATOR_PLATFORM, + }, + }, + wantErr: true, + }, + { + name: "wrong code", + args: args{ + ctx: CTX, + req: &user.RegisterPasskeyRequest{ + UserId: userID, + Code: &user.PasskeyRegistrationCode{ + Id: reg.GetCode().GetId(), + Code: "foobar", + }, + Authenticator: user.PasskeyAuthenticator_PASSKEY_AUTHENTICATOR_CROSS_PLATFORM, + }, + }, + wantErr: true, + }, + { + name: "user mismatch", + args: args{ + ctx: CTX, + req: &user.RegisterPasskeyRequest{ + UserId: userID, + }, + }, + wantErr: true, + }, + { + name: "user setting its own passkey", + args: args{ + ctx: Tester.WithAuthorizationToken(CTX, sessionToken), + req: &user.RegisterPasskeyRequest{ + UserId: userID, + }, + }, + want: &user.RegisterPasskeyResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.RegisterPasskey(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.NotNil(t, got) + integration.AssertDetails(t, tt.want, got) + if tt.want != nil { + assert.NotEmpty(t, got.GetPasskeyId()) + assert.NotEmpty(t, got.GetPublicKeyCredentialCreationOptions()) + _, err = Tester.WebAuthN.CreateAttestationResponse(got.GetPublicKeyCredentialCreationOptions()) + require.NoError(t, err) + } + }) + } +} + +func TestServer_VerifyPasskeyRegistration(t *testing.T) { + userID := Tester.CreateHumanUser(CTX).GetUserId() + reg, err := Client.CreatePasskeyRegistrationLink(CTX, &user.CreatePasskeyRegistrationLinkRequest{ + UserId: userID, + Medium: &user.CreatePasskeyRegistrationLinkRequest_ReturnCode{}, + }) + require.NoError(t, err) + pkr, err := Client.RegisterPasskey(CTX, &user.RegisterPasskeyRequest{ + UserId: userID, + Code: reg.GetCode(), + }) + require.NoError(t, err) + require.NotEmpty(t, pkr.GetPasskeyId()) + require.NotEmpty(t, pkr.GetPublicKeyCredentialCreationOptions()) + + attestationResponse, err := Tester.WebAuthN.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions()) + require.NoError(t, err) + + type args struct { + ctx context.Context + req *user.VerifyPasskeyRegistrationRequest + } + tests := []struct { + name string + args args + want *user.VerifyPasskeyRegistrationResponse + wantErr bool + }{ + { + name: "missing user id", + args: args{ + ctx: CTX, + req: &user.VerifyPasskeyRegistrationRequest{ + PasskeyId: pkr.GetPasskeyId(), + PublicKeyCredential: attestationResponse, + PasskeyName: "nice name", + }, + }, + wantErr: true, + }, + { + name: "success", + args: args{ + ctx: CTX, + req: &user.VerifyPasskeyRegistrationRequest{ + UserId: userID, + PasskeyId: pkr.GetPasskeyId(), + PublicKeyCredential: attestationResponse, + PasskeyName: "nice name", + }, + }, + want: &user.VerifyPasskeyRegistrationResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "wrong credential", + args: args{ + ctx: CTX, + req: &user.VerifyPasskeyRegistrationRequest{ + UserId: userID, + PasskeyId: pkr.GetPasskeyId(), + PublicKeyCredential: &structpb.Struct{ + Fields: map[string]*structpb.Value{"foo": {Kind: &structpb.Value_StringValue{StringValue: "bar"}}}, + }, + PasskeyName: "nice name", + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.VerifyPasskeyRegistration(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.NotNil(t, got) + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_CreatePasskeyRegistrationLink(t *testing.T) { + userID := Tester.CreateHumanUser(CTX).GetUserId() + + type args struct { + ctx context.Context + req *user.CreatePasskeyRegistrationLinkRequest + } + tests := []struct { + name string + args args + want *user.CreatePasskeyRegistrationLinkResponse + wantCode bool + wantErr bool + }{ + { + name: "missing user id", + args: args{ + ctx: CTX, + req: &user.CreatePasskeyRegistrationLinkRequest{}, + }, + wantErr: true, + }, + { + name: "send default mail", + args: args{ + ctx: CTX, + req: &user.CreatePasskeyRegistrationLinkRequest{ + UserId: userID, + }, + }, + want: &user.CreatePasskeyRegistrationLinkResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "send custom url", + args: args{ + ctx: CTX, + req: &user.CreatePasskeyRegistrationLinkRequest{ + UserId: userID, + Medium: &user.CreatePasskeyRegistrationLinkRequest_SendLink{ + SendLink: &user.SendPasskeyRegistrationLink{ + UrlTemplate: gu.Ptr("https://example.com/passkey/register?userID={{.UserID}}&orgID={{.OrgID}}&codeID={{.CodeID}}&code={{.Code}}"), + }, + }, + }, + }, + want: &user.CreatePasskeyRegistrationLinkResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "return code", + args: args{ + ctx: CTX, + req: &user.CreatePasskeyRegistrationLinkRequest{ + UserId: userID, + Medium: &user.CreatePasskeyRegistrationLinkRequest_ReturnCode{}, + }, + }, + want: &user.CreatePasskeyRegistrationLinkResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + wantCode: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.CreatePasskeyRegistrationLink(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.NotNil(t, got) + integration.AssertDetails(t, tt.want, got) + if tt.wantCode { + assert.NotEmpty(t, got.GetCode().GetId()) + assert.NotEmpty(t, got.GetCode().GetId()) + } + }) + } +} diff --git a/internal/api/grpc/user/v2beta/passkey_test.go b/internal/api/grpc/user/v2beta/passkey_test.go new file mode 100644 index 0000000000..7d45c41756 --- /dev/null +++ b/internal/api/grpc/user/v2beta/passkey_test.go @@ -0,0 +1,235 @@ +package user + +import ( + "io" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/grpc" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/zerrors" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" +) + +func Test_passkeyAuthenticatorToDomain(t *testing.T) { + tests := []struct { + pa user.PasskeyAuthenticator + want domain.AuthenticatorAttachment + }{ + { + pa: user.PasskeyAuthenticator_PASSKEY_AUTHENTICATOR_UNSPECIFIED, + want: domain.AuthenticatorAttachmentUnspecified, + }, + { + pa: user.PasskeyAuthenticator_PASSKEY_AUTHENTICATOR_PLATFORM, + want: domain.AuthenticatorAttachmentPlattform, + }, + { + pa: user.PasskeyAuthenticator_PASSKEY_AUTHENTICATOR_CROSS_PLATFORM, + want: domain.AuthenticatorAttachmentCrossPlattform, + }, + { + pa: 999, + want: domain.AuthenticatorAttachmentUnspecified, + }, + } + for _, tt := range tests { + t.Run(tt.pa.String(), func(t *testing.T) { + got := passkeyAuthenticatorToDomain(tt.pa) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_passkeyRegistrationDetailsToPb(t *testing.T) { + type args struct { + details *domain.WebAuthNRegistrationDetails + err error + } + tests := []struct { + name string + args args + want *user.RegisterPasskeyResponse + wantErr error + }{ + { + name: "an error", + args: args{ + details: nil, + err: io.ErrClosedPipe, + }, + wantErr: io.ErrClosedPipe, + }, + { + name: "unmarshall error", + args: args{ + details: &domain.WebAuthNRegistrationDetails{ + ObjectDetails: &domain.ObjectDetails{ + Sequence: 22, + EventDate: time.Unix(3000, 22), + ResourceOwner: "me", + }, + ID: "123", + PublicKeyCredentialCreationOptions: []byte(`\\`), + }, + err: nil, + }, + wantErr: zerrors.ThrowInternal(nil, "USERv2-Dohr6", "Errors.Internal"), + }, + { + name: "ok", + args: args{ + details: &domain.WebAuthNRegistrationDetails{ + ObjectDetails: &domain.ObjectDetails{ + Sequence: 22, + EventDate: time.Unix(3000, 22), + ResourceOwner: "me", + }, + ID: "123", + PublicKeyCredentialCreationOptions: []byte(`{"foo": "bar"}`), + }, + err: nil, + }, + want: &user.RegisterPasskeyResponse{ + Details: &object.Details{ + Sequence: 22, + ChangeDate: ×tamppb.Timestamp{ + Seconds: 3000, + Nanos: 22, + }, + ResourceOwner: "me", + }, + PasskeyId: "123", + PublicKeyCredentialCreationOptions: &structpb.Struct{ + Fields: map[string]*structpb.Value{"foo": {Kind: &structpb.Value_StringValue{StringValue: "bar"}}}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := passkeyRegistrationDetailsToPb(tt.args.details, tt.args.err) + require.ErrorIs(t, err, tt.wantErr) + if !proto.Equal(tt.want, got) { + t.Errorf("Not equal:\nExpected\n%s\nActual:%s", tt.want, got) + } + if tt.want != nil { + grpc.AllFieldsSet(t, got.ProtoReflect()) + } + }) + } +} + +func Test_passkeyDetailsToPb(t *testing.T) { + type args struct { + details *domain.ObjectDetails + err error + } + tests := []struct { + name string + args args + want *user.CreatePasskeyRegistrationLinkResponse + }{ + { + name: "an error", + args: args{ + details: nil, + err: io.ErrClosedPipe, + }, + }, + { + name: "ok", + args: args{ + details: &domain.ObjectDetails{ + Sequence: 22, + EventDate: time.Unix(3000, 22), + ResourceOwner: "me", + }, + err: nil, + }, + want: &user.CreatePasskeyRegistrationLinkResponse{ + Details: &object.Details{ + Sequence: 22, + ChangeDate: ×tamppb.Timestamp{ + Seconds: 3000, + Nanos: 22, + }, + ResourceOwner: "me", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := passkeyDetailsToPb(tt.args.details, tt.args.err) + require.ErrorIs(t, err, tt.args.err) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_passkeyCodeDetailsToPb(t *testing.T) { + type args struct { + details *domain.PasskeyCodeDetails + err error + } + tests := []struct { + name string + args args + want *user.CreatePasskeyRegistrationLinkResponse + }{ + { + name: "an error", + args: args{ + details: nil, + err: io.ErrClosedPipe, + }, + }, + { + name: "ok", + args: args{ + details: &domain.PasskeyCodeDetails{ + ObjectDetails: &domain.ObjectDetails{ + Sequence: 22, + EventDate: time.Unix(3000, 22), + ResourceOwner: "me", + }, + CodeID: "123", + Code: "456", + }, + err: nil, + }, + want: &user.CreatePasskeyRegistrationLinkResponse{ + Details: &object.Details{ + Sequence: 22, + ChangeDate: ×tamppb.Timestamp{ + Seconds: 3000, + Nanos: 22, + }, + ResourceOwner: "me", + }, + Code: &user.PasskeyRegistrationCode{ + Id: "123", + Code: "456", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := passkeyCodeDetailsToPb(tt.args.details, tt.args.err) + require.ErrorIs(t, err, tt.args.err) + assert.Equal(t, tt.want, got) + if tt.want != nil { + grpc.AllFieldsSet(t, got.ProtoReflect()) + } + }) + } +} diff --git a/internal/api/grpc/user/v2beta/password.go b/internal/api/grpc/user/v2beta/password.go new file mode 100644 index 0000000000..0de1262215 --- /dev/null +++ b/internal/api/grpc/user/v2beta/password.go @@ -0,0 +1,69 @@ +package user + +import ( + "context" + + object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/zerrors" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" +) + +func (s *Server) PasswordReset(ctx context.Context, req *user.PasswordResetRequest) (_ *user.PasswordResetResponse, err error) { + var details *domain.ObjectDetails + var code *string + + switch m := req.GetMedium().(type) { + case *user.PasswordResetRequest_SendLink: + details, code, err = s.command.RequestPasswordResetURLTemplate(ctx, req.GetUserId(), m.SendLink.GetUrlTemplate(), notificationTypeToDomain(m.SendLink.GetNotificationType())) + case *user.PasswordResetRequest_ReturnCode: + details, code, err = s.command.RequestPasswordResetReturnCode(ctx, req.GetUserId()) + case nil: + details, code, err = s.command.RequestPasswordReset(ctx, req.GetUserId()) + default: + err = zerrors.ThrowUnimplementedf(nil, "USERv2-SDeeg", "verification oneOf %T in method RequestPasswordReset not implemented", m) + } + if err != nil { + return nil, err + } + + return &user.PasswordResetResponse{ + Details: object.DomainToDetailsPb(details), + VerificationCode: code, + }, nil +} + +func notificationTypeToDomain(notificationType user.NotificationType) domain.NotificationType { + switch notificationType { + case user.NotificationType_NOTIFICATION_TYPE_Email: + return domain.NotificationTypeEmail + case user.NotificationType_NOTIFICATION_TYPE_SMS: + return domain.NotificationTypeSms + case user.NotificationType_NOTIFICATION_TYPE_Unspecified: + return domain.NotificationTypeEmail + default: + return domain.NotificationTypeEmail + } +} + +func (s *Server) SetPassword(ctx context.Context, req *user.SetPasswordRequest) (_ *user.SetPasswordResponse, err error) { + var details *domain.ObjectDetails + + switch v := req.GetVerification().(type) { + case *user.SetPasswordRequest_CurrentPassword: + details, err = s.command.ChangePassword(ctx, "", req.GetUserId(), v.CurrentPassword, req.GetNewPassword().GetPassword(), "", req.GetNewPassword().GetChangeRequired()) + case *user.SetPasswordRequest_VerificationCode: + details, err = s.command.SetPasswordWithVerifyCode(ctx, "", req.GetUserId(), v.VerificationCode, req.GetNewPassword().GetPassword(), "", req.GetNewPassword().GetChangeRequired()) + case nil: + details, err = s.command.SetPassword(ctx, "", req.GetUserId(), req.GetNewPassword().GetPassword(), req.GetNewPassword().GetChangeRequired()) + default: + err = zerrors.ThrowUnimplementedf(nil, "USERv2-SFdf2", "verification oneOf %T in method SetPasswordRequest not implemented", v) + } + if err != nil { + return nil, err + } + + return &user.SetPasswordResponse{ + Details: object.DomainToDetailsPb(details), + }, nil +} diff --git a/internal/api/grpc/user/v2beta/password_integration_test.go b/internal/api/grpc/user/v2beta/password_integration_test.go new file mode 100644 index 0000000000..03b18a5fa7 --- /dev/null +++ b/internal/api/grpc/user/v2beta/password_integration_test.go @@ -0,0 +1,232 @@ +//go:build integration + +package user_test + +import ( + "context" + "testing" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/integration" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" +) + +func TestServer_RequestPasswordReset(t *testing.T) { + userID := Tester.CreateHumanUser(CTX).GetUserId() + + tests := []struct { + name string + req *user.PasswordResetRequest + want *user.PasswordResetResponse + wantErr bool + }{ + { + name: "default medium", + req: &user.PasswordResetRequest{ + UserId: userID, + }, + want: &user.PasswordResetResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "custom url template", + req: &user.PasswordResetRequest{ + UserId: userID, + Medium: &user.PasswordResetRequest_SendLink{ + SendLink: &user.SendPasswordResetLink{ + NotificationType: user.NotificationType_NOTIFICATION_TYPE_Email, + UrlTemplate: gu.Ptr("https://example.com/password/change?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}"), + }, + }, + }, + want: &user.PasswordResetResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "template error", + req: &user.PasswordResetRequest{ + UserId: userID, + Medium: &user.PasswordResetRequest_SendLink{ + SendLink: &user.SendPasswordResetLink{ + UrlTemplate: gu.Ptr("{{"), + }, + }, + }, + wantErr: true, + }, + { + name: "return code", + req: &user.PasswordResetRequest{ + UserId: userID, + Medium: &user.PasswordResetRequest_ReturnCode{ + ReturnCode: &user.ReturnPasswordResetCode{}, + }, + }, + want: &user.PasswordResetResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + VerificationCode: gu.Ptr("xxx"), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.PasswordReset(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_SetPassword(t *testing.T) { + type args struct { + ctx context.Context + req *user.SetPasswordRequest + } + tests := []struct { + name string + prepare func(request *user.SetPasswordRequest) error + args args + want *user.SetPasswordResponse + wantErr bool + }{ + { + name: "missing user id", + prepare: func(request *user.SetPasswordRequest) error { + return nil + }, + args: args{ + ctx: CTX, + req: &user.SetPasswordRequest{}, + }, + wantErr: true, + }, + { + name: "set successful", + prepare: func(request *user.SetPasswordRequest) error { + userID := Tester.CreateHumanUser(CTX).GetUserId() + request.UserId = userID + return nil + }, + args: args{ + ctx: CTX, + req: &user.SetPasswordRequest{ + NewPassword: &user.Password{ + Password: "Secr3tP4ssw0rd!", + }, + }, + }, + want: &user.SetPasswordResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "change successful", + prepare: func(request *user.SetPasswordRequest) error { + userID := Tester.CreateHumanUser(CTX).GetUserId() + request.UserId = userID + _, err := Client.SetPassword(CTX, &user.SetPasswordRequest{ + UserId: userID, + NewPassword: &user.Password{ + Password: "InitialPassw0rd!", + }, + }) + return err + }, + args: args{ + ctx: CTX, + req: &user.SetPasswordRequest{ + NewPassword: &user.Password{ + Password: "Secr3tP4ssw0rd!", + }, + Verification: &user.SetPasswordRequest_CurrentPassword{ + CurrentPassword: "InitialPassw0rd!", + }, + }, + }, + want: &user.SetPasswordResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "set with code successful", + prepare: func(request *user.SetPasswordRequest) error { + userID := Tester.CreateHumanUser(CTX).GetUserId() + request.UserId = userID + resp, err := Client.PasswordReset(CTX, &user.PasswordResetRequest{ + UserId: userID, + Medium: &user.PasswordResetRequest_ReturnCode{ + ReturnCode: &user.ReturnPasswordResetCode{}, + }, + }) + if err != nil { + return err + } + request.Verification = &user.SetPasswordRequest_VerificationCode{ + VerificationCode: resp.GetVerificationCode(), + } + return nil + }, + args: args{ + ctx: CTX, + req: &user.SetPasswordRequest{ + NewPassword: &user.Password{ + Password: "Secr3tP4ssw0rd!", + }, + }, + }, + want: &user.SetPasswordResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.prepare(tt.args.req) + require.NoError(t, err) + + got, err := Client.SetPassword(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.NotNil(t, got) + integration.AssertDetails(t, tt.want, got) + }) + } +} diff --git a/internal/api/grpc/user/v2beta/password_test.go b/internal/api/grpc/user/v2beta/password_test.go new file mode 100644 index 0000000000..5ce9930b39 --- /dev/null +++ b/internal/api/grpc/user/v2beta/password_test.go @@ -0,0 +1,39 @@ +package user + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/zitadel/zitadel/internal/domain" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" +) + +func Test_notificationTypeToDomain(t *testing.T) { + tests := []struct { + name string + notificationType user.NotificationType + want domain.NotificationType + }{ + { + "unspecified", + user.NotificationType_NOTIFICATION_TYPE_Unspecified, + domain.NotificationTypeEmail, + }, + { + "email", + user.NotificationType_NOTIFICATION_TYPE_Email, + domain.NotificationTypeEmail, + }, + { + "sms", + user.NotificationType_NOTIFICATION_TYPE_SMS, + domain.NotificationTypeSms, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equalf(t, tt.want, notificationTypeToDomain(tt.notificationType), "notificationTypeToDomain(%v)", tt.notificationType) + }) + } +} diff --git a/internal/api/grpc/user/v2beta/phone.go b/internal/api/grpc/user/v2beta/phone.go new file mode 100644 index 0000000000..eac7eb4e31 --- /dev/null +++ b/internal/api/grpc/user/v2beta/phone.go @@ -0,0 +1,102 @@ +package user + +import ( + "context" + + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/zerrors" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" +) + +func (s *Server) SetPhone(ctx context.Context, req *user.SetPhoneRequest) (resp *user.SetPhoneResponse, err error) { + var phone *domain.Phone + + switch v := req.GetVerification().(type) { + case *user.SetPhoneRequest_SendCode: + phone, err = s.command.ChangeUserPhone(ctx, req.GetUserId(), req.GetPhone(), s.userCodeAlg) + case *user.SetPhoneRequest_ReturnCode: + phone, err = s.command.ChangeUserPhoneReturnCode(ctx, req.GetUserId(), req.GetPhone(), s.userCodeAlg) + case *user.SetPhoneRequest_IsVerified: + phone, err = s.command.ChangeUserPhoneVerified(ctx, req.GetUserId(), req.GetPhone()) + case nil: + phone, err = s.command.ChangeUserPhone(ctx, req.GetUserId(), req.GetPhone(), s.userCodeAlg) + default: + err = zerrors.ThrowUnimplementedf(nil, "USERv2-Ahng0", "verification oneOf %T in method SetPhone not implemented", v) + } + if err != nil { + return nil, err + } + + return &user.SetPhoneResponse{ + Details: &object.Details{ + Sequence: phone.Sequence, + ChangeDate: timestamppb.New(phone.ChangeDate), + ResourceOwner: phone.ResourceOwner, + }, + VerificationCode: phone.PlainCode, + }, nil +} + +func (s *Server) RemovePhone(ctx context.Context, req *user.RemovePhoneRequest) (resp *user.RemovePhoneResponse, err error) { + details, err := s.command.RemoveUserPhone(ctx, + req.GetUserId(), + ) + if err != nil { + return nil, err + } + + return &user.RemovePhoneResponse{ + Details: &object.Details{ + Sequence: details.Sequence, + ChangeDate: timestamppb.New(details.EventDate), + ResourceOwner: details.ResourceOwner, + }, + }, nil +} + +func (s *Server) ResendPhoneCode(ctx context.Context, req *user.ResendPhoneCodeRequest) (resp *user.ResendPhoneCodeResponse, err error) { + var phone *domain.Phone + switch v := req.GetVerification().(type) { + case *user.ResendPhoneCodeRequest_SendCode: + phone, err = s.command.ResendUserPhoneCode(ctx, req.GetUserId(), s.userCodeAlg) + case *user.ResendPhoneCodeRequest_ReturnCode: + phone, err = s.command.ResendUserPhoneCodeReturnCode(ctx, req.GetUserId(), s.userCodeAlg) + case nil: + phone, err = s.command.ResendUserPhoneCode(ctx, req.GetUserId(), s.userCodeAlg) + default: + err = zerrors.ThrowUnimplementedf(nil, "USERv2-ResendUserPhoneCode", "verification oneOf %T in method SetPhone not implemented", v) + } + if err != nil { + return nil, err + } + + return &user.ResendPhoneCodeResponse{ + Details: &object.Details{ + Sequence: phone.Sequence, + ChangeDate: timestamppb.New(phone.ChangeDate), + ResourceOwner: phone.ResourceOwner, + }, + VerificationCode: phone.PlainCode, + }, nil +} + +func (s *Server) VerifyPhone(ctx context.Context, req *user.VerifyPhoneRequest) (*user.VerifyPhoneResponse, error) { + details, err := s.command.VerifyUserPhone(ctx, + req.GetUserId(), + req.GetVerificationCode(), + s.userCodeAlg, + ) + if err != nil { + return nil, err + } + return &user.VerifyPhoneResponse{ + Details: &object.Details{ + Sequence: details.Sequence, + ChangeDate: timestamppb.New(details.EventDate), + ResourceOwner: details.ResourceOwner, + }, + }, nil +} diff --git a/internal/api/grpc/user/v2beta/phone_integration_test.go b/internal/api/grpc/user/v2beta/phone_integration_test.go new file mode 100644 index 0000000000..692f7af5f7 --- /dev/null +++ b/internal/api/grpc/user/v2beta/phone_integration_test.go @@ -0,0 +1,344 @@ +//go:build integration + +package user_test + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/integration" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" +) + +func TestServer_SetPhone(t *testing.T) { + userID := Tester.CreateHumanUser(CTX).GetUserId() + + tests := []struct { + name string + req *user.SetPhoneRequest + want *user.SetPhoneResponse + wantErr bool + }{ + { + name: "default verification", + req: &user.SetPhoneRequest{ + UserId: userID, + Phone: "+41791234568", + }, + want: &user.SetPhoneResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "send verification", + req: &user.SetPhoneRequest{ + UserId: userID, + Phone: "+41791234569", + Verification: &user.SetPhoneRequest_SendCode{ + SendCode: &user.SendPhoneVerificationCode{}, + }, + }, + want: &user.SetPhoneResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "return code", + req: &user.SetPhoneRequest{ + UserId: userID, + Phone: "+41791234566", + Verification: &user.SetPhoneRequest_ReturnCode{ + ReturnCode: &user.ReturnPhoneVerificationCode{}, + }, + }, + want: &user.SetPhoneResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + VerificationCode: gu.Ptr("xxx"), + }, + }, + { + name: "is verified true", + req: &user.SetPhoneRequest{ + UserId: userID, + Phone: "+41791234565", + Verification: &user.SetPhoneRequest_IsVerified{ + IsVerified: true, + }, + }, + want: &user.SetPhoneResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "is verified false", + req: &user.SetPhoneRequest{ + UserId: userID, + Phone: "+41791234564", + Verification: &user.SetPhoneRequest_IsVerified{ + IsVerified: false, + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.SetPhone(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_ResendPhoneCode(t *testing.T) { + userID := Tester.CreateHumanUser(CTX).GetUserId() + verifiedUserID := Tester.CreateHumanUserVerified(CTX, Tester.Organisation.ID, fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())).GetUserId() + + tests := []struct { + name string + req *user.ResendPhoneCodeRequest + want *user.ResendPhoneCodeResponse + wantErr bool + }{ + { + name: "user not existing", + req: &user.ResendPhoneCodeRequest{ + UserId: "xxx", + }, + wantErr: true, + }, + { + name: "user not existing", + req: &user.ResendPhoneCodeRequest{ + UserId: verifiedUserID, + }, + wantErr: true, + }, + { + name: "resend code", + req: &user.ResendPhoneCodeRequest{ + UserId: userID, + Verification: &user.ResendPhoneCodeRequest_SendCode{ + SendCode: &user.SendPhoneVerificationCode{}, + }, + }, + want: &user.ResendPhoneCodeResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "return code", + req: &user.ResendPhoneCodeRequest{ + UserId: userID, + Verification: &user.ResendPhoneCodeRequest_ReturnCode{ + ReturnCode: &user.ReturnPhoneVerificationCode{}, + }, + }, + want: &user.ResendPhoneCodeResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + VerificationCode: gu.Ptr("xxx"), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.ResendPhoneCode(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_VerifyPhone(t *testing.T) { + userResp := Tester.CreateHumanUser(CTX) + tests := []struct { + name string + req *user.VerifyPhoneRequest + want *user.VerifyPhoneResponse + wantErr bool + }{ + { + name: "wrong code", + req: &user.VerifyPhoneRequest{ + UserId: userResp.GetUserId(), + VerificationCode: "xxx", + }, + wantErr: true, + }, + { + name: "wrong user", + req: &user.VerifyPhoneRequest{ + UserId: "xxx", + VerificationCode: userResp.GetPhoneCode(), + }, + wantErr: true, + }, + { + name: "verify user", + req: &user.VerifyPhoneRequest{ + UserId: userResp.GetUserId(), + VerificationCode: userResp.GetPhoneCode(), + }, + want: &user.VerifyPhoneResponse{ + 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.VerifyPhone(CTX, tt.req) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_RemovePhone(t *testing.T) { + userResp := Tester.CreateHumanUser(CTX) + failResp := Tester.CreateHumanUserNoPhone(CTX) + otherUser := Tester.CreateHumanUser(CTX).GetUserId() + doubleRemoveUser := Tester.CreateHumanUser(CTX) + + Tester.RegisterUserPasskey(CTX, otherUser) + _, sessionTokenOtherUser, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, otherUser) + + tests := []struct { + name string + ctx context.Context + req *user.RemovePhoneRequest + want *user.RemovePhoneResponse + wantErr bool + dep func(ctx context.Context, userID string) (*user.RemovePhoneResponse, error) + }{ + { + name: "remove phone", + ctx: CTX, + req: &user.RemovePhoneRequest{ + UserId: userResp.GetUserId(), + }, + want: &user.RemovePhoneResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + dep: func(ctx context.Context, userID string) (*user.RemovePhoneResponse, error) { + return nil, nil + }, + }, + { + name: "user without phone", + ctx: CTX, + req: &user.RemovePhoneRequest{ + UserId: failResp.GetUserId(), + }, + wantErr: true, + dep: func(ctx context.Context, userID string) (*user.RemovePhoneResponse, error) { + return nil, nil + }, + }, + { + name: "remove previously deleted phone", + ctx: CTX, + req: &user.RemovePhoneRequest{ + UserId: doubleRemoveUser.GetUserId(), + }, + wantErr: true, + dep: func(ctx context.Context, userID string) (*user.RemovePhoneResponse, error) { + return Client.RemovePhone(ctx, &user.RemovePhoneRequest{ + UserId: doubleRemoveUser.GetUserId(), + }) + }, + }, + { + name: "no user id", + ctx: CTX, + req: &user.RemovePhoneRequest{}, + wantErr: true, + dep: func(ctx context.Context, userID string) (*user.RemovePhoneResponse, error) { + return nil, nil + }, + }, + { + name: "other user, no permission", + ctx: Tester.WithAuthorizationToken(CTX, sessionTokenOtherUser), + req: &user.RemovePhoneRequest{ + UserId: userResp.GetUserId(), + }, + wantErr: true, + dep: func(ctx context.Context, userID string) (*user.RemovePhoneResponse, error) { + return nil, nil + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, depErr := tt.dep(tt.ctx, tt.req.UserId) + require.NoError(t, depErr) + + got, err := Client.RemovePhone(tt.ctx, tt.req) + + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + integration.AssertDetails(t, tt.want, got) + }) + } +} diff --git a/internal/api/grpc/user/v2beta/query.go b/internal/api/grpc/user/v2beta/query.go new file mode 100644 index 0000000000..0eaeba5ca1 --- /dev/null +++ b/internal/api/grpc/user/v2beta/query.go @@ -0,0 +1,338 @@ +package user + +import ( + "context" + + "github.com/muhlemmer/gu" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/authz" + object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" +) + +func (s *Server) GetUserByID(ctx context.Context, req *user.GetUserByIDRequest) (_ *user.GetUserByIDResponse, err error) { + resp, err := s.query.GetUserByID(ctx, true, req.GetUserId()) + if err != nil { + return nil, err + } + if authz.GetCtxData(ctx).UserID != req.GetUserId() { + if err := s.checkPermission(ctx, domain.PermissionUserRead, resp.ResourceOwner, req.GetUserId()); err != nil { + return nil, err + } + } + return &user.GetUserByIDResponse{ + Details: object.DomainToDetailsPb(&domain.ObjectDetails{ + Sequence: resp.Sequence, + EventDate: resp.ChangeDate, + ResourceOwner: resp.ResourceOwner, + }), + User: userToPb(resp, s.assetAPIPrefix(ctx)), + }, nil +} + +func (s *Server) ListUsers(ctx context.Context, req *user.ListUsersRequest) (*user.ListUsersResponse, error) { + queries, err := listUsersRequestToModel(req) + if err != nil { + return nil, err + } + res, err := s.query.SearchUsers(ctx, queries) + if err != nil { + return nil, err + } + res.RemoveNoPermission(ctx, s.checkPermission) + return &user.ListUsersResponse{ + Result: UsersToPb(res.Users, s.assetAPIPrefix(ctx)), + Details: object.ToListDetails(res.SearchResponse), + }, nil +} + +func UsersToPb(users []*query.User, assetPrefix string) []*user.User { + u := make([]*user.User, len(users)) + for i, user := range users { + u[i] = userToPb(user, assetPrefix) + } + return u +} + +func userToPb(userQ *query.User, assetPrefix string) *user.User { + return &user.User{ + UserId: userQ.ID, + Details: object.DomainToDetailsPb(&domain.ObjectDetails{ + Sequence: userQ.Sequence, + EventDate: userQ.ChangeDate, + ResourceOwner: userQ.ResourceOwner, + }), + State: userStateToPb(userQ.State), + Username: userQ.Username, + LoginNames: userQ.LoginNames, + PreferredLoginName: userQ.PreferredLoginName, + Type: userTypeToPb(userQ, assetPrefix), + } +} + +func userTypeToPb(userQ *query.User, assetPrefix string) user.UserType { + if userQ.Human != nil { + return &user.User_Human{ + Human: humanToPb(userQ.Human, assetPrefix, userQ.ResourceOwner), + } + } + if userQ.Machine != nil { + return &user.User_Machine{ + Machine: machineToPb(userQ.Machine), + } + } + return nil +} + +func humanToPb(userQ *query.Human, assetPrefix, owner string) *user.HumanUser { + var passwordChanged *timestamppb.Timestamp + if !userQ.PasswordChanged.IsZero() { + passwordChanged = timestamppb.New(userQ.PasswordChanged) + } + return &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: userQ.FirstName, + FamilyName: userQ.LastName, + NickName: gu.Ptr(userQ.NickName), + DisplayName: gu.Ptr(userQ.DisplayName), + PreferredLanguage: gu.Ptr(userQ.PreferredLanguage.String()), + Gender: gu.Ptr(genderToPb(userQ.Gender)), + AvatarUrl: domain.AvatarURL(assetPrefix, owner, userQ.AvatarKey), + }, + Email: &user.HumanEmail{ + Email: string(userQ.Email), + IsVerified: userQ.IsEmailVerified, + }, + Phone: &user.HumanPhone{ + Phone: string(userQ.Phone), + IsVerified: userQ.IsPhoneVerified, + }, + PasswordChangeRequired: userQ.PasswordChangeRequired, + PasswordChanged: passwordChanged, + } +} + +func machineToPb(userQ *query.Machine) *user.MachineUser { + return &user.MachineUser{ + Name: userQ.Name, + Description: userQ.Description, + HasSecret: userQ.EncodedSecret != "", + AccessTokenType: accessTokenTypeToPb(userQ.AccessTokenType), + } +} + +func userStateToPb(state domain.UserState) user.UserState { + switch state { + case domain.UserStateActive: + return user.UserState_USER_STATE_ACTIVE + case domain.UserStateInactive: + return user.UserState_USER_STATE_INACTIVE + case domain.UserStateDeleted: + return user.UserState_USER_STATE_DELETED + case domain.UserStateInitial: + return user.UserState_USER_STATE_INITIAL + case domain.UserStateLocked: + return user.UserState_USER_STATE_LOCKED + case domain.UserStateUnspecified: + return user.UserState_USER_STATE_UNSPECIFIED + case domain.UserStateSuspend: + return user.UserState_USER_STATE_UNSPECIFIED + default: + return user.UserState_USER_STATE_UNSPECIFIED + } +} + +func genderToPb(gender domain.Gender) user.Gender { + switch gender { + case domain.GenderDiverse: + return user.Gender_GENDER_DIVERSE + case domain.GenderFemale: + return user.Gender_GENDER_FEMALE + case domain.GenderMale: + return user.Gender_GENDER_MALE + case domain.GenderUnspecified: + return user.Gender_GENDER_UNSPECIFIED + default: + return user.Gender_GENDER_UNSPECIFIED + } +} + +func accessTokenTypeToPb(accessTokenType domain.OIDCTokenType) user.AccessTokenType { + switch accessTokenType { + case domain.OIDCTokenTypeBearer: + return user.AccessTokenType_ACCESS_TOKEN_TYPE_BEARER + case domain.OIDCTokenTypeJWT: + return user.AccessTokenType_ACCESS_TOKEN_TYPE_JWT + default: + return user.AccessTokenType_ACCESS_TOKEN_TYPE_BEARER + } +} + +func listUsersRequestToModel(req *user.ListUsersRequest) (*query.UserSearchQueries, error) { + offset, limit, asc := object.ListQueryToQuery(req.Query) + queries, err := userQueriesToQuery(req.Queries, 0 /*start from level 0*/) + if err != nil { + return nil, err + } + return &query.UserSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + SortingColumn: userFieldNameToSortingColumn(req.SortingColumn), + }, + Queries: queries, + }, nil +} + +func userFieldNameToSortingColumn(field user.UserFieldName) query.Column { + switch field { + case user.UserFieldName_USER_FIELD_NAME_EMAIL: + return query.HumanEmailCol + case user.UserFieldName_USER_FIELD_NAME_FIRST_NAME: + return query.HumanFirstNameCol + case user.UserFieldName_USER_FIELD_NAME_LAST_NAME: + return query.HumanLastNameCol + case user.UserFieldName_USER_FIELD_NAME_DISPLAY_NAME: + return query.HumanDisplayNameCol + case user.UserFieldName_USER_FIELD_NAME_USER_NAME: + return query.UserUsernameCol + case user.UserFieldName_USER_FIELD_NAME_STATE: + return query.UserStateCol + case user.UserFieldName_USER_FIELD_NAME_TYPE: + return query.UserTypeCol + case user.UserFieldName_USER_FIELD_NAME_NICK_NAME: + return query.HumanNickNameCol + case user.UserFieldName_USER_FIELD_NAME_CREATION_DATE: + return query.UserCreationDateCol + case user.UserFieldName_USER_FIELD_NAME_UNSPECIFIED: + return query.UserIDCol + default: + return query.UserIDCol + } +} + +func userQueriesToQuery(queries []*user.SearchQuery, level uint8) (_ []query.SearchQuery, err error) { + q := make([]query.SearchQuery, len(queries)) + for i, query := range queries { + q[i], err = userQueryToQuery(query, level) + if err != nil { + return nil, err + } + } + return q, nil +} + +func userQueryToQuery(query *user.SearchQuery, level uint8) (query.SearchQuery, error) { + if level > 20 { + // can't go deeper than 20 levels of nesting. + return nil, zerrors.ThrowInvalidArgument(nil, "USER-zsQ97", "Errors.Query.TooManyNestingLevels") + } + switch q := query.Query.(type) { + case *user.SearchQuery_UserNameQuery: + return userNameQueryToQuery(q.UserNameQuery) + case *user.SearchQuery_FirstNameQuery: + return firstNameQueryToQuery(q.FirstNameQuery) + case *user.SearchQuery_LastNameQuery: + return lastNameQueryToQuery(q.LastNameQuery) + case *user.SearchQuery_NickNameQuery: + return nickNameQueryToQuery(q.NickNameQuery) + case *user.SearchQuery_DisplayNameQuery: + return displayNameQueryToQuery(q.DisplayNameQuery) + case *user.SearchQuery_EmailQuery: + return emailQueryToQuery(q.EmailQuery) + case *user.SearchQuery_StateQuery: + return stateQueryToQuery(q.StateQuery) + case *user.SearchQuery_TypeQuery: + return typeQueryToQuery(q.TypeQuery) + case *user.SearchQuery_LoginNameQuery: + return loginNameQueryToQuery(q.LoginNameQuery) + case *user.SearchQuery_OrganizationIdQuery: + return resourceOwnerQueryToQuery(q.OrganizationIdQuery) + case *user.SearchQuery_InUserIdsQuery: + return inUserIdsQueryToQuery(q.InUserIdsQuery) + case *user.SearchQuery_OrQuery: + return orQueryToQuery(q.OrQuery, level) + case *user.SearchQuery_AndQuery: + return andQueryToQuery(q.AndQuery, level) + case *user.SearchQuery_NotQuery: + return notQueryToQuery(q.NotQuery, level) + case *user.SearchQuery_InUserEmailsQuery: + return inUserEmailsQueryToQuery(q.InUserEmailsQuery) + default: + return nil, zerrors.ThrowInvalidArgument(nil, "GRPC-vR9nC", "List.Query.Invalid") + } +} + +func userNameQueryToQuery(q *user.UserNameQuery) (query.SearchQuery, error) { + return query.NewUserUsernameSearchQuery(q.UserName, object.TextMethodToQuery(q.Method)) +} + +func firstNameQueryToQuery(q *user.FirstNameQuery) (query.SearchQuery, error) { + return query.NewUserFirstNameSearchQuery(q.FirstName, object.TextMethodToQuery(q.Method)) +} + +func lastNameQueryToQuery(q *user.LastNameQuery) (query.SearchQuery, error) { + return query.NewUserLastNameSearchQuery(q.LastName, object.TextMethodToQuery(q.Method)) +} + +func nickNameQueryToQuery(q *user.NickNameQuery) (query.SearchQuery, error) { + return query.NewUserNickNameSearchQuery(q.NickName, object.TextMethodToQuery(q.Method)) +} + +func displayNameQueryToQuery(q *user.DisplayNameQuery) (query.SearchQuery, error) { + return query.NewUserDisplayNameSearchQuery(q.DisplayName, object.TextMethodToQuery(q.Method)) +} + +func emailQueryToQuery(q *user.EmailQuery) (query.SearchQuery, error) { + return query.NewUserEmailSearchQuery(q.EmailAddress, object.TextMethodToQuery(q.Method)) +} + +func stateQueryToQuery(q *user.StateQuery) (query.SearchQuery, error) { + return query.NewUserStateSearchQuery(int32(q.State)) +} + +func typeQueryToQuery(q *user.TypeQuery) (query.SearchQuery, error) { + return query.NewUserTypeSearchQuery(int32(q.Type)) +} + +func loginNameQueryToQuery(q *user.LoginNameQuery) (query.SearchQuery, error) { + return query.NewUserLoginNameExistsQuery(q.LoginName, object.TextMethodToQuery(q.Method)) +} + +func resourceOwnerQueryToQuery(q *user.OrganizationIdQuery) (query.SearchQuery, error) { + return query.NewUserResourceOwnerSearchQuery(q.OrganizationId, query.TextEquals) +} + +func inUserIdsQueryToQuery(q *user.InUserIDQuery) (query.SearchQuery, error) { + return query.NewUserInUserIdsSearchQuery(q.UserIds) +} +func orQueryToQuery(q *user.OrQuery, level uint8) (query.SearchQuery, error) { + mappedQueries, err := userQueriesToQuery(q.Queries, level+1) + if err != nil { + return nil, err + } + return query.NewUserOrSearchQuery(mappedQueries) +} +func andQueryToQuery(q *user.AndQuery, level uint8) (query.SearchQuery, error) { + mappedQueries, err := userQueriesToQuery(q.Queries, level+1) + if err != nil { + return nil, err + } + return query.NewUserAndSearchQuery(mappedQueries) +} +func notQueryToQuery(q *user.NotQuery, level uint8) (query.SearchQuery, error) { + mappedQuery, err := userQueryToQuery(q.Query, level+1) + if err != nil { + return nil, err + } + return query.NewUserNotSearchQuery(mappedQuery) +} + +func inUserEmailsQueryToQuery(q *user.InUserEmailsQuery) (query.SearchQuery, error) { + return query.NewUserInUserEmailsSearchQuery(q.UserEmails) +} diff --git a/internal/api/grpc/user/v2beta/query_integration_test.go b/internal/api/grpc/user/v2beta/query_integration_test.go new file mode 100644 index 0000000000..124e47bb27 --- /dev/null +++ b/internal/api/grpc/user/v2beta/query_integration_test.go @@ -0,0 +1,982 @@ +//go:build integration + +package user_test + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/object/v2" + object_v2beta "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" +) + +func detailsV2ToV2beta(obj *object.Details) *object_v2beta.Details { + return &object_v2beta.Details{ + Sequence: obj.GetSequence(), + ChangeDate: obj.GetChangeDate(), + ResourceOwner: obj.GetResourceOwner(), + } +} + +func TestServer_GetUserByID(t *testing.T) { + orgResp := Tester.CreateOrganization(IamCTX, fmt.Sprintf("GetUserByIDOrg%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())) + type args struct { + ctx context.Context + req *user.GetUserByIDRequest + dep func(ctx context.Context, username string, request *user.GetUserByIDRequest) (*userAttr, error) + } + tests := []struct { + name string + args args + want *user.GetUserByIDResponse + wantErr bool + }{ + { + name: "user by ID, no id provided", + args: args{ + IamCTX, + &user.GetUserByIDRequest{ + UserId: "", + }, + func(ctx context.Context, username string, request *user.GetUserByIDRequest) (*userAttr, error) { + return nil, nil + }, + }, + wantErr: true, + }, + { + name: "user by ID, not found", + args: args{ + IamCTX, + &user.GetUserByIDRequest{ + UserId: "unknown", + }, + func(ctx context.Context, username string, request *user.GetUserByIDRequest) (*userAttr, error) { + return nil, nil + }, + }, + wantErr: true, + }, + { + name: "user by ID, ok", + args: args{ + IamCTX, + &user.GetUserByIDRequest{}, + func(ctx context.Context, username string, request *user.GetUserByIDRequest) (*userAttr, error) { + resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) + request.UserId = resp.GetUserId() + return &userAttr{resp.GetUserId(), username, nil, resp.GetDetails()}, nil + }, + }, + want: &user.GetUserByIDResponse{ + User: &user.User{ + State: user.UserState_USER_STATE_ACTIVE, + Username: "", + LoginNames: nil, + PreferredLoginName: "", + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + AvatarUrl: "", + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ + Phone: "+41791234567", + IsVerified: true, + }, + }, + }, + }, + Details: &object_v2beta.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: orgResp.OrganizationId, + }, + }, + }, + { + name: "user by ID, passwordChangeRequired, ok", + args: args{ + IamCTX, + &user.GetUserByIDRequest{}, + func(ctx context.Context, username string, request *user.GetUserByIDRequest) (*userAttr, error) { + resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) + request.UserId = resp.GetUserId() + details := Tester.SetUserPassword(ctx, resp.GetUserId(), integration.UserPassword, true) + return &userAttr{resp.GetUserId(), username, details.GetChangeDate(), resp.GetDetails()}, nil + }, + }, + want: &user.GetUserByIDResponse{ + User: &user.User{ + State: user.UserState_USER_STATE_ACTIVE, + Username: "", + LoginNames: nil, + PreferredLoginName: "", + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + AvatarUrl: "", + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ + Phone: "+41791234567", + IsVerified: true, + }, + PasswordChangeRequired: true, + PasswordChanged: timestamppb.Now(), + }, + }, + }, + Details: &object_v2beta.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: orgResp.OrganizationId, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + username := fmt.Sprintf("%d@mouse.com", time.Now().UnixNano()) + userAttr, err := tt.args.dep(tt.args.ctx, username, tt.args.req) + require.NoError(t, err) + retryDuration := time.Minute + if ctxDeadline, ok := CTX.Deadline(); ok { + retryDuration = time.Until(ctxDeadline) + } + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, getErr := Client.GetUserByID(tt.args.ctx, tt.args.req) + assertErr := assert.NoError + if tt.wantErr { + assertErr = assert.Error + } + assertErr(ttt, getErr) + if getErr != nil { + return + } + tt.want.User.Details = detailsV2ToV2beta(userAttr.Details) + tt.want.User.UserId = userAttr.UserID + tt.want.User.Username = userAttr.Username + tt.want.User.PreferredLoginName = userAttr.Username + tt.want.User.LoginNames = []string{userAttr.Username} + if human := tt.want.User.GetHuman(); human != nil { + human.Email.Email = userAttr.Username + if tt.want.User.GetHuman().GetPasswordChanged() != nil { + human.PasswordChanged = userAttr.Changed + } + } + assert.Equal(ttt, tt.want.User, got.User) + integration.AssertDetails(t, tt.want, got) + }, retryDuration, time.Second) + }) + } +} + +func TestServer_GetUserByID_Permission(t *testing.T) { + timeNow := time.Now().UTC() + newOrgOwnerEmail := fmt.Sprintf("%d@permission.get.com", timeNow.UnixNano()) + newOrg := Tester.CreateOrganization(IamCTX, fmt.Sprintf("GetHuman%d", time.Now().UnixNano()), newOrgOwnerEmail) + newUserID := newOrg.CreatedAdmins[0].GetUserId() + type args struct { + ctx context.Context + req *user.GetUserByIDRequest + } + tests := []struct { + name string + args args + want *user.GetUserByIDResponse + wantErr bool + }{ + { + name: "System, ok", + args: args{ + SystemCTX, + &user.GetUserByIDRequest{ + UserId: newUserID, + }, + }, + want: &user.GetUserByIDResponse{ + User: &user.User{ + State: user.UserState_USER_STATE_ACTIVE, + Username: "", + LoginNames: nil, + PreferredLoginName: "", + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "firstname", + FamilyName: "lastname", + NickName: gu.Ptr(""), + DisplayName: gu.Ptr("firstname lastname"), + PreferredLanguage: gu.Ptr("und"), + Gender: user.Gender_GENDER_UNSPECIFIED.Enum(), + AvatarUrl: "", + }, + Email: &user.HumanEmail{ + Email: newOrgOwnerEmail, + }, + Phone: &user.HumanPhone{}, + }, + }, + }, + Details: &object_v2beta.Details{ + ChangeDate: timestamppb.New(timeNow), + ResourceOwner: newOrg.GetOrganizationId(), + }, + }, + }, + { + name: "Instance, ok", + args: args{ + IamCTX, + &user.GetUserByIDRequest{ + UserId: newUserID, + }, + }, + want: &user.GetUserByIDResponse{ + User: &user.User{ + State: user.UserState_USER_STATE_ACTIVE, + Username: "", + LoginNames: nil, + PreferredLoginName: "", + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "firstname", + FamilyName: "lastname", + NickName: gu.Ptr(""), + DisplayName: gu.Ptr("firstname lastname"), + PreferredLanguage: gu.Ptr("und"), + Gender: user.Gender_GENDER_UNSPECIFIED.Enum(), + AvatarUrl: "", + }, + Email: &user.HumanEmail{ + Email: newOrgOwnerEmail, + }, + Phone: &user.HumanPhone{}, + }, + }, + }, + Details: &object_v2beta.Details{ + ChangeDate: timestamppb.New(timeNow), + ResourceOwner: newOrg.GetOrganizationId(), + }, + }, + }, + { + name: "Org, error", + args: args{ + CTX, + &user.GetUserByIDRequest{ + UserId: newUserID, + }, + }, + wantErr: true, + }, + { + name: "User, error", + args: args{ + UserCTX, + &user.GetUserByIDRequest{ + UserId: newUserID, + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.GetUserByID(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + tt.want.User.UserId = tt.args.req.GetUserId() + tt.want.User.Username = newOrgOwnerEmail + tt.want.User.PreferredLoginName = newOrgOwnerEmail + tt.want.User.LoginNames = []string{newOrgOwnerEmail} + if human := tt.want.User.GetHuman(); human != nil { + human.Email.Email = newOrgOwnerEmail + } + // details tested in GetUserByID + tt.want.User.Details = got.User.GetDetails() + + assert.Equal(t, tt.want.User, got.User) + } + }) + } +} + +type userAttr struct { + UserID string + Username string + Changed *timestamppb.Timestamp + Details *object.Details +} + +func TestServer_ListUsers(t *testing.T) { + orgResp := Tester.CreateOrganization(IamCTX, fmt.Sprintf("ListUsersOrg%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())) + userResp := Tester.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, fmt.Sprintf("%d@listusers.com", time.Now().UnixNano())) + type args struct { + ctx context.Context + count int + req *user.ListUsersRequest + dep func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) + } + tests := []struct { + name string + args args + want *user.ListUsersResponse + wantErr bool + }{ + { + name: "list user by id, no permission", + args: args{ + UserCTX, + 0, + &user.ListUsersRequest{}, + func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { + request.Queries = append(request.Queries, InUserIDsQuery([]string{userResp.UserId})) + return []userAttr{}, nil + }, + }, + want: &user.ListUsersResponse{ + Details: &object_v2beta.ListDetails{ + TotalResult: 0, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*user.User{}, + }, + }, + { + name: "list user by id, ok", + args: args{ + IamCTX, + 1, + &user.ListUsersRequest{ + Queries: []*user.SearchQuery{ + OrganizationIdQuery(orgResp.OrganizationId), + }, + }, + func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { + infos := make([]userAttr, len(usernames)) + userIDs := make([]string, len(usernames)) + for i, username := range usernames { + resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) + userIDs[i] = resp.GetUserId() + infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} + } + request.Queries = append(request.Queries, InUserIDsQuery(userIDs)) + return infos, nil + }, + }, + want: &user.ListUsersResponse{ + Details: &object_v2beta.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*user.User{ + { + State: user.UserState_USER_STATE_ACTIVE, + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ + Phone: "+41791234567", + IsVerified: true, + }, + }, + }, + }, + }, + }, + }, + { + name: "list user by id, passwordChangeRequired, ok", + args: args{ + IamCTX, + 1, + &user.ListUsersRequest{ + Queries: []*user.SearchQuery{ + OrganizationIdQuery(orgResp.OrganizationId), + }, + }, + func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { + infos := make([]userAttr, len(usernames)) + userIDs := make([]string, len(usernames)) + for i, username := range usernames { + resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) + userIDs[i] = resp.GetUserId() + details := Tester.SetUserPassword(ctx, resp.GetUserId(), integration.UserPassword, true) + infos[i] = userAttr{resp.GetUserId(), username, details.GetChangeDate(), resp.GetDetails()} + } + request.Queries = append(request.Queries, InUserIDsQuery(userIDs)) + return infos, nil + }, + }, + want: &user.ListUsersResponse{ + Details: &object_v2beta.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*user.User{ + { + State: user.UserState_USER_STATE_ACTIVE, + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ + Phone: "+41791234567", + IsVerified: true, + }, + PasswordChangeRequired: true, + PasswordChanged: timestamppb.Now(), + }, + }, + }, + }, + }, + }, + { + name: "list user by id multiple, ok", + args: args{ + IamCTX, + 3, + &user.ListUsersRequest{ + Queries: []*user.SearchQuery{ + OrganizationIdQuery(orgResp.OrganizationId), + }, + }, + func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { + infos := make([]userAttr, len(usernames)) + userIDs := make([]string, len(usernames)) + for i, username := range usernames { + resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) + userIDs[i] = resp.GetUserId() + infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} + } + request.Queries = append(request.Queries, InUserIDsQuery(userIDs)) + return infos, nil + }, + }, + want: &user.ListUsersResponse{ + Details: &object_v2beta.ListDetails{ + TotalResult: 3, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*user.User{ + { + State: user.UserState_USER_STATE_ACTIVE, + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ + Phone: "+41791234567", + IsVerified: true, + }, + }, + }, + }, { + State: user.UserState_USER_STATE_ACTIVE, + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ + Phone: "+41791234567", + IsVerified: true, + }, + }, + }, + }, { + State: user.UserState_USER_STATE_ACTIVE, + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ + Phone: "+41791234567", + IsVerified: true, + }, + }, + }, + }, + }, + }, + }, + { + name: "list user by username, ok", + args: args{ + IamCTX, + 1, + &user.ListUsersRequest{ + Queries: []*user.SearchQuery{ + OrganizationIdQuery(orgResp.OrganizationId), + }, + }, + func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { + infos := make([]userAttr, len(usernames)) + userIDs := make([]string, len(usernames)) + for i, username := range usernames { + resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) + userIDs[i] = resp.GetUserId() + infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} + request.Queries = append(request.Queries, UsernameQuery(username)) + } + return infos, nil + }, + }, + want: &user.ListUsersResponse{ + Details: &object_v2beta.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*user.User{ + { + State: user.UserState_USER_STATE_ACTIVE, + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ + Phone: "+41791234567", + IsVerified: true, + }, + }, + }, + }, + }, + }, + }, + { + name: "list user in emails, ok", + args: args{ + IamCTX, + 1, + &user.ListUsersRequest{ + Queries: []*user.SearchQuery{ + OrganizationIdQuery(orgResp.OrganizationId), + }, + }, + func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { + infos := make([]userAttr, len(usernames)) + for i, username := range usernames { + resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) + infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} + } + request.Queries = append(request.Queries, InUserEmailsQuery(usernames)) + return infos, nil + }, + }, + want: &user.ListUsersResponse{ + Details: &object_v2beta.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*user.User{ + { + State: user.UserState_USER_STATE_ACTIVE, + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ + Phone: "+41791234567", + IsVerified: true, + }, + }, + }, + }, + }, + }, + }, + { + name: "list user in emails multiple, ok", + args: args{ + IamCTX, + 3, + &user.ListUsersRequest{ + Queries: []*user.SearchQuery{ + OrganizationIdQuery(orgResp.OrganizationId), + }, + }, + func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { + infos := make([]userAttr, len(usernames)) + for i, username := range usernames { + resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) + infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} + } + request.Queries = append(request.Queries, InUserEmailsQuery(usernames)) + return infos, nil + }, + }, + want: &user.ListUsersResponse{ + Details: &object_v2beta.ListDetails{ + TotalResult: 3, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*user.User{ + { + State: user.UserState_USER_STATE_ACTIVE, + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ + Phone: "+41791234567", + IsVerified: true, + }, + }, + }, + }, { + State: user.UserState_USER_STATE_ACTIVE, + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ + Phone: "+41791234567", + IsVerified: true, + }, + }, + }, + }, { + State: user.UserState_USER_STATE_ACTIVE, + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ + Phone: "+41791234567", + IsVerified: true, + }, + }, + }, + }, + }, + }, + }, + { + name: "list user in emails no found, ok", + args: args{ + IamCTX, + 3, + &user.ListUsersRequest{Queries: []*user.SearchQuery{ + OrganizationIdQuery(orgResp.OrganizationId), + InUserEmailsQuery([]string{"notfound"}), + }, + }, + func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { + return []userAttr{}, nil + }, + }, + want: &user.ListUsersResponse{ + Details: &object_v2beta.ListDetails{ + TotalResult: 0, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*user.User{}, + }, + }, + { + name: "list user resourceowner multiple, ok", + args: args{ + IamCTX, + 3, + &user.ListUsersRequest{}, + func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { + orgResp := Tester.CreateOrganization(ctx, fmt.Sprintf("ListUsersResourceowner%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())) + + infos := make([]userAttr, len(usernames)) + for i, username := range usernames { + resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) + infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} + } + request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId)) + request.Queries = append(request.Queries, InUserEmailsQuery(usernames)) + return infos, nil + }, + }, + want: &user.ListUsersResponse{ + Details: &object_v2beta.ListDetails{ + TotalResult: 3, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*user.User{ + { + State: user.UserState_USER_STATE_ACTIVE, + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ + Phone: "+41791234567", + IsVerified: true, + }, + }, + }, + }, { + State: user.UserState_USER_STATE_ACTIVE, + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ + Phone: "+41791234567", + IsVerified: true, + }, + }, + }, + }, { + State: user.UserState_USER_STATE_ACTIVE, + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ + Phone: "+41791234567", + IsVerified: true, + }, + }, + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + usernames := make([]string, tt.args.count) + for i := 0; i < tt.args.count; i++ { + usernames[i] = fmt.Sprintf("%d%d@mouse.com", time.Now().UnixNano(), i) + } + infos, err := tt.args.dep(tt.args.ctx, usernames, tt.args.req) + require.NoError(t, err) + retryDuration := time.Minute + if ctxDeadline, ok := CTX.Deadline(); ok { + retryDuration = time.Until(ctxDeadline) + } + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, listErr := Client.ListUsers(tt.args.ctx, tt.args.req) + assertErr := assert.NoError + if tt.wantErr { + assertErr = assert.Error + } + assertErr(ttt, listErr) + if listErr != nil { + return + } + // always only give back dependency infos which are required for the response + assert.Len(ttt, tt.want.Result, len(infos)) + // always first check length, otherwise its failed anyway + assert.Len(ttt, got.Result, len(tt.want.Result)) + // fill in userid and username as it is generated + for i := range infos { + tt.want.Result[i].UserId = infos[i].UserID + tt.want.Result[i].Username = infos[i].Username + tt.want.Result[i].PreferredLoginName = infos[i].Username + tt.want.Result[i].LoginNames = []string{infos[i].Username} + if human := tt.want.Result[i].GetHuman(); human != nil { + human.Email.Email = infos[i].Username + if tt.want.Result[i].GetHuman().GetPasswordChanged() != nil { + human.PasswordChanged = infos[i].Changed + } + } + tt.want.Result[i].Details = detailsV2ToV2beta(infos[i].Details) + } + for i := range tt.want.Result { + assert.Contains(ttt, got.Result, tt.want.Result[i]) + } + integration.AssertListDetails(t, tt.want, got) + }, retryDuration, time.Millisecond*100, "timeout waiting for expected user result") + }) + } +} + +func InUserIDsQuery(ids []string) *user.SearchQuery { + return &user.SearchQuery{Query: &user.SearchQuery_InUserIdsQuery{ + InUserIdsQuery: &user.InUserIDQuery{ + UserIds: ids, + }, + }, + } +} + +func InUserEmailsQuery(emails []string) *user.SearchQuery { + return &user.SearchQuery{Query: &user.SearchQuery_InUserEmailsQuery{ + InUserEmailsQuery: &user.InUserEmailsQuery{ + UserEmails: emails, + }, + }, + } +} + +func UsernameQuery(username string) *user.SearchQuery { + return &user.SearchQuery{Query: &user.SearchQuery_UserNameQuery{ + UserNameQuery: &user.UserNameQuery{ + UserName: username, + }, + }, + } +} + +func OrganizationIdQuery(resourceowner string) *user.SearchQuery { + return &user.SearchQuery{Query: &user.SearchQuery_OrganizationIdQuery{ + OrganizationIdQuery: &user.OrganizationIdQuery{ + OrganizationId: resourceowner, + }, + }, + } +} diff --git a/internal/api/grpc/user/v2beta/server.go b/internal/api/grpc/user/v2beta/server.go new file mode 100644 index 0000000000..93af47f58b --- /dev/null +++ b/internal/api/grpc/user/v2beta/server.go @@ -0,0 +1,75 @@ +package user + +import ( + "context" + + "google.golang.org/grpc" + + "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/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" +) + +var _ user.UserServiceServer = (*Server)(nil) + +type Server struct { + user.UnimplementedUserServiceServer + command *command.Commands + query *query.Queries + userCodeAlg crypto.EncryptionAlgorithm + idpAlg crypto.EncryptionAlgorithm + idpCallback func(ctx context.Context) string + samlRootURL func(ctx context.Context, idpID string) string + + assetAPIPrefix func(context.Context) string + + checkPermission domain.PermissionCheck +} + +type Config struct{} + +func CreateServer( + command *command.Commands, + query *query.Queries, + userCodeAlg crypto.EncryptionAlgorithm, + idpAlg crypto.EncryptionAlgorithm, + idpCallback func(ctx context.Context) string, + samlRootURL func(ctx context.Context, idpID string) string, + assetAPIPrefix func(ctx context.Context) string, + checkPermission domain.PermissionCheck, +) *Server { + return &Server{ + command: command, + query: query, + userCodeAlg: userCodeAlg, + idpAlg: idpAlg, + idpCallback: idpCallback, + samlRootURL: samlRootURL, + assetAPIPrefix: assetAPIPrefix, + checkPermission: checkPermission, + } +} + +func (s *Server) RegisterServer(grpcServer *grpc.Server) { + user.RegisterUserServiceServer(grpcServer, s) +} + +func (s *Server) AppName() string { + return user.UserService_ServiceDesc.ServiceName +} + +func (s *Server) MethodPrefix() string { + return user.UserService_ServiceDesc.ServiceName +} + +func (s *Server) AuthMethods() authz.MethodMapping { + return user.UserService_AuthMethods +} + +func (s *Server) RegisterGateway() server.RegisterGatewayFunc { + return user.RegisterUserServiceHandler +} diff --git a/internal/api/grpc/user/v2beta/totp.go b/internal/api/grpc/user/v2beta/totp.go new file mode 100644 index 0000000000..2ef47a9817 --- /dev/null +++ b/internal/api/grpc/user/v2beta/totp.go @@ -0,0 +1,44 @@ +package user + +import ( + "context" + + object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta" + "github.com/zitadel/zitadel/internal/domain" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" +) + +func (s *Server) RegisterTOTP(ctx context.Context, req *user.RegisterTOTPRequest) (*user.RegisterTOTPResponse, error) { + return totpDetailsToPb( + s.command.AddUserTOTP(ctx, req.GetUserId(), ""), + ) +} + +func totpDetailsToPb(totp *domain.TOTP, err error) (*user.RegisterTOTPResponse, error) { + if err != nil { + return nil, err + } + return &user.RegisterTOTPResponse{ + Details: object.DomainToDetailsPb(totp.ObjectDetails), + Uri: totp.URI, + Secret: totp.Secret, + }, nil +} + +func (s *Server) VerifyTOTPRegistration(ctx context.Context, req *user.VerifyTOTPRegistrationRequest) (*user.VerifyTOTPRegistrationResponse, error) { + objectDetails, err := s.command.CheckUserTOTP(ctx, req.GetUserId(), req.GetCode(), "") + if err != nil { + return nil, err + } + return &user.VerifyTOTPRegistrationResponse{ + Details: object.DomainToDetailsPb(objectDetails), + }, nil +} + +func (s *Server) RemoveTOTP(ctx context.Context, req *user.RemoveTOTPRequest) (*user.RemoveTOTPResponse, error) { + objectDetails, err := s.command.HumanRemoveTOTP(ctx, req.GetUserId(), "") + if err != nil { + return nil, err + } + return &user.RemoveTOTPResponse{Details: object.DomainToDetailsPb(objectDetails)}, nil +} diff --git a/internal/api/grpc/user/v2beta/totp_integration_test.go b/internal/api/grpc/user/v2beta/totp_integration_test.go new file mode 100644 index 0000000000..47b2952afd --- /dev/null +++ b/internal/api/grpc/user/v2beta/totp_integration_test.go @@ -0,0 +1,284 @@ +//go:build integration + +package user_test + +import ( + "context" + "testing" + "time" + + "github.com/pquerna/otp/totp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/integration" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" +) + +func TestServer_RegisterTOTP(t *testing.T) { + userID := Tester.CreateHumanUser(CTX).GetUserId() + Tester.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID) + ctx := Tester.WithAuthorizationToken(CTX, sessionToken) + + otherUser := Tester.CreateHumanUser(CTX).GetUserId() + Tester.RegisterUserPasskey(CTX, otherUser) + _, sessionTokenOtherUser, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, otherUser) + ctxOtherUser := Tester.WithAuthorizationToken(CTX, sessionTokenOtherUser) + + type args struct { + ctx context.Context + req *user.RegisterTOTPRequest + } + tests := []struct { + name string + args args + want *user.RegisterTOTPResponse + wantErr bool + }{ + { + name: "missing user id", + args: args{ + ctx: ctx, + req: &user.RegisterTOTPRequest{}, + }, + wantErr: true, + }, + { + name: "user mismatch", + args: args{ + ctx: ctxOtherUser, + req: &user.RegisterTOTPRequest{ + UserId: userID, + }, + }, + wantErr: true, + }, + { + name: "admin", + args: args{ + ctx: CTX, + req: &user.RegisterTOTPRequest{ + UserId: userID, + }, + }, + want: &user.RegisterTOTPResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "success", + args: args{ + ctx: ctx, + req: &user.RegisterTOTPRequest{ + UserId: userID, + }, + }, + want: &user.RegisterTOTPResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.RegisterTOTP(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.NotNil(t, got) + integration.AssertDetails(t, tt.want, got) + assert.NotEmpty(t, got.GetUri()) + assert.NotEmpty(t, got.GetSecret()) + }) + } +} + +func TestServer_VerifyTOTPRegistration(t *testing.T) { + userID := Tester.CreateHumanUser(CTX).GetUserId() + Tester.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID) + ctx := Tester.WithAuthorizationToken(CTX, sessionToken) + + reg, err := Client.RegisterTOTP(ctx, &user.RegisterTOTPRequest{ + UserId: userID, + }) + require.NoError(t, err) + code, err := totp.GenerateCode(reg.Secret, time.Now()) + require.NoError(t, err) + + otherUser := Tester.CreateHumanUser(CTX).GetUserId() + Tester.RegisterUserPasskey(CTX, otherUser) + _, sessionTokenOtherUser, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, otherUser) + ctxOtherUser := Tester.WithAuthorizationToken(CTX, sessionTokenOtherUser) + + regOtherUser, err := Client.RegisterTOTP(CTX, &user.RegisterTOTPRequest{ + UserId: otherUser, + }) + require.NoError(t, err) + codeOtherUser, err := totp.GenerateCode(regOtherUser.Secret, time.Now()) + require.NoError(t, err) + + type args struct { + ctx context.Context + req *user.VerifyTOTPRegistrationRequest + } + tests := []struct { + name string + args args + want *user.VerifyTOTPRegistrationResponse + wantErr bool + }{ + { + name: "user mismatch", + args: args{ + ctx: ctxOtherUser, + req: &user.VerifyTOTPRegistrationRequest{ + UserId: userID, + }, + }, + wantErr: true, + }, + { + name: "wrong code", + args: args{ + ctx: ctx, + req: &user.VerifyTOTPRegistrationRequest{ + UserId: userID, + Code: "123", + }, + }, + wantErr: true, + }, + { + name: "success", + args: args{ + ctx: ctx, + req: &user.VerifyTOTPRegistrationRequest{ + UserId: userID, + Code: code, + }, + }, + want: &user.VerifyTOTPRegistrationResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ResourceOwner, + }, + }, + }, + { + name: "success, admin", + args: args{ + ctx: CTX, + req: &user.VerifyTOTPRegistrationRequest{ + UserId: otherUser, + Code: codeOtherUser, + }, + }, + want: &user.VerifyTOTPRegistrationResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ResourceOwner, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.VerifyTOTPRegistration(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.NotNil(t, got) + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_RemoveTOTP(t *testing.T) { + userID := Tester.CreateHumanUser(CTX).GetUserId() + Tester.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID) + + userVerified := Tester.CreateHumanUser(CTX) + Tester.RegisterUserPasskey(CTX, userVerified.GetUserId()) + _, sessionTokenVerified, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userVerified.GetUserId()) + userVerifiedCtx := Tester.WithAuthorizationToken(context.Background(), sessionTokenVerified) + _, err := Client.VerifyPhone(userVerifiedCtx, &user.VerifyPhoneRequest{ + UserId: userVerified.GetUserId(), + VerificationCode: userVerified.GetPhoneCode(), + }) + require.NoError(t, err) + + regOtherUser, err := Client.RegisterTOTP(CTX, &user.RegisterTOTPRequest{ + UserId: userVerified.GetUserId(), + }) + require.NoError(t, err) + codeOtherUser, err := totp.GenerateCode(regOtherUser.Secret, time.Now()) + require.NoError(t, err) + _, err = Client.VerifyTOTPRegistration(userVerifiedCtx, &user.VerifyTOTPRegistrationRequest{ + UserId: userVerified.GetUserId(), + Code: codeOtherUser, + }, + ) + require.NoError(t, err) + + type args struct { + ctx context.Context + req *user.RemoveTOTPRequest + } + tests := []struct { + name string + args args + want *user.RemoveTOTPResponse + wantErr bool + }{ + { + name: "not added", + args: args{ + ctx: Tester.WithAuthorizationToken(context.Background(), sessionToken), + req: &user.RemoveTOTPRequest{ + UserId: userID, + }, + }, + wantErr: true, + }, + { + name: "success", + args: args{ + ctx: userVerifiedCtx, + req: &user.RemoveTOTPRequest{ + UserId: userVerified.GetUserId(), + }, + }, + want: &user.RemoveTOTPResponse{ + Details: &object.Details{ + ResourceOwner: Tester.Organisation.ResourceOwner, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.RemoveTOTP(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.NotNil(t, got) + integration.AssertDetails(t, tt.want, got) + }) + } +} diff --git a/internal/api/grpc/user/v2beta/totp_test.go b/internal/api/grpc/user/v2beta/totp_test.go new file mode 100644 index 0000000000..81a54675f2 --- /dev/null +++ b/internal/api/grpc/user/v2beta/totp_test.go @@ -0,0 +1,71 @@ +package user + +import ( + "io" + "testing" + "time" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/domain" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" +) + +func Test_totpDetailsToPb(t *testing.T) { + type args struct { + otp *domain.TOTP + err error + } + tests := []struct { + name string + args args + want *user.RegisterTOTPResponse + wantErr error + }{ + { + name: "error", + args: args{ + err: io.ErrClosedPipe, + }, + wantErr: io.ErrClosedPipe, + }, + { + name: "success", + args: args{ + otp: &domain.TOTP{ + ObjectDetails: &domain.ObjectDetails{ + Sequence: 123, + EventDate: time.Unix(456, 789), + ResourceOwner: "me", + }, + Secret: "secret", + URI: "URI", + }, + }, + want: &user.RegisterTOTPResponse{ + Details: &object.Details{ + Sequence: 123, + ChangeDate: ×tamppb.Timestamp{ + Seconds: 456, + Nanos: 789, + }, + ResourceOwner: "me", + }, + Secret: "secret", + Uri: "URI", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := totpDetailsToPb(tt.args.otp, tt.args.err) + require.ErrorIs(t, err, tt.wantErr) + if !proto.Equal(tt.want, got) { + t.Errorf("RegisterTOTPResponse =\n%v\nwant\n%v", got, tt.want) + } + }) + } +} diff --git a/internal/api/grpc/user/v2beta/u2f.go b/internal/api/grpc/user/v2beta/u2f.go new file mode 100644 index 0000000000..e23a22b8b5 --- /dev/null +++ b/internal/api/grpc/user/v2beta/u2f.go @@ -0,0 +1,42 @@ +package user + +import ( + "context" + + object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/zerrors" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" +) + +func (s *Server) RegisterU2F(ctx context.Context, req *user.RegisterU2FRequest) (*user.RegisterU2FResponse, error) { + return u2fRegistrationDetailsToPb( + s.command.RegisterUserU2F(ctx, req.GetUserId(), "", req.GetDomain()), + ) +} + +func u2fRegistrationDetailsToPb(details *domain.WebAuthNRegistrationDetails, err error) (*user.RegisterU2FResponse, error) { + objectDetails, options, err := webAuthNRegistrationDetailsToPb(details, err) + if err != nil { + return nil, err + } + return &user.RegisterU2FResponse{ + Details: objectDetails, + U2FId: details.ID, + PublicKeyCredentialCreationOptions: options, + }, nil +} + +func (s *Server) VerifyU2FRegistration(ctx context.Context, req *user.VerifyU2FRegistrationRequest) (*user.VerifyU2FRegistrationResponse, error) { + pkc, err := req.GetPublicKeyCredential().MarshalJSON() + if err != nil { + return nil, zerrors.ThrowInternal(err, "USERv2-IeTh4", "Errors.Internal") + } + objectDetails, err := s.command.HumanVerifyU2FSetup(ctx, req.GetUserId(), "", req.GetTokenName(), "", pkc) + if err != nil { + return nil, err + } + return &user.VerifyU2FRegistrationResponse{ + Details: object.DomainToDetailsPb(objectDetails), + }, nil +} diff --git a/internal/api/grpc/user/v2beta/u2f_integration_test.go b/internal/api/grpc/user/v2beta/u2f_integration_test.go new file mode 100644 index 0000000000..3b7fbd293c --- /dev/null +++ b/internal/api/grpc/user/v2beta/u2f_integration_test.go @@ -0,0 +1,190 @@ +//go:build integration + +package user_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/integration" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" +) + +func TestServer_RegisterU2F(t *testing.T) { + userID := Tester.CreateHumanUser(CTX).GetUserId() + otherUser := Tester.CreateHumanUser(CTX).GetUserId() + + // We also need a user session + Tester.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID) + Tester.RegisterUserPasskey(CTX, otherUser) + _, sessionTokenOtherUser, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, otherUser) + + type args struct { + ctx context.Context + req *user.RegisterU2FRequest + } + tests := []struct { + name string + args args + want *user.RegisterU2FResponse + wantErr bool + }{ + { + name: "missing user id", + args: args{ + ctx: CTX, + req: &user.RegisterU2FRequest{}, + }, + wantErr: true, + }, + { + name: "admin user", + args: args{ + ctx: CTX, + req: &user.RegisterU2FRequest{ + UserId: userID, + }, + }, + want: &user.RegisterU2FResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "other user, no permission", + args: args{ + ctx: Tester.WithAuthorizationToken(CTX, sessionTokenOtherUser), + req: &user.RegisterU2FRequest{ + UserId: userID, + }, + }, + wantErr: true, + }, + { + name: "user setting its own passkey", + args: args{ + ctx: Tester.WithAuthorizationToken(CTX, sessionToken), + req: &user.RegisterU2FRequest{ + UserId: userID, + }, + }, + want: &user.RegisterU2FResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.RegisterU2F(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.NotNil(t, got) + integration.AssertDetails(t, tt.want, got) + if tt.want != nil { + assert.NotEmpty(t, got.GetU2FId()) + assert.NotEmpty(t, got.GetPublicKeyCredentialCreationOptions()) + _, err = Tester.WebAuthN.CreateAttestationResponse(got.GetPublicKeyCredentialCreationOptions()) + require.NoError(t, err) + } + }) + } +} + +func TestServer_VerifyU2FRegistration(t *testing.T) { + userID := Tester.CreateHumanUser(CTX).GetUserId() + Tester.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, userID) + ctx := Tester.WithAuthorizationToken(CTX, sessionToken) + + pkr, err := Client.RegisterU2F(ctx, &user.RegisterU2FRequest{ + UserId: userID, + }) + require.NoError(t, err) + require.NotEmpty(t, pkr.GetPublicKeyCredentialCreationOptions()) + + attestationResponse, err := Tester.WebAuthN.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions()) + require.NoError(t, err) + + type args struct { + ctx context.Context + req *user.VerifyU2FRegistrationRequest + } + tests := []struct { + name string + args args + want *user.VerifyU2FRegistrationResponse + wantErr bool + }{ + { + name: "missing user id", + args: args{ + ctx: ctx, + req: &user.VerifyU2FRegistrationRequest{ + U2FId: "123", + TokenName: "nice name", + }, + }, + wantErr: true, + }, + { + name: "success", + args: args{ + ctx: ctx, + req: &user.VerifyU2FRegistrationRequest{ + UserId: userID, + U2FId: pkr.GetU2FId(), + PublicKeyCredential: attestationResponse, + TokenName: "nice name", + }, + }, + want: &user.VerifyU2FRegistrationResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "wrong credential", + args: args{ + ctx: ctx, + req: &user.VerifyU2FRegistrationRequest{ + UserId: userID, + U2FId: "123", + PublicKeyCredential: &structpb.Struct{ + Fields: map[string]*structpb.Value{"foo": {Kind: &structpb.Value_StringValue{StringValue: "bar"}}}, + }, + TokenName: "nice name", + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.VerifyU2FRegistration(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.NotNil(t, got) + integration.AssertDetails(t, tt.want, got) + }) + } +} diff --git a/internal/api/grpc/user/v2beta/u2f_test.go b/internal/api/grpc/user/v2beta/u2f_test.go new file mode 100644 index 0000000000..087837ce3c --- /dev/null +++ b/internal/api/grpc/user/v2beta/u2f_test.go @@ -0,0 +1,97 @@ +package user + +import ( + "io" + "testing" + "time" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/grpc" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/zerrors" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" +) + +func Test_u2fRegistrationDetailsToPb(t *testing.T) { + type args struct { + details *domain.WebAuthNRegistrationDetails + err error + } + tests := []struct { + name string + args args + want *user.RegisterU2FResponse + wantErr error + }{ + { + name: "an error", + args: args{ + details: nil, + err: io.ErrClosedPipe, + }, + wantErr: io.ErrClosedPipe, + }, + { + name: "unmarshall error", + args: args{ + details: &domain.WebAuthNRegistrationDetails{ + ObjectDetails: &domain.ObjectDetails{ + Sequence: 22, + EventDate: time.Unix(3000, 22), + ResourceOwner: "me", + }, + ID: "123", + PublicKeyCredentialCreationOptions: []byte(`\\`), + }, + err: nil, + }, + wantErr: zerrors.ThrowInternal(nil, "USERv2-Dohr6", "Errors.Internal"), + }, + { + name: "ok", + args: args{ + details: &domain.WebAuthNRegistrationDetails{ + ObjectDetails: &domain.ObjectDetails{ + Sequence: 22, + EventDate: time.Unix(3000, 22), + ResourceOwner: "me", + }, + ID: "123", + PublicKeyCredentialCreationOptions: []byte(`{"foo": "bar"}`), + }, + err: nil, + }, + want: &user.RegisterU2FResponse{ + Details: &object.Details{ + Sequence: 22, + ChangeDate: ×tamppb.Timestamp{ + Seconds: 3000, + Nanos: 22, + }, + ResourceOwner: "me", + }, + U2FId: "123", + PublicKeyCredentialCreationOptions: &structpb.Struct{ + Fields: map[string]*structpb.Value{"foo": {Kind: &structpb.Value_StringValue{StringValue: "bar"}}}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := u2fRegistrationDetailsToPb(tt.args.details, tt.args.err) + require.ErrorIs(t, err, tt.wantErr) + if !proto.Equal(tt.want, got) { + t.Errorf("Not equal:\nExpected\n%s\nActual:%s", tt.want, got) + } + if tt.want != nil { + grpc.AllFieldsSet(t, got.ProtoReflect()) + } + }) + } +} diff --git a/internal/api/grpc/user/v2beta/user.go b/internal/api/grpc/user/v2beta/user.go new file mode 100644 index 0000000000..8e3151a0b0 --- /dev/null +++ b/internal/api/grpc/user/v2beta/user.go @@ -0,0 +1,633 @@ +package user + +import ( + "context" + "errors" + "io" + + "golang.org/x/text/language" + "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/authz" + object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/idp" + "github.com/zitadel/zitadel/internal/idp/providers/ldap" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" +) + +func (s *Server) AddHumanUser(ctx context.Context, req *user.AddHumanUserRequest) (_ *user.AddHumanUserResponse, err error) { + human, err := AddUserRequestToAddHuman(req) + if err != nil { + return nil, err + } + orgID := authz.GetCtxData(ctx).OrgID + if err = s.command.AddUserHuman(ctx, orgID, human, false, s.userCodeAlg); err != nil { + return nil, err + } + return &user.AddHumanUserResponse{ + UserId: human.ID, + Details: object.DomainToDetailsPb(human.Details), + EmailCode: human.EmailCode, + PhoneCode: human.PhoneCode, + }, nil +} + +func AddUserRequestToAddHuman(req *user.AddHumanUserRequest) (*command.AddHuman, error) { + username := req.GetUsername() + if username == "" { + username = req.GetEmail().GetEmail() + } + var urlTemplate string + if req.GetEmail().GetSendCode() != nil { + urlTemplate = req.GetEmail().GetSendCode().GetUrlTemplate() + // test the template execution so the async notification will not fail because of it and the user won't realize + if err := domain.RenderConfirmURLTemplate(io.Discard, urlTemplate, req.GetUserId(), "code", "orgID"); err != nil { + return nil, err + } + } + passwordChangeRequired := req.GetPassword().GetChangeRequired() || req.GetHashedPassword().GetChangeRequired() + metadata := make([]*command.AddMetadataEntry, len(req.Metadata)) + for i, metadataEntry := range req.Metadata { + metadata[i] = &command.AddMetadataEntry{ + Key: metadataEntry.GetKey(), + Value: metadataEntry.GetValue(), + } + } + links := make([]*command.AddLink, len(req.GetIdpLinks())) + for i, link := range req.GetIdpLinks() { + links[i] = &command.AddLink{ + IDPID: link.GetIdpId(), + IDPExternalID: link.GetUserId(), + DisplayName: link.GetUserName(), + } + } + return &command.AddHuman{ + ID: req.GetUserId(), + Username: username, + FirstName: req.GetProfile().GetGivenName(), + LastName: req.GetProfile().GetFamilyName(), + NickName: req.GetProfile().GetNickName(), + DisplayName: req.GetProfile().GetDisplayName(), + Email: command.Email{ + Address: domain.EmailAddress(req.GetEmail().GetEmail()), + Verified: req.GetEmail().GetIsVerified(), + ReturnCode: req.GetEmail().GetReturnCode() != nil, + URLTemplate: urlTemplate, + }, + Phone: command.Phone{ + Number: domain.PhoneNumber(req.GetPhone().GetPhone()), + Verified: req.GetPhone().GetIsVerified(), + ReturnCode: req.GetPhone().GetReturnCode() != nil, + }, + PreferredLanguage: language.Make(req.GetProfile().GetPreferredLanguage()), + Gender: genderToDomain(req.GetProfile().GetGender()), + Password: req.GetPassword().GetPassword(), + EncodedPasswordHash: req.GetHashedPassword().GetHash(), + PasswordChangeRequired: passwordChangeRequired, + Passwordless: false, + Register: false, + Metadata: metadata, + Links: links, + TOTPSecret: req.GetTotpSecret(), + }, nil +} + +func genderToDomain(gender user.Gender) domain.Gender { + switch gender { + case user.Gender_GENDER_UNSPECIFIED: + return domain.GenderUnspecified + case user.Gender_GENDER_FEMALE: + return domain.GenderFemale + case user.Gender_GENDER_MALE: + return domain.GenderMale + case user.Gender_GENDER_DIVERSE: + return domain.GenderDiverse + default: + return domain.GenderUnspecified + } +} + +func (s *Server) UpdateHumanUser(ctx context.Context, req *user.UpdateHumanUserRequest) (_ *user.UpdateHumanUserResponse, err error) { + human, err := UpdateUserRequestToChangeHuman(req) + if err != nil { + return nil, err + } + err = s.command.ChangeUserHuman(ctx, human, s.userCodeAlg) + if err != nil { + return nil, err + } + return &user.UpdateHumanUserResponse{ + Details: object.DomainToDetailsPb(human.Details), + EmailCode: human.EmailCode, + PhoneCode: human.PhoneCode, + }, nil +} + +func (s *Server) LockUser(ctx context.Context, req *user.LockUserRequest) (_ *user.LockUserResponse, err error) { + details, err := s.command.LockUserV2(ctx, req.UserId) + if err != nil { + return nil, err + } + return &user.LockUserResponse{ + Details: object.DomainToDetailsPb(details), + }, nil +} + +func (s *Server) UnlockUser(ctx context.Context, req *user.UnlockUserRequest) (_ *user.UnlockUserResponse, err error) { + details, err := s.command.UnlockUserV2(ctx, req.UserId) + if err != nil { + return nil, err + } + return &user.UnlockUserResponse{ + Details: object.DomainToDetailsPb(details), + }, nil +} + +func (s *Server) DeactivateUser(ctx context.Context, req *user.DeactivateUserRequest) (_ *user.DeactivateUserResponse, err error) { + details, err := s.command.DeactivateUserV2(ctx, req.UserId) + if err != nil { + return nil, err + } + return &user.DeactivateUserResponse{ + Details: object.DomainToDetailsPb(details), + }, nil +} + +func (s *Server) ReactivateUser(ctx context.Context, req *user.ReactivateUserRequest) (_ *user.ReactivateUserResponse, err error) { + details, err := s.command.ReactivateUserV2(ctx, req.UserId) + if err != nil { + return nil, err + } + return &user.ReactivateUserResponse{ + Details: object.DomainToDetailsPb(details), + }, nil +} + +func ifNotNilPtr[v, p any](value *v, conv func(v) p) *p { + var pNil *p + if value == nil { + return pNil + } + pVal := conv(*value) + return &pVal +} + +func UpdateUserRequestToChangeHuman(req *user.UpdateHumanUserRequest) (*command.ChangeHuman, error) { + email, err := SetHumanEmailToEmail(req.Email, req.GetUserId()) + if err != nil { + return nil, err + } + return &command.ChangeHuman{ + ID: req.GetUserId(), + Username: req.Username, + Profile: SetHumanProfileToProfile(req.Profile), + Email: email, + Phone: SetHumanPhoneToPhone(req.Phone), + Password: SetHumanPasswordToPassword(req.Password), + }, nil +} + +func SetHumanProfileToProfile(profile *user.SetHumanProfile) *command.Profile { + if profile == nil { + return nil + } + var firstName *string + if profile.GivenName != "" { + firstName = &profile.GivenName + } + var lastName *string + if profile.FamilyName != "" { + lastName = &profile.FamilyName + } + return &command.Profile{ + FirstName: firstName, + LastName: lastName, + NickName: profile.NickName, + DisplayName: profile.DisplayName, + PreferredLanguage: ifNotNilPtr(profile.PreferredLanguage, language.Make), + Gender: ifNotNilPtr(profile.Gender, genderToDomain), + } +} + +func SetHumanEmailToEmail(email *user.SetHumanEmail, userID string) (*command.Email, error) { + if email == nil { + return nil, nil + } + var urlTemplate string + if email.GetSendCode() != nil && email.GetSendCode().UrlTemplate != nil { + urlTemplate = *email.GetSendCode().UrlTemplate + if err := domain.RenderConfirmURLTemplate(io.Discard, urlTemplate, userID, "code", "orgID"); err != nil { + return nil, err + } + } + return &command.Email{ + Address: domain.EmailAddress(email.Email), + Verified: email.GetIsVerified(), + ReturnCode: email.GetReturnCode() != nil, + URLTemplate: urlTemplate, + }, nil +} + +func SetHumanPhoneToPhone(phone *user.SetHumanPhone) *command.Phone { + if phone == nil { + return nil + } + return &command.Phone{ + Number: domain.PhoneNumber(phone.GetPhone()), + Verified: phone.GetIsVerified(), + ReturnCode: phone.GetReturnCode() != nil, + } +} + +func SetHumanPasswordToPassword(password *user.SetPassword) *command.Password { + if password == nil { + return nil + } + return &command.Password{ + PasswordCode: password.GetVerificationCode(), + OldPassword: password.GetCurrentPassword(), + Password: password.GetPassword().GetPassword(), + EncodedPasswordHash: password.GetHashedPassword().GetHash(), + ChangeRequired: password.GetPassword().GetChangeRequired() || password.GetHashedPassword().GetChangeRequired(), + } +} + +func (s *Server) AddIDPLink(ctx context.Context, req *user.AddIDPLinkRequest) (_ *user.AddIDPLinkResponse, err error) { + details, err := s.command.AddUserIDPLink(ctx, req.UserId, "", &command.AddLink{ + IDPID: req.GetIdpLink().GetIdpId(), + DisplayName: req.GetIdpLink().GetUserName(), + IDPExternalID: req.GetIdpLink().GetUserId(), + }) + if err != nil { + return nil, err + } + return &user.AddIDPLinkResponse{ + Details: object.DomainToDetailsPb(details), + }, nil +} + +func (s *Server) DeleteUser(ctx context.Context, req *user.DeleteUserRequest) (_ *user.DeleteUserResponse, err error) { + memberships, grants, err := s.removeUserDependencies(ctx, req.GetUserId()) + if err != nil { + return nil, err + } + details, err := s.command.RemoveUserV2(ctx, req.UserId, memberships, grants...) + if err != nil { + return nil, err + } + return &user.DeleteUserResponse{ + Details: object.DomainToDetailsPb(details), + }, nil +} + +func (s *Server) removeUserDependencies(ctx context.Context, userID string) ([]*command.CascadingMembership, []string, error) { + userGrantUserQuery, err := query.NewUserGrantUserIDSearchQuery(userID) + if err != nil { + return nil, nil, err + } + grants, err := s.query.UserGrants(ctx, &query.UserGrantsQueries{ + Queries: []query.SearchQuery{userGrantUserQuery}, + }, true) + if err != nil { + return nil, nil, err + } + membershipsUserQuery, err := query.NewMembershipUserIDQuery(userID) + if err != nil { + return nil, nil, err + } + memberships, err := s.query.Memberships(ctx, &query.MembershipSearchQuery{ + Queries: []query.SearchQuery{membershipsUserQuery}, + }, false) + if err != nil { + return nil, nil, err + } + return cascadingMemberships(memberships.Memberships), userGrantsToIDs(grants.UserGrants), nil +} + +func cascadingMemberships(memberships []*query.Membership) []*command.CascadingMembership { + cascades := make([]*command.CascadingMembership, len(memberships)) + for i, membership := range memberships { + cascades[i] = &command.CascadingMembership{ + UserID: membership.UserID, + ResourceOwner: membership.ResourceOwner, + IAM: cascadingIAMMembership(membership.IAM), + Org: cascadingOrgMembership(membership.Org), + Project: cascadingProjectMembership(membership.Project), + ProjectGrant: cascadingProjectGrantMembership(membership.ProjectGrant), + } + } + return cascades +} + +func cascadingIAMMembership(membership *query.IAMMembership) *command.CascadingIAMMembership { + if membership == nil { + return nil + } + return &command.CascadingIAMMembership{IAMID: membership.IAMID} +} +func cascadingOrgMembership(membership *query.OrgMembership) *command.CascadingOrgMembership { + if membership == nil { + return nil + } + return &command.CascadingOrgMembership{OrgID: membership.OrgID} +} +func cascadingProjectMembership(membership *query.ProjectMembership) *command.CascadingProjectMembership { + if membership == nil { + return nil + } + return &command.CascadingProjectMembership{ProjectID: membership.ProjectID} +} +func cascadingProjectGrantMembership(membership *query.ProjectGrantMembership) *command.CascadingProjectGrantMembership { + if membership == nil { + return nil + } + return &command.CascadingProjectGrantMembership{ProjectID: membership.ProjectID, GrantID: membership.GrantID} +} + +func userGrantsToIDs(userGrants []*query.UserGrant) []string { + converted := make([]string, len(userGrants)) + for i, grant := range userGrants { + converted[i] = grant.ID + } + return converted +} + +func (s *Server) StartIdentityProviderIntent(ctx context.Context, req *user.StartIdentityProviderIntentRequest) (_ *user.StartIdentityProviderIntentResponse, err error) { + switch t := req.GetContent().(type) { + case *user.StartIdentityProviderIntentRequest_Urls: + return s.startIDPIntent(ctx, req.GetIdpId(), t.Urls) + case *user.StartIdentityProviderIntentRequest_Ldap: + return s.startLDAPIntent(ctx, req.GetIdpId(), t.Ldap) + default: + return nil, zerrors.ThrowUnimplementedf(nil, "USERv2-S2g21", "type oneOf %T in method StartIdentityProviderIntent not implemented", t) + } +} + +func (s *Server) startIDPIntent(ctx context.Context, idpID string, urls *user.RedirectURLs) (*user.StartIdentityProviderIntentResponse, error) { + intentWriteModel, details, err := s.command.CreateIntent(ctx, idpID, urls.GetSuccessUrl(), urls.GetFailureUrl(), authz.GetInstance(ctx).InstanceID()) + if err != nil { + return nil, err + } + content, redirect, err := s.command.AuthFromProvider(ctx, idpID, intentWriteModel.AggregateID, s.idpCallback(ctx), s.samlRootURL(ctx, idpID)) + if err != nil { + return nil, err + } + if redirect { + return &user.StartIdentityProviderIntentResponse{ + Details: object.DomainToDetailsPb(details), + NextStep: &user.StartIdentityProviderIntentResponse_AuthUrl{AuthUrl: content}, + }, nil + } else { + return &user.StartIdentityProviderIntentResponse{ + Details: object.DomainToDetailsPb(details), + NextStep: &user.StartIdentityProviderIntentResponse_PostForm{ + PostForm: []byte(content), + }, + }, nil + } +} + +func (s *Server) startLDAPIntent(ctx context.Context, idpID string, ldapCredentials *user.LDAPCredentials) (*user.StartIdentityProviderIntentResponse, error) { + intentWriteModel, details, err := s.command.CreateIntent(ctx, idpID, "", "", authz.GetInstance(ctx).InstanceID()) + if err != nil { + return nil, err + } + externalUser, userID, attributes, err := s.ldapLogin(ctx, intentWriteModel.IDPID, ldapCredentials.GetUsername(), ldapCredentials.GetPassword()) + if err != nil { + if err := s.command.FailIDPIntent(ctx, intentWriteModel, err.Error()); err != nil { + return nil, err + } + return nil, err + } + token, err := s.command.SucceedLDAPIDPIntent(ctx, intentWriteModel, externalUser, userID, attributes) + if err != nil { + return nil, err + } + return &user.StartIdentityProviderIntentResponse{ + Details: object.DomainToDetailsPb(details), + NextStep: &user.StartIdentityProviderIntentResponse_IdpIntent{ + IdpIntent: &user.IDPIntent{ + IdpIntentId: intentWriteModel.AggregateID, + IdpIntentToken: token, + UserId: userID, + }, + }, + }, nil +} + +func (s *Server) checkLinkedExternalUser(ctx context.Context, idpID, externalUserID string) (string, error) { + idQuery, err := query.NewIDPUserLinkIDPIDSearchQuery(idpID) + if err != nil { + return "", err + } + externalIDQuery, err := query.NewIDPUserLinksExternalIDSearchQuery(externalUserID) + if err != nil { + return "", err + } + queries := []query.SearchQuery{ + idQuery, externalIDQuery, + } + links, err := s.query.IDPUserLinks(ctx, &query.IDPUserLinksSearchQuery{Queries: queries}, false) + if err != nil { + return "", err + } + if len(links.Links) == 1 { + return links.Links[0].UserID, nil + } + return "", nil +} + +func (s *Server) ldapLogin(ctx context.Context, idpID, username, password string) (idp.User, string, map[string][]string, error) { + provider, err := s.command.GetProvider(ctx, idpID, "", "") + if err != nil { + return nil, "", nil, err + } + ldapProvider, ok := provider.(*ldap.Provider) + if !ok { + return nil, "", nil, zerrors.ThrowInvalidArgument(nil, "IDP-9a02j2n2bh", "Errors.ExternalIDP.IDPTypeNotImplemented") + } + session := ldapProvider.GetSession(username, password) + externalUser, err := session.FetchUser(ctx) + if errors.Is(err, ldap.ErrFailedLogin) || errors.Is(err, ldap.ErrNoSingleUser) { + return nil, "", nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-nzun2i", "Errors.User.ExternalIDP.LoginFailed") + } + if err != nil { + return nil, "", nil, err + } + userID, err := s.checkLinkedExternalUser(ctx, idpID, externalUser.GetID()) + if err != nil { + return nil, "", nil, err + } + + attributes := make(map[string][]string, 0) + for _, item := range session.Entry.Attributes { + attributes[item.Name] = item.Values + } + return externalUser, userID, attributes, nil +} + +func (s *Server) RetrieveIdentityProviderIntent(ctx context.Context, req *user.RetrieveIdentityProviderIntentRequest) (_ *user.RetrieveIdentityProviderIntentResponse, err error) { + intent, err := s.command.GetIntentWriteModel(ctx, req.GetIdpIntentId(), "") + if err != nil { + return nil, err + } + if err := s.checkIntentToken(req.GetIdpIntentToken(), intent.AggregateID); err != nil { + return nil, err + } + if intent.State != domain.IDPIntentStateSucceeded { + return nil, zerrors.ThrowPreconditionFailed(nil, "IDP-nme4gszsvx", "Errors.Intent.NotSucceeded") + } + return idpIntentToIDPIntentPb(intent, s.idpAlg) +} + +func idpIntentToIDPIntentPb(intent *command.IDPIntentWriteModel, alg crypto.EncryptionAlgorithm) (_ *user.RetrieveIdentityProviderIntentResponse, err error) { + rawInformation := new(structpb.Struct) + err = rawInformation.UnmarshalJSON(intent.IDPUser) + if err != nil { + return nil, err + } + information := &user.RetrieveIdentityProviderIntentResponse{ + Details: intentToDetailsPb(intent), + IdpInformation: &user.IDPInformation{ + IdpId: intent.IDPID, + UserId: intent.IDPUserID, + UserName: intent.IDPUserName, + RawInformation: rawInformation, + }, + UserId: intent.UserID, + } + if intent.IDPIDToken != "" || intent.IDPAccessToken != nil { + information.IdpInformation.Access, err = idpOAuthTokensToPb(intent.IDPIDToken, intent.IDPAccessToken, alg) + if err != nil { + return nil, err + } + } + + if intent.IDPEntryAttributes != nil { + access, err := IDPEntryAttributesToPb(intent.IDPEntryAttributes) + if err != nil { + return nil, err + } + information.IdpInformation.Access = access + } + + if intent.Assertion != nil { + assertion, err := crypto.Decrypt(intent.Assertion, alg) + if err != nil { + return nil, err + } + information.IdpInformation.Access = IDPSAMLResponseToPb(assertion) + } + + return information, nil +} + +func idpOAuthTokensToPb(idpIDToken string, idpAccessToken *crypto.CryptoValue, alg crypto.EncryptionAlgorithm) (_ *user.IDPInformation_Oauth, err error) { + var idToken *string + if idpIDToken != "" { + idToken = &idpIDToken + } + var accessToken string + if idpAccessToken != nil { + accessToken, err = crypto.DecryptString(idpAccessToken, alg) + if err != nil { + return nil, err + } + } + return &user.IDPInformation_Oauth{ + Oauth: &user.IDPOAuthAccessInformation{ + AccessToken: accessToken, + IdToken: idToken, + }, + }, nil +} + +func intentToDetailsPb(intent *command.IDPIntentWriteModel) *object_pb.Details { + return &object_pb.Details{ + Sequence: intent.ProcessedSequence, + ChangeDate: timestamppb.New(intent.ChangeDate), + ResourceOwner: intent.ResourceOwner, + } +} + +func IDPEntryAttributesToPb(entryAttributes map[string][]string) (*user.IDPInformation_Ldap, error) { + values := make(map[string]interface{}, 0) + for k, v := range entryAttributes { + intValues := make([]interface{}, len(v)) + for i, value := range v { + intValues[i] = value + } + values[k] = intValues + } + attributes, err := structpb.NewStruct(values) + if err != nil { + return nil, err + } + return &user.IDPInformation_Ldap{ + Ldap: &user.IDPLDAPAccessInformation{ + Attributes: attributes, + }, + }, nil +} + +func IDPSAMLResponseToPb(assertion []byte) *user.IDPInformation_Saml { + return &user.IDPInformation_Saml{ + Saml: &user.IDPSAMLAccessInformation{ + Assertion: assertion, + }, + } +} + +func (s *Server) checkIntentToken(token string, intentID string) error { + return crypto.CheckToken(s.idpAlg, token, intentID) +} + +func (s *Server) ListAuthenticationMethodTypes(ctx context.Context, req *user.ListAuthenticationMethodTypesRequest) (*user.ListAuthenticationMethodTypesResponse, error) { + authMethods, err := s.query.ListUserAuthMethodTypes(ctx, req.GetUserId(), true) + if err != nil { + return nil, err + } + return &user.ListAuthenticationMethodTypesResponse{ + Details: object.ToListDetails(authMethods.SearchResponse), + AuthMethodTypes: authMethodTypesToPb(authMethods.AuthMethodTypes), + }, nil +} + +func authMethodTypesToPb(methodTypes []domain.UserAuthMethodType) []user.AuthenticationMethodType { + methods := make([]user.AuthenticationMethodType, len(methodTypes)) + for i, method := range methodTypes { + methods[i] = authMethodTypeToPb(method) + } + return methods +} + +func authMethodTypeToPb(methodType domain.UserAuthMethodType) user.AuthenticationMethodType { + switch methodType { + case domain.UserAuthMethodTypeTOTP: + return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_TOTP + case domain.UserAuthMethodTypeU2F: + return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_U2F + case domain.UserAuthMethodTypePasswordless: + return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_PASSKEY + case domain.UserAuthMethodTypePassword: + return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_PASSWORD + case domain.UserAuthMethodTypeIDP: + return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_IDP + case domain.UserAuthMethodTypeOTPSMS: + return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_OTP_SMS + case domain.UserAuthMethodTypeOTPEmail: + return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_OTP_EMAIL + case domain.UserAuthMethodTypeUnspecified, domain.UserAuthMethodTypeOTP, domain.UserAuthMethodTypePrivateKey: + // Handle all remaining cases so the linter succeeds + return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_UNSPECIFIED + default: + return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_UNSPECIFIED + } +} diff --git a/internal/api/grpc/user/v2beta/user_integration_test.go b/internal/api/grpc/user/v2beta/user_integration_test.go new file mode 100644 index 0000000000..d808e46c5f --- /dev/null +++ b/internal/api/grpc/user/v2beta/user_integration_test.go @@ -0,0 +1,2521 @@ +//go:build integration + +package user_test + +import ( + "context" + "fmt" + "net/url" + "os" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/grpc" + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/idp" + mgmt "github.com/zitadel/zitadel/pkg/grpc/management" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" +) + +var ( + CTX context.Context + IamCTX context.Context + UserCTX context.Context + SystemCTX 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() + + UserCTX = Tester.WithAuthorization(ctx, integration.Login) + IamCTX = Tester.WithAuthorization(ctx, integration.IAMOwner) + SystemCTX = Tester.WithAuthorization(ctx, integration.SystemUser) + CTX, ErrCTX = Tester.WithAuthorization(ctx, integration.OrgOwner), errCtx + Client = Tester.Client.UserV2beta + return m.Run() + }()) +} + +func TestServer_AddHumanUser(t *testing.T) { + idpID := Tester.AddGenericOAuthProvider(t, CTX) + 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{ + Organization: &object.Organization{ + Org: &object.Organization_OrgId{ + OrgId: Tester.Organisation.ID, + }, + }, + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + NickName: gu.Ptr("Dukkie"), + DisplayName: gu.Ptr("Donald Duck"), + PreferredLanguage: gu.Ptr("en"), + Gender: user.Gender_GENDER_DIVERSE.Enum(), + }, + Email: &user.SetHumanEmail{}, + Phone: &user.SetHumanPhone{}, + 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 email verification code", + args: args{ + CTX, + &user.AddHumanUserRequest{ + Organization: &object.Organization{ + Org: &object.Organization_OrgId{ + OrgId: Tester.Organisation.ID, + }, + }, + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "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{ + Organization: &object.Organization{ + Org: &object.Organization_OrgId{ + OrgId: Tester.Organisation.ID, + }, + }, + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "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: "return phone verification code", + args: args{ + CTX, + &user.AddHumanUserRequest{ + Organization: &object.Organization{ + Org: &object.Organization_OrgId{ + OrgId: Tester.Organisation.ID, + }, + }, + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + NickName: gu.Ptr("Dukkie"), + DisplayName: gu.Ptr("Donald Duck"), + PreferredLanguage: gu.Ptr("en"), + Gender: user.Gender_GENDER_DIVERSE.Enum(), + }, + Email: &user.SetHumanEmail{}, + Phone: &user.SetHumanPhone{ + Phone: "+41791234567", + Verification: &user.SetHumanPhone_ReturnCode{ + ReturnCode: &user.ReturnPhoneVerificationCode{}, + }, + }, + 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, + }, + PhoneCode: gu.Ptr("something"), + }, + }, + { + name: "custom template error", + args: args{ + CTX, + &user.AddHumanUserRequest{ + Organization: &object.Organization{ + Org: &object.Organization_OrgId{ + OrgId: Tester.Organisation.ID, + }, + }, + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "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{ + Organization: &object.Organization{ + Org: &object.Organization_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{ + Organization: &object.Organization{ + Org: &object.Organization_OrgId{ + OrgId: Tester.Organisation.ID, + }, + }, + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "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, + }, + { + name: "missing idp", + args: args{ + CTX, + &user.AddHumanUserRequest{ + Organization: &object.Organization{ + Org: &object.Organization_OrgId{ + OrgId: Tester.Organisation.ID, + }, + }, + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + NickName: gu.Ptr("Dukkie"), + DisplayName: gu.Ptr("Donald Duck"), + PreferredLanguage: gu.Ptr("en"), + Gender: user.Gender_GENDER_DIVERSE.Enum(), + }, + Email: &user.SetHumanEmail{ + Email: "livio@zitadel.com", + Verification: &user.SetHumanEmail_IsVerified{ + IsVerified: true, + }, + }, + Metadata: []*user.SetMetadataEntry{ + { + Key: "somekey", + Value: []byte("somevalue"), + }, + }, + PasswordType: &user.AddHumanUserRequest_Password{ + Password: &user.Password{ + Password: "DifficultPW666!", + ChangeRequired: false, + }, + }, + IdpLinks: []*user.IDPLink{ + { + IdpId: "idpID", + UserId: "userID", + UserName: "username", + }, + }, + }, + }, + wantErr: true, + }, + { + name: "with idp", + args: args{ + CTX, + &user.AddHumanUserRequest{ + Organization: &object.Organization{ + Org: &object.Organization_OrgId{ + OrgId: Tester.Organisation.ID, + }, + }, + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + NickName: gu.Ptr("Dukkie"), + DisplayName: gu.Ptr("Donald Duck"), + PreferredLanguage: gu.Ptr("en"), + Gender: user.Gender_GENDER_DIVERSE.Enum(), + }, + Email: &user.SetHumanEmail{ + Email: "livio@zitadel.com", + Verification: &user.SetHumanEmail_IsVerified{ + IsVerified: true, + }, + }, + Metadata: []*user.SetMetadataEntry{ + { + Key: "somekey", + Value: []byte("somevalue"), + }, + }, + PasswordType: &user.AddHumanUserRequest_Password{ + Password: &user.Password{ + Password: "DifficultPW666!", + ChangeRequired: false, + }, + }, + IdpLinks: []*user.IDPLink{ + { + IdpId: idpID, + UserId: "userID", + UserName: "username", + }, + }, + }, + }, + want: &user.AddHumanUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "with totp", + args: args{ + CTX, + &user.AddHumanUserRequest{ + Organization: &object.Organization{ + Org: &object.Organization_OrgId{ + OrgId: Tester.Organisation.ID, + }, + }, + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + NickName: gu.Ptr("Dukkie"), + DisplayName: gu.Ptr("Donald Duck"), + PreferredLanguage: gu.Ptr("en"), + Gender: user.Gender_GENDER_DIVERSE.Enum(), + }, + Email: &user.SetHumanEmail{ + Email: "livio@zitadel.com", + Verification: &user.SetHumanEmail_IsVerified{ + IsVerified: true, + }, + }, + Metadata: []*user.SetMetadataEntry{ + { + Key: "somekey", + Value: []byte("somevalue"), + }, + }, + PasswordType: &user.AddHumanUserRequest_Password{ + Password: &user.Password{ + Password: "DifficultPW666!", + ChangeRequired: false, + }, + }, + TotpSecret: gu.Ptr("secret"), + }, + }, + want: &user.AddHumanUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "password not complexity conform", + args: args{ + CTX, + &user.AddHumanUserRequest{ + Organization: &object.Organization{ + Org: &object.Organization_OrgId{ + OrgId: Tester.Organisation.ID, + }, + }, + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "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: "insufficient", + }, + }, + }, + }, + wantErr: true, + }, + { + name: "hashed password", + args: args{ + CTX, + &user.AddHumanUserRequest{ + Organization: &object.Organization{ + Org: &object.Organization_OrgId{ + OrgId: Tester.Organisation.ID, + }, + }, + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "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_HashedPassword{ + HashedPassword: &user.HashedPassword{ + Hash: "$2y$12$hXUrnqdq1RIIYZ2HPytIIe5lXdIvbhqrTvdPsSF7o.jFh817Z6lwm", + }, + }, + }, + }, + want: &user.AddHumanUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "unsupported hashed password", + args: args{ + CTX, + &user.AddHumanUserRequest{ + Organization: &object.Organization{ + Org: &object.Organization_OrgId{ + OrgId: Tester.Organisation.ID, + }, + }, + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "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_HashedPassword{ + HashedPassword: &user.HashedPassword{ + Hash: "$scrypt$ln=16,r=8,p=1$cmFuZG9tc2FsdGlzaGFyZA$Rh+NnJNo1I6nRwaNqbDm6kmADswD1+7FTKZ7Ln9D8nQ", + }, + }, + }, + }, + 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()) + } + if tt.want.GetPhoneCode() != "" { + assert.NotEmpty(t, got.GetPhoneCode()) + } + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_AddHumanUser_Permission(t *testing.T) { + newOrgOwnerEmail := fmt.Sprintf("%d@permission.com", time.Now().UnixNano()) + newOrg := Tester.CreateOrganization(IamCTX, fmt.Sprintf("AddHuman%d", time.Now().UnixNano()), newOrgOwnerEmail) + type args struct { + ctx context.Context + req *user.AddHumanUserRequest + } + tests := []struct { + name string + args args + want *user.AddHumanUserResponse + wantErr bool + }{ + { + name: "System, ok", + args: args{ + SystemCTX, + &user.AddHumanUserRequest{ + Organization: &object.Organization{ + Org: &object.Organization_OrgId{ + OrgId: newOrg.GetOrganizationId(), + }, + }, + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + NickName: gu.Ptr("Dukkie"), + DisplayName: gu.Ptr("Donald Duck"), + PreferredLanguage: gu.Ptr("en"), + Gender: user.Gender_GENDER_DIVERSE.Enum(), + }, + Email: &user.SetHumanEmail{}, + Phone: &user.SetHumanPhone{}, + 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: newOrg.GetOrganizationId(), + }, + }, + }, + { + name: "Instance, ok", + args: args{ + IamCTX, + &user.AddHumanUserRequest{ + Organization: &object.Organization{ + Org: &object.Organization_OrgId{ + OrgId: newOrg.GetOrganizationId(), + }, + }, + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + NickName: gu.Ptr("Dukkie"), + DisplayName: gu.Ptr("Donald Duck"), + PreferredLanguage: gu.Ptr("en"), + Gender: user.Gender_GENDER_DIVERSE.Enum(), + }, + Email: &user.SetHumanEmail{}, + Phone: &user.SetHumanPhone{}, + 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: newOrg.GetOrganizationId(), + }, + }, + }, + { + name: "Org, error", + args: args{ + CTX, + &user.AddHumanUserRequest{ + Organization: &object.Organization{ + Org: &object.Organization_OrgId{ + OrgId: newOrg.GetOrganizationId(), + }, + }, + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + NickName: gu.Ptr("Dukkie"), + DisplayName: gu.Ptr("Donald Duck"), + PreferredLanguage: gu.Ptr("en"), + Gender: user.Gender_GENDER_DIVERSE.Enum(), + }, + Email: &user.SetHumanEmail{}, + Phone: &user.SetHumanPhone{}, + Metadata: []*user.SetMetadataEntry{ + { + Key: "somekey", + Value: []byte("somevalue"), + }, + }, + PasswordType: &user.AddHumanUserRequest_Password{ + Password: &user.Password{ + Password: "DifficultPW666!", + ChangeRequired: true, + }, + }, + }, + }, + wantErr: true, + }, + { + name: "User, error", + args: args{ + UserCTX, + &user.AddHumanUserRequest{ + Organization: &object.Organization{ + Org: &object.Organization_OrgId{ + OrgId: newOrg.GetOrganizationId(), + }, + }, + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + NickName: gu.Ptr("Dukkie"), + DisplayName: gu.Ptr("Donald Duck"), + PreferredLanguage: gu.Ptr("en"), + Gender: user.Gender_GENDER_DIVERSE.Enum(), + }, + Email: &user.SetHumanEmail{}, + Phone: &user.SetHumanPhone{}, + 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()) + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_UpdateHumanUser(t *testing.T) { + type args struct { + ctx context.Context + req *user.UpdateHumanUserRequest + } + tests := []struct { + name string + prepare func(request *user.UpdateHumanUserRequest) error + args args + want *user.UpdateHumanUserResponse + wantErr bool + }{ + { + name: "not exisiting", + prepare: func(request *user.UpdateHumanUserRequest) error { + request.UserId = "notexisiting" + return nil + }, + args: args{ + CTX, + &user.UpdateHumanUserRequest{ + Username: gu.Ptr("changed"), + }, + }, + wantErr: true, + }, + { + name: "change username, ok", + prepare: func(request *user.UpdateHumanUserRequest) error { + userID := Tester.CreateHumanUser(CTX).GetUserId() + request.UserId = userID + return nil + }, + args: args{ + CTX, + &user.UpdateHumanUserRequest{ + Username: gu.Ptr(fmt.Sprint(time.Now().UnixNano() + 1)), + }, + }, + want: &user.UpdateHumanUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "change profile, ok", + prepare: func(request *user.UpdateHumanUserRequest) error { + userID := Tester.CreateHumanUser(CTX).GetUserId() + request.UserId = userID + return nil + }, + args: args{ + CTX, + &user.UpdateHumanUserRequest{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + NickName: gu.Ptr("Dukkie"), + DisplayName: gu.Ptr("Donald Duck"), + PreferredLanguage: gu.Ptr("en"), + Gender: user.Gender_GENDER_DIVERSE.Enum(), + }, + }, + }, + want: &user.UpdateHumanUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "change email, ok", + prepare: func(request *user.UpdateHumanUserRequest) error { + userID := Tester.CreateHumanUser(CTX).GetUserId() + request.UserId = userID + return nil + }, + args: args{ + CTX, + &user.UpdateHumanUserRequest{ + Email: &user.SetHumanEmail{ + Email: "changed@test.com", + Verification: &user.SetHumanEmail_IsVerified{IsVerified: true}, + }, + }, + }, + want: &user.UpdateHumanUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "change email, code, ok", + prepare: func(request *user.UpdateHumanUserRequest) error { + userID := Tester.CreateHumanUser(CTX).GetUserId() + request.UserId = userID + return nil + }, + args: args{ + CTX, + &user.UpdateHumanUserRequest{ + Email: &user.SetHumanEmail{ + Email: "changed@test.com", + Verification: &user.SetHumanEmail_ReturnCode{}, + }, + }, + }, + want: &user.UpdateHumanUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + EmailCode: gu.Ptr("something"), + }, + }, + { + name: "change phone, ok", + prepare: func(request *user.UpdateHumanUserRequest) error { + userID := Tester.CreateHumanUser(CTX).GetUserId() + request.UserId = userID + return nil + }, + args: args{ + CTX, + &user.UpdateHumanUserRequest{ + Phone: &user.SetHumanPhone{ + Phone: "+41791234567", + Verification: &user.SetHumanPhone_IsVerified{IsVerified: true}, + }, + }, + }, + want: &user.UpdateHumanUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "change phone, code, ok", + prepare: func(request *user.UpdateHumanUserRequest) error { + userID := Tester.CreateHumanUser(CTX).GetUserId() + request.UserId = userID + return nil + }, + args: args{ + CTX, + &user.UpdateHumanUserRequest{ + Phone: &user.SetHumanPhone{ + Phone: "+41791234568", + Verification: &user.SetHumanPhone_ReturnCode{}, + }, + }, + }, + want: &user.UpdateHumanUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + PhoneCode: gu.Ptr("something"), + }, + }, + { + name: "change password, code, ok", + prepare: func(request *user.UpdateHumanUserRequest) error { + userID := Tester.CreateHumanUser(CTX).GetUserId() + request.UserId = userID + resp, err := Client.PasswordReset(CTX, &user.PasswordResetRequest{ + UserId: userID, + Medium: &user.PasswordResetRequest_ReturnCode{ + ReturnCode: &user.ReturnPasswordResetCode{}, + }, + }) + if err != nil { + return err + } + request.Password.Verification = &user.SetPassword_VerificationCode{ + VerificationCode: resp.GetVerificationCode(), + } + return nil + }, + args: args{ + CTX, + &user.UpdateHumanUserRequest{ + Password: &user.SetPassword{ + PasswordType: &user.SetPassword_Password{ + Password: &user.Password{ + Password: "Password1!", + ChangeRequired: true, + }, + }, + }, + }, + }, + want: &user.UpdateHumanUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "change hashed password, code, ok", + prepare: func(request *user.UpdateHumanUserRequest) error { + userID := Tester.CreateHumanUser(CTX).GetUserId() + request.UserId = userID + resp, err := Client.PasswordReset(CTX, &user.PasswordResetRequest{ + UserId: userID, + Medium: &user.PasswordResetRequest_ReturnCode{ + ReturnCode: &user.ReturnPasswordResetCode{}, + }, + }) + if err != nil { + return err + } + request.Password.Verification = &user.SetPassword_VerificationCode{ + VerificationCode: resp.GetVerificationCode(), + } + return nil + }, + args: args{ + CTX, + &user.UpdateHumanUserRequest{ + Password: &user.SetPassword{ + PasswordType: &user.SetPassword_HashedPassword{ + HashedPassword: &user.HashedPassword{ + Hash: "$2y$12$hXUrnqdq1RIIYZ2HPytIIe5lXdIvbhqrTvdPsSF7o.jFh817Z6lwm", + }, + }, + }, + }, + }, + want: &user.UpdateHumanUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "change hashed password, code, not supported", + prepare: func(request *user.UpdateHumanUserRequest) error { + userID := Tester.CreateHumanUser(CTX).GetUserId() + request.UserId = userID + resp, err := Client.PasswordReset(CTX, &user.PasswordResetRequest{ + UserId: userID, + Medium: &user.PasswordResetRequest_ReturnCode{ + ReturnCode: &user.ReturnPasswordResetCode{}, + }, + }) + if err != nil { + return err + } + request.Password = &user.SetPassword{ + Verification: &user.SetPassword_VerificationCode{ + VerificationCode: resp.GetVerificationCode(), + }, + } + return nil + }, + args: args{ + CTX, + &user.UpdateHumanUserRequest{ + Password: &user.SetPassword{ + PasswordType: &user.SetPassword_HashedPassword{ + HashedPassword: &user.HashedPassword{ + Hash: "$scrypt$ln=16,r=8,p=1$cmFuZG9tc2FsdGlzaGFyZA$Rh+NnJNo1I6nRwaNqbDm6kmADswD1+7FTKZ7Ln9D8nQ", + }, + }, + }, + }, + }, + wantErr: true, + }, + { + name: "change password, old password, ok", + prepare: func(request *user.UpdateHumanUserRequest) error { + userID := Tester.CreateHumanUser(CTX).GetUserId() + request.UserId = userID + + resp, err := Client.PasswordReset(CTX, &user.PasswordResetRequest{ + UserId: userID, + Medium: &user.PasswordResetRequest_ReturnCode{ + ReturnCode: &user.ReturnPasswordResetCode{}, + }, + }) + if err != nil { + return err + } + pw := "Password1." + _, err = Client.SetPassword(CTX, &user.SetPasswordRequest{ + UserId: userID, + NewPassword: &user.Password{ + Password: pw, + ChangeRequired: true, + }, + Verification: &user.SetPasswordRequest_VerificationCode{ + VerificationCode: resp.GetVerificationCode(), + }, + }) + if err != nil { + return err + } + request.Password.Verification = &user.SetPassword_CurrentPassword{ + CurrentPassword: pw, + } + return nil + }, + args: args{ + CTX, + &user.UpdateHumanUserRequest{ + Password: &user.SetPassword{ + PasswordType: &user.SetPassword_Password{ + Password: &user.Password{ + Password: "Password1!", + ChangeRequired: true, + }, + }, + }, + }, + }, + want: &user.UpdateHumanUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.prepare(tt.args.req) + require.NoError(t, err) + + got, err := Client.UpdateHumanUser(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + if tt.want.GetEmailCode() != "" { + assert.NotEmpty(t, got.GetEmailCode()) + } + if tt.want.GetPhoneCode() != "" { + assert.NotEmpty(t, got.GetPhoneCode()) + } + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_UpdateHumanUser_Permission(t *testing.T) { + newOrgOwnerEmail := fmt.Sprintf("%d@permission.update.com", time.Now().UnixNano()) + newOrg := Tester.CreateOrganization(IamCTX, fmt.Sprintf("UpdateHuman%d", time.Now().UnixNano()), newOrgOwnerEmail) + newUserID := newOrg.CreatedAdmins[0].GetUserId() + type args struct { + ctx context.Context + req *user.UpdateHumanUserRequest + } + tests := []struct { + name string + args args + want *user.UpdateHumanUserResponse + wantErr bool + }{ + { + name: "system, ok", + args: args{ + SystemCTX, + &user.UpdateHumanUserRequest{ + UserId: newUserID, + Username: gu.Ptr(fmt.Sprint("system", time.Now().UnixNano()+1)), + }, + }, + want: &user.UpdateHumanUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: newOrg.GetOrganizationId(), + }, + }, + }, + { + name: "instance, ok", + args: args{ + IamCTX, + &user.UpdateHumanUserRequest{ + UserId: newUserID, + Username: gu.Ptr(fmt.Sprint("instance", time.Now().UnixNano()+1)), + }, + }, + want: &user.UpdateHumanUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: newOrg.GetOrganizationId(), + }, + }, + }, + { + name: "org, error", + args: args{ + CTX, + &user.UpdateHumanUserRequest{ + UserId: newUserID, + Username: gu.Ptr(fmt.Sprint("org", time.Now().UnixNano()+1)), + }, + }, + wantErr: true, + }, + { + name: "user, error", + args: args{ + UserCTX, + &user.UpdateHumanUserRequest{ + UserId: newUserID, + Username: gu.Ptr(fmt.Sprint("user", time.Now().UnixNano()+1)), + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + got, err := Client.UpdateHumanUser(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_LockUser(t *testing.T) { + type args struct { + ctx context.Context + req *user.LockUserRequest + prepare func(request *user.LockUserRequest) error + } + tests := []struct { + name string + args args + want *user.LockUserResponse + wantErr bool + }{ + { + name: "lock, not existing", + args: args{ + CTX, + &user.LockUserRequest{ + UserId: "notexisting", + }, + func(request *user.LockUserRequest) error { return nil }, + }, + wantErr: true, + }, + { + name: "lock, ok", + args: args{ + CTX, + &user.LockUserRequest{}, + func(request *user.LockUserRequest) error { + resp := Tester.CreateHumanUser(CTX) + request.UserId = resp.GetUserId() + return nil + }, + }, + want: &user.LockUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "lock machine, ok", + args: args{ + CTX, + &user.LockUserRequest{}, + func(request *user.LockUserRequest) error { + resp := Tester.CreateMachineUser(CTX) + request.UserId = resp.GetUserId() + return nil + }, + }, + want: &user.LockUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "lock, already locked", + args: args{ + CTX, + &user.LockUserRequest{}, + func(request *user.LockUserRequest) error { + resp := Tester.CreateHumanUser(CTX) + request.UserId = resp.GetUserId() + _, err := Client.LockUser(CTX, &user.LockUserRequest{ + UserId: resp.GetUserId(), + }) + return err + }, + }, + wantErr: true, + }, + { + name: "lock machine, already locked", + args: args{ + CTX, + &user.LockUserRequest{}, + func(request *user.LockUserRequest) error { + resp := Tester.CreateMachineUser(CTX) + request.UserId = resp.GetUserId() + _, err := Client.LockUser(CTX, &user.LockUserRequest{ + UserId: resp.GetUserId(), + }) + return err + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.args.prepare(tt.args.req) + require.NoError(t, err) + + got, err := Client.LockUser(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_UnLockUser(t *testing.T) { + type args struct { + ctx context.Context + req *user.UnlockUserRequest + prepare func(request *user.UnlockUserRequest) error + } + tests := []struct { + name string + args args + want *user.UnlockUserResponse + wantErr bool + }{ + { + name: "unlock, not existing", + args: args{ + CTX, + &user.UnlockUserRequest{ + UserId: "notexisting", + }, + func(request *user.UnlockUserRequest) error { return nil }, + }, + wantErr: true, + }, + { + name: "unlock, not locked", + args: args{ + ctx: CTX, + req: &user.UnlockUserRequest{}, + prepare: func(request *user.UnlockUserRequest) error { + resp := Tester.CreateHumanUser(CTX) + request.UserId = resp.GetUserId() + return nil + }, + }, + wantErr: true, + }, + { + name: "unlock machine, not locked", + args: args{ + ctx: CTX, + req: &user.UnlockUserRequest{}, + prepare: func(request *user.UnlockUserRequest) error { + resp := Tester.CreateMachineUser(CTX) + request.UserId = resp.GetUserId() + return nil + }, + }, + wantErr: true, + }, + { + name: "unlock, ok", + args: args{ + ctx: CTX, + req: &user.UnlockUserRequest{}, + prepare: func(request *user.UnlockUserRequest) error { + resp := Tester.CreateHumanUser(CTX) + request.UserId = resp.GetUserId() + _, err := Client.LockUser(CTX, &user.LockUserRequest{ + UserId: resp.GetUserId(), + }) + return err + }, + }, + want: &user.UnlockUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "unlock machine, ok", + args: args{ + ctx: CTX, + req: &user.UnlockUserRequest{}, + prepare: func(request *user.UnlockUserRequest) error { + resp := Tester.CreateMachineUser(CTX) + request.UserId = resp.GetUserId() + _, err := Client.LockUser(CTX, &user.LockUserRequest{ + UserId: resp.GetUserId(), + }) + return err + }, + }, + want: &user.UnlockUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.args.prepare(tt.args.req) + require.NoError(t, err) + + got, err := Client.UnlockUser(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_DeactivateUser(t *testing.T) { + type args struct { + ctx context.Context + req *user.DeactivateUserRequest + prepare func(request *user.DeactivateUserRequest) error + } + tests := []struct { + name string + args args + want *user.DeactivateUserResponse + wantErr bool + }{ + { + name: "deactivate, not existing", + args: args{ + CTX, + &user.DeactivateUserRequest{ + UserId: "notexisting", + }, + func(request *user.DeactivateUserRequest) error { return nil }, + }, + wantErr: true, + }, + { + name: "deactivate, ok", + args: args{ + CTX, + &user.DeactivateUserRequest{}, + func(request *user.DeactivateUserRequest) error { + resp := Tester.CreateHumanUser(CTX) + request.UserId = resp.GetUserId() + return nil + }, + }, + want: &user.DeactivateUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "deactivate machine, ok", + args: args{ + CTX, + &user.DeactivateUserRequest{}, + func(request *user.DeactivateUserRequest) error { + resp := Tester.CreateMachineUser(CTX) + request.UserId = resp.GetUserId() + return nil + }, + }, + want: &user.DeactivateUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "deactivate, already deactivated", + args: args{ + CTX, + &user.DeactivateUserRequest{}, + func(request *user.DeactivateUserRequest) error { + resp := Tester.CreateHumanUser(CTX) + request.UserId = resp.GetUserId() + _, err := Client.DeactivateUser(CTX, &user.DeactivateUserRequest{ + UserId: resp.GetUserId(), + }) + return err + }, + }, + wantErr: true, + }, + { + name: "deactivate machine, already deactivated", + args: args{ + CTX, + &user.DeactivateUserRequest{}, + func(request *user.DeactivateUserRequest) error { + resp := Tester.CreateMachineUser(CTX) + request.UserId = resp.GetUserId() + _, err := Client.DeactivateUser(CTX, &user.DeactivateUserRequest{ + UserId: resp.GetUserId(), + }) + return err + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.args.prepare(tt.args.req) + require.NoError(t, err) + + got, err := Client.DeactivateUser(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_ReactivateUser(t *testing.T) { + type args struct { + ctx context.Context + req *user.ReactivateUserRequest + prepare func(request *user.ReactivateUserRequest) error + } + tests := []struct { + name string + args args + want *user.ReactivateUserResponse + wantErr bool + }{ + { + name: "reactivate, not existing", + args: args{ + CTX, + &user.ReactivateUserRequest{ + UserId: "notexisting", + }, + func(request *user.ReactivateUserRequest) error { return nil }, + }, + wantErr: true, + }, + { + name: "reactivate, not deactivated", + args: args{ + ctx: CTX, + req: &user.ReactivateUserRequest{}, + prepare: func(request *user.ReactivateUserRequest) error { + resp := Tester.CreateHumanUser(CTX) + request.UserId = resp.GetUserId() + return nil + }, + }, + wantErr: true, + }, + { + name: "reactivate machine, not deactivated", + args: args{ + ctx: CTX, + req: &user.ReactivateUserRequest{}, + prepare: func(request *user.ReactivateUserRequest) error { + resp := Tester.CreateMachineUser(CTX) + request.UserId = resp.GetUserId() + return nil + }, + }, + wantErr: true, + }, + { + name: "reactivate, ok", + args: args{ + ctx: CTX, + req: &user.ReactivateUserRequest{}, + prepare: func(request *user.ReactivateUserRequest) error { + resp := Tester.CreateHumanUser(CTX) + request.UserId = resp.GetUserId() + _, err := Client.DeactivateUser(CTX, &user.DeactivateUserRequest{ + UserId: resp.GetUserId(), + }) + return err + }, + }, + want: &user.ReactivateUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "reactivate machine, ok", + args: args{ + ctx: CTX, + req: &user.ReactivateUserRequest{}, + prepare: func(request *user.ReactivateUserRequest) error { + resp := Tester.CreateMachineUser(CTX) + request.UserId = resp.GetUserId() + _, err := Client.DeactivateUser(CTX, &user.DeactivateUserRequest{ + UserId: resp.GetUserId(), + }) + return err + }, + }, + want: &user.ReactivateUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.args.prepare(tt.args.req) + require.NoError(t, err) + + got, err := Client.ReactivateUser(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_DeleteUser(t *testing.T) { + projectResp, err := Tester.CreateProject(CTX) + require.NoError(t, err) + type args struct { + ctx context.Context + req *user.DeleteUserRequest + prepare func(request *user.DeleteUserRequest) error + } + tests := []struct { + name string + args args + want *user.DeleteUserResponse + wantErr bool + }{ + { + name: "remove, not existing", + args: args{ + CTX, + &user.DeleteUserRequest{ + UserId: "notexisting", + }, + func(request *user.DeleteUserRequest) error { return nil }, + }, + wantErr: true, + }, + { + name: "remove human, ok", + args: args{ + ctx: CTX, + req: &user.DeleteUserRequest{}, + prepare: func(request *user.DeleteUserRequest) error { + resp := Tester.CreateHumanUser(CTX) + request.UserId = resp.GetUserId() + return err + }, + }, + want: &user.DeleteUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "remove machine, ok", + args: args{ + ctx: CTX, + req: &user.DeleteUserRequest{}, + prepare: func(request *user.DeleteUserRequest) error { + resp := Tester.CreateMachineUser(CTX) + request.UserId = resp.GetUserId() + return err + }, + }, + want: &user.DeleteUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "remove dependencies, ok", + args: args{ + ctx: CTX, + req: &user.DeleteUserRequest{}, + prepare: func(request *user.DeleteUserRequest) error { + resp := Tester.CreateHumanUser(CTX) + request.UserId = resp.GetUserId() + Tester.CreateProjectUserGrant(t, CTX, projectResp.GetId(), request.UserId) + Tester.CreateProjectMembership(t, CTX, projectResp.GetId(), request.UserId) + Tester.CreateOrgMembership(t, CTX, request.UserId) + return err + }, + }, + want: &user.DeleteUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.args.prepare(tt.args.req) + require.NoError(t, err) + + got, err := Client.DeleteUser(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_AddIDPLink(t *testing.T) { + idpID := Tester.AddGenericOAuthProvider(t, CTX) + type args struct { + ctx context.Context + req *user.AddIDPLinkRequest + } + tests := []struct { + name string + args args + want *user.AddIDPLinkResponse + wantErr bool + }{ + { + name: "user does not exist", + args: args{ + CTX, + &user.AddIDPLinkRequest{ + UserId: "userID", + IdpLink: &user.IDPLink{ + IdpId: idpID, + UserId: "userID", + UserName: "username", + }, + }, + }, + want: nil, + wantErr: true, + }, + { + name: "idp does not exist", + args: args{ + CTX, + &user.AddIDPLinkRequest{ + UserId: Tester.Users[integration.FirstInstanceUsersKey][integration.OrgOwner].ID, + IdpLink: &user.IDPLink{ + IdpId: "idpID", + UserId: "userID", + UserName: "username", + }, + }, + }, + want: nil, + wantErr: true, + }, + { + name: "add link", + args: args{ + CTX, + &user.AddIDPLinkRequest{ + UserId: Tester.Users[integration.FirstInstanceUsersKey][integration.OrgOwner].ID, + IdpLink: &user.IDPLink{ + IdpId: idpID, + UserId: "userID", + UserName: "username", + }, + }, + }, + want: &user.AddIDPLinkResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.AddIDPLink(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_StartIdentityProviderIntent(t *testing.T) { + idpID := Tester.AddGenericOAuthProvider(t, CTX) + orgIdpID := Tester.AddOrgGenericOAuthProvider(t, CTX, Tester.Organisation.ID) + orgResp := Tester.CreateOrganization(IamCTX, fmt.Sprintf("NotDefaultOrg%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())) + notDefaultOrgIdpID := Tester.AddOrgGenericOAuthProvider(t, CTX, orgResp.OrganizationId) + samlIdpID := Tester.AddSAMLProvider(t, CTX) + samlRedirectIdpID := Tester.AddSAMLRedirectProvider(t, CTX, "") + samlPostIdpID := Tester.AddSAMLPostProvider(t, CTX) + type args struct { + ctx context.Context + req *user.StartIdentityProviderIntentRequest + } + type want struct { + details *object.Details + url string + parametersExisting []string + parametersEqual map[string]string + postForm bool + } + tests := []struct { + name string + args args + want want + wantErr bool + }{ + { + name: "missing urls", + args: args{ + CTX, + &user.StartIdentityProviderIntentRequest{ + IdpId: idpID, + }, + }, + wantErr: true, + }, + { + name: "next step oauth auth url", + args: args{ + CTX, + &user.StartIdentityProviderIntentRequest{ + IdpId: idpID, + Content: &user.StartIdentityProviderIntentRequest_Urls{ + Urls: &user.RedirectURLs{ + SuccessUrl: "https://example.com/success", + FailureUrl: "https://example.com/failure", + }, + }, + }, + }, + want: want{ + details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + url: "https://example.com/oauth/v2/authorize", + parametersEqual: map[string]string{ + "client_id": "clientID", + "prompt": "select_account", + "redirect_uri": "http://" + Tester.Config.ExternalDomain + ":8080/idps/callback", + "response_type": "code", + "scope": "openid profile email", + }, + parametersExisting: []string{"state"}, + }, + wantErr: false, + }, + { + name: "next step oauth auth url, default org", + args: args{ + CTX, + &user.StartIdentityProviderIntentRequest{ + IdpId: orgIdpID, + Content: &user.StartIdentityProviderIntentRequest_Urls{ + Urls: &user.RedirectURLs{ + SuccessUrl: "https://example.com/success", + FailureUrl: "https://example.com/failure", + }, + }, + }, + }, + want: want{ + details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + url: "https://example.com/oauth/v2/authorize", + parametersEqual: map[string]string{ + "client_id": "clientID", + "prompt": "select_account", + "redirect_uri": "http://" + Tester.Config.ExternalDomain + ":8080/idps/callback", + "response_type": "code", + "scope": "openid profile email", + }, + parametersExisting: []string{"state"}, + }, + wantErr: false, + }, + { + name: "next step oauth auth url, default org", + args: args{ + CTX, + &user.StartIdentityProviderIntentRequest{ + IdpId: notDefaultOrgIdpID, + Content: &user.StartIdentityProviderIntentRequest_Urls{ + Urls: &user.RedirectURLs{ + SuccessUrl: "https://example.com/success", + FailureUrl: "https://example.com/failure", + }, + }, + }, + }, + want: want{ + details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + url: "https://example.com/oauth/v2/authorize", + parametersEqual: map[string]string{ + "client_id": "clientID", + "prompt": "select_account", + "redirect_uri": "http://" + Tester.Config.ExternalDomain + ":8080/idps/callback", + "response_type": "code", + "scope": "openid profile email", + }, + parametersExisting: []string{"state"}, + }, + wantErr: false, + }, + { + name: "next step oauth auth url org", + args: args{ + CTX, + &user.StartIdentityProviderIntentRequest{ + IdpId: orgIdpID, + Content: &user.StartIdentityProviderIntentRequest_Urls{ + Urls: &user.RedirectURLs{ + SuccessUrl: "https://example.com/success", + FailureUrl: "https://example.com/failure", + }, + }, + }, + }, + want: want{ + details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + url: "https://example.com/oauth/v2/authorize", + parametersEqual: map[string]string{ + "client_id": "clientID", + "prompt": "select_account", + "redirect_uri": "http://" + Tester.Config.ExternalDomain + ":8080/idps/callback", + "response_type": "code", + "scope": "openid profile email", + }, + parametersExisting: []string{"state"}, + }, + wantErr: false, + }, + { + name: "next step saml default", + args: args{ + CTX, + &user.StartIdentityProviderIntentRequest{ + IdpId: samlIdpID, + Content: &user.StartIdentityProviderIntentRequest_Urls{ + Urls: &user.RedirectURLs{ + SuccessUrl: "https://example.com/success", + FailureUrl: "https://example.com/failure", + }, + }, + }, + }, + want: want{ + details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + url: "http://" + Tester.Config.ExternalDomain + ":8000/sso", + parametersExisting: []string{"RelayState", "SAMLRequest"}, + }, + wantErr: false, + }, + { + name: "next step saml auth url", + args: args{ + CTX, + &user.StartIdentityProviderIntentRequest{ + IdpId: samlRedirectIdpID, + Content: &user.StartIdentityProviderIntentRequest_Urls{ + Urls: &user.RedirectURLs{ + SuccessUrl: "https://example.com/success", + FailureUrl: "https://example.com/failure", + }, + }, + }, + }, + want: want{ + details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + url: "http://" + Tester.Config.ExternalDomain + ":8000/sso", + parametersExisting: []string{"RelayState", "SAMLRequest"}, + }, + wantErr: false, + }, + { + name: "next step saml form", + args: args{ + CTX, + &user.StartIdentityProviderIntentRequest{ + IdpId: samlPostIdpID, + Content: &user.StartIdentityProviderIntentRequest_Urls{ + Urls: &user.RedirectURLs{ + SuccessUrl: "https://example.com/success", + FailureUrl: "https://example.com/failure", + }, + }, + }, + }, + want: want{ + details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Instance.InstanceID(), + }, + postForm: true, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.StartIdentityProviderIntent(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + if tt.want.url != "" { + authUrl, err := url.Parse(got.GetAuthUrl()) + assert.NoError(t, err) + + assert.Len(t, authUrl.Query(), len(tt.want.parametersEqual)+len(tt.want.parametersExisting)) + + for _, existing := range tt.want.parametersExisting { + assert.True(t, authUrl.Query().Has(existing)) + } + for key, equal := range tt.want.parametersEqual { + assert.Equal(t, equal, authUrl.Query().Get(key)) + } + } + if tt.want.postForm { + assert.NotEmpty(t, got.GetPostForm()) + } + integration.AssertDetails(t, &user.StartIdentityProviderIntentResponse{ + Details: tt.want.details, + }, got) + }) + } +} + +func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { + idpID := Tester.AddGenericOAuthProvider(t, CTX) + intentID := Tester.CreateIntent(t, CTX, idpID) + successfulID, token, changeDate, sequence := Tester.CreateSuccessfulOAuthIntent(t, CTX, idpID, "", "id") + successfulWithUserID, withUsertoken, withUserchangeDate, withUsersequence := Tester.CreateSuccessfulOAuthIntent(t, CTX, idpID, "user", "id") + ldapSuccessfulID, ldapToken, ldapChangeDate, ldapSequence := Tester.CreateSuccessfulLDAPIntent(t, CTX, idpID, "", "id") + ldapSuccessfulWithUserID, ldapWithUserToken, ldapWithUserChangeDate, ldapWithUserSequence := Tester.CreateSuccessfulLDAPIntent(t, CTX, idpID, "user", "id") + samlSuccessfulID, samlToken, samlChangeDate, samlSequence := Tester.CreateSuccessfulSAMLIntent(t, CTX, idpID, "", "id") + type args struct { + ctx context.Context + req *user.RetrieveIdentityProviderIntentRequest + } + tests := []struct { + name string + args args + want *user.RetrieveIdentityProviderIntentResponse + wantErr bool + }{ + { + name: "failed intent", + args: args{ + CTX, + &user.RetrieveIdentityProviderIntentRequest{ + IdpIntentId: intentID, + IdpIntentToken: "", + }, + }, + wantErr: true, + }, + { + name: "wrong token", + args: args{ + CTX, + &user.RetrieveIdentityProviderIntentRequest{ + IdpIntentId: successfulID, + IdpIntentToken: "wrong token", + }, + }, + wantErr: true, + }, + { + name: "retrieve successful intent", + args: args{ + CTX, + &user.RetrieveIdentityProviderIntentRequest{ + IdpIntentId: successfulID, + IdpIntentToken: token, + }, + }, + want: &user.RetrieveIdentityProviderIntentResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.New(changeDate), + ResourceOwner: Tester.Instance.InstanceID(), + Sequence: sequence, + }, + IdpInformation: &user.IDPInformation{ + Access: &user.IDPInformation_Oauth{ + Oauth: &user.IDPOAuthAccessInformation{ + AccessToken: "accessToken", + IdToken: gu.Ptr("idToken"), + }, + }, + IdpId: idpID, + UserId: "id", + UserName: "username", + RawInformation: func() *structpb.Struct { + s, err := structpb.NewStruct(map[string]interface{}{ + "sub": "id", + "preferred_username": "username", + }) + require.NoError(t, err) + return s + }(), + }, + }, + wantErr: false, + }, + { + name: "retrieve successful intent with linked user", + args: args{ + CTX, + &user.RetrieveIdentityProviderIntentRequest{ + IdpIntentId: successfulWithUserID, + IdpIntentToken: withUsertoken, + }, + }, + want: &user.RetrieveIdentityProviderIntentResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.New(withUserchangeDate), + ResourceOwner: Tester.Instance.InstanceID(), + Sequence: withUsersequence, + }, + UserId: "user", + IdpInformation: &user.IDPInformation{ + Access: &user.IDPInformation_Oauth{ + Oauth: &user.IDPOAuthAccessInformation{ + AccessToken: "accessToken", + IdToken: gu.Ptr("idToken"), + }, + }, + IdpId: idpID, + UserId: "id", + UserName: "username", + RawInformation: func() *structpb.Struct { + s, err := structpb.NewStruct(map[string]interface{}{ + "sub": "id", + "preferred_username": "username", + }) + require.NoError(t, err) + return s + }(), + }, + }, + wantErr: false, + }, + { + name: "retrieve successful ldap intent", + args: args{ + CTX, + &user.RetrieveIdentityProviderIntentRequest{ + IdpIntentId: ldapSuccessfulID, + IdpIntentToken: ldapToken, + }, + }, + want: &user.RetrieveIdentityProviderIntentResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.New(ldapChangeDate), + ResourceOwner: Tester.Instance.InstanceID(), + Sequence: ldapSequence, + }, + IdpInformation: &user.IDPInformation{ + Access: &user.IDPInformation_Ldap{ + Ldap: &user.IDPLDAPAccessInformation{ + Attributes: func() *structpb.Struct { + s, err := structpb.NewStruct(map[string]interface{}{ + "id": []interface{}{"id"}, + "username": []interface{}{"username"}, + "language": []interface{}{"en"}, + }) + require.NoError(t, err) + return s + }(), + }, + }, + IdpId: idpID, + UserId: "id", + UserName: "username", + RawInformation: func() *structpb.Struct { + s, err := structpb.NewStruct(map[string]interface{}{ + "id": "id", + "preferredUsername": "username", + "preferredLanguage": "en", + }) + require.NoError(t, err) + return s + }(), + }, + }, + wantErr: false, + }, + { + name: "retrieve successful ldap intent with linked user", + args: args{ + CTX, + &user.RetrieveIdentityProviderIntentRequest{ + IdpIntentId: ldapSuccessfulWithUserID, + IdpIntentToken: ldapWithUserToken, + }, + }, + want: &user.RetrieveIdentityProviderIntentResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.New(ldapWithUserChangeDate), + ResourceOwner: Tester.Instance.InstanceID(), + Sequence: ldapWithUserSequence, + }, + UserId: "user", + IdpInformation: &user.IDPInformation{ + Access: &user.IDPInformation_Ldap{ + Ldap: &user.IDPLDAPAccessInformation{ + Attributes: func() *structpb.Struct { + s, err := structpb.NewStruct(map[string]interface{}{ + "id": []interface{}{"id"}, + "username": []interface{}{"username"}, + "language": []interface{}{"en"}, + }) + require.NoError(t, err) + return s + }(), + }, + }, + IdpId: idpID, + UserId: "id", + UserName: "username", + RawInformation: func() *structpb.Struct { + s, err := structpb.NewStruct(map[string]interface{}{ + "id": "id", + "preferredUsername": "username", + "preferredLanguage": "en", + }) + require.NoError(t, err) + return s + }(), + }, + }, + wantErr: false, + }, + { + name: "retrieve successful saml intent", + args: args{ + CTX, + &user.RetrieveIdentityProviderIntentRequest{ + IdpIntentId: samlSuccessfulID, + IdpIntentToken: samlToken, + }, + }, + want: &user.RetrieveIdentityProviderIntentResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.New(samlChangeDate), + ResourceOwner: Tester.Instance.InstanceID(), + Sequence: samlSequence, + }, + IdpInformation: &user.IDPInformation{ + Access: &user.IDPInformation_Saml{ + Saml: &user.IDPSAMLAccessInformation{ + Assertion: []byte(""), + }, + }, + IdpId: idpID, + UserId: "id", + UserName: "", + RawInformation: func() *structpb.Struct { + s, err := structpb.NewStruct(map[string]interface{}{ + "id": "id", + "attributes": map[string]interface{}{ + "attribute1": []interface{}{"value1"}, + }, + }) + require.NoError(t, err) + return s + }(), + }, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.RetrieveIdentityProviderIntent(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + grpc.AllFieldsEqual(t, tt.want.ProtoReflect(), got.ProtoReflect(), grpc.CustomMappers) + }) + } +} + +func TestServer_ListAuthenticationMethodTypes(t *testing.T) { + userIDWithoutAuth := Tester.CreateHumanUser(CTX).GetUserId() + + userIDWithPasskey := Tester.CreateHumanUser(CTX).GetUserId() + Tester.RegisterUserPasskey(CTX, userIDWithPasskey) + + userMultipleAuth := Tester.CreateHumanUser(CTX).GetUserId() + Tester.RegisterUserPasskey(CTX, userMultipleAuth) + provider, err := Tester.Client.Mgmt.AddGenericOIDCProvider(CTX, &mgmt.AddGenericOIDCProviderRequest{ + Name: "ListAuthenticationMethodTypes", + Issuer: "https://example.com", + ClientId: "client_id", + ClientSecret: "client_secret", + }) + require.NoError(t, err) + _, err = Tester.Client.Mgmt.AddCustomLoginPolicy(CTX, &mgmt.AddCustomLoginPolicyRequest{}) + require.Condition(t, func() bool { + code := status.Convert(err).Code() + return code == codes.AlreadyExists || code == codes.OK + }) + _, err = Tester.Client.Mgmt.AddIDPToLoginPolicy(CTX, &mgmt.AddIDPToLoginPolicyRequest{ + IdpId: provider.GetId(), + OwnerType: idp.IDPOwnerType_IDP_OWNER_TYPE_ORG, + }) + require.NoError(t, err) + idpLink, err := Client.AddIDPLink(CTX, &user.AddIDPLinkRequest{UserId: userMultipleAuth, IdpLink: &user.IDPLink{ + IdpId: provider.GetId(), + UserId: "external-id", + UserName: "displayName", + }}) + require.NoError(t, err) + // This should not remove the user IDP links + _, err = Tester.Client.Mgmt.RemoveIDPFromLoginPolicy(CTX, &mgmt.RemoveIDPFromLoginPolicyRequest{ + IdpId: provider.GetId(), + }) + require.NoError(t, err) + + type args struct { + ctx context.Context + req *user.ListAuthenticationMethodTypesRequest + } + tests := []struct { + name string + args args + want *user.ListAuthenticationMethodTypesResponse + }{ + { + name: "no auth", + args: args{ + CTX, + &user.ListAuthenticationMethodTypesRequest{ + UserId: userIDWithoutAuth, + }, + }, + want: &user.ListAuthenticationMethodTypesResponse{ + Details: &object.ListDetails{ + TotalResult: 0, + }, + }, + }, + { + name: "with auth (passkey)", + args: args{ + CTX, + &user.ListAuthenticationMethodTypesRequest{ + UserId: userIDWithPasskey, + }, + }, + want: &user.ListAuthenticationMethodTypesResponse{ + Details: &object.ListDetails{ + TotalResult: 1, + }, + AuthMethodTypes: []user.AuthenticationMethodType{ + user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_PASSKEY, + }, + }, + }, + { + name: "multiple auth", + args: args{ + CTX, + &user.ListAuthenticationMethodTypesRequest{ + UserId: userMultipleAuth, + }, + }, + want: &user.ListAuthenticationMethodTypesResponse{ + Details: &object.ListDetails{ + TotalResult: 2, + }, + AuthMethodTypes: []user.AuthenticationMethodType{ + user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_PASSKEY, + user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_IDP, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got *user.ListAuthenticationMethodTypesResponse + var err error + + for { + got, err = Client.ListAuthenticationMethodTypes(tt.args.ctx, tt.args.req) + if err == nil && !got.GetDetails().GetTimestamp().AsTime().Before(idpLink.GetDetails().GetChangeDate().AsTime()) { + break + } + select { + case <-CTX.Done(): + t.Fatal(CTX.Err(), err) + case <-time.After(time.Second): + t.Log("retrying ListAuthenticationMethodTypes") + continue + } + } + require.NoError(t, err) + assert.Equal(t, tt.want.GetDetails().GetTotalResult(), got.GetDetails().GetTotalResult()) + require.Equal(t, tt.want.GetAuthMethodTypes(), got.GetAuthMethodTypes()) + }) + } +} diff --git a/internal/api/grpc/user/v2beta/user_test.go b/internal/api/grpc/user/v2beta/user_test.go new file mode 100644 index 0000000000..9e398e83ff --- /dev/null +++ b/internal/api/grpc/user/v2beta/user_test.go @@ -0,0 +1,410 @@ +package user + +import ( + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/grpc" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/zerrors" + object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" +) + +func Test_idpIntentToIDPIntentPb(t *testing.T) { + decryption := func(err error) crypto.EncryptionAlgorithm { + mCrypto := crypto.NewMockEncryptionAlgorithm(gomock.NewController(t)) + mCrypto.EXPECT().Algorithm().Return("enc") + mCrypto.EXPECT().DecryptionKeyIDs().Return([]string{"id"}) + mCrypto.EXPECT().DecryptString(gomock.Any(), gomock.Any()).DoAndReturn( + func(code []byte, keyID string) (string, error) { + if err != nil { + return "", err + } + return string(code), nil + }) + return mCrypto + } + + type args struct { + intent *command.IDPIntentWriteModel + alg crypto.EncryptionAlgorithm + } + type res struct { + resp *user.RetrieveIdentityProviderIntentResponse + err error + } + tests := []struct { + name string + args args + res res + }{ + { + "decryption invalid key id error", + args{ + intent: &command.IDPIntentWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: "intentID", + ProcessedSequence: 123, + ResourceOwner: "ro", + InstanceID: "instanceID", + ChangeDate: time.Date(2019, 4, 1, 1, 1, 1, 1, time.Local), + }, + IDPID: "idpID", + IDPUser: []byte(`{"userID": "idpUserID", "username": "username"}`), + IDPUserID: "idpUserID", + IDPUserName: "username", + IDPAccessToken: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("accessToken"), + }, + IDPIDToken: "idToken", + IDPEntryAttributes: map[string][]string{}, + UserID: "userID", + State: domain.IDPIntentStateSucceeded, + }, + alg: decryption(zerrors.ThrowInternal(nil, "id", "invalid key id")), + }, + res{ + resp: nil, + err: zerrors.ThrowInternal(nil, "id", "invalid key id"), + }, + }, { + "successful oauth", + args{ + intent: &command.IDPIntentWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: "intentID", + ProcessedSequence: 123, + ResourceOwner: "ro", + InstanceID: "instanceID", + ChangeDate: time.Date(2019, 4, 1, 1, 1, 1, 1, time.Local), + }, + IDPID: "idpID", + IDPUser: []byte(`{"userID": "idpUserID", "username": "username"}`), + IDPUserID: "idpUserID", + IDPUserName: "username", + IDPAccessToken: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("accessToken"), + }, + IDPIDToken: "idToken", + UserID: "", + State: domain.IDPIntentStateSucceeded, + }, + alg: decryption(nil), + }, + res{ + resp: &user.RetrieveIdentityProviderIntentResponse{ + Details: &object_pb.Details{ + Sequence: 123, + ChangeDate: timestamppb.New(time.Date(2019, 4, 1, 1, 1, 1, 1, time.Local)), + ResourceOwner: "ro", + }, + IdpInformation: &user.IDPInformation{ + Access: &user.IDPInformation_Oauth{ + Oauth: &user.IDPOAuthAccessInformation{ + AccessToken: "accessToken", + IdToken: gu.Ptr("idToken"), + }, + }, + IdpId: "idpID", + UserId: "idpUserID", + UserName: "username", + RawInformation: func() *structpb.Struct { + s, err := structpb.NewStruct(map[string]interface{}{ + "userID": "idpUserID", + "username": "username", + }) + require.NoError(t, err) + return s + }(), + }, + }, + err: nil, + }, + }, + { + "successful oauth with linked user", + args{ + intent: &command.IDPIntentWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: "intentID", + ProcessedSequence: 123, + ResourceOwner: "ro", + InstanceID: "instanceID", + ChangeDate: time.Date(2019, 4, 1, 1, 1, 1, 1, time.Local), + }, + IDPID: "idpID", + IDPUser: []byte(`{"userID": "idpUserID", "username": "username"}`), + IDPUserID: "idpUserID", + IDPUserName: "username", + IDPAccessToken: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("accessToken"), + }, + IDPIDToken: "idToken", + UserID: "userID", + State: domain.IDPIntentStateSucceeded, + }, + alg: decryption(nil), + }, + res{ + resp: &user.RetrieveIdentityProviderIntentResponse{ + Details: &object_pb.Details{ + Sequence: 123, + ChangeDate: timestamppb.New(time.Date(2019, 4, 1, 1, 1, 1, 1, time.Local)), + ResourceOwner: "ro", + }, + IdpInformation: &user.IDPInformation{ + Access: &user.IDPInformation_Oauth{ + Oauth: &user.IDPOAuthAccessInformation{ + AccessToken: "accessToken", + IdToken: gu.Ptr("idToken"), + }, + }, + IdpId: "idpID", + UserId: "idpUserID", + UserName: "username", + RawInformation: func() *structpb.Struct { + s, err := structpb.NewStruct(map[string]interface{}{ + "userID": "idpUserID", + "username": "username", + }) + require.NoError(t, err) + return s + }(), + }, + UserId: "userID", + }, + err: nil, + }, + }, { + "successful ldap", + args{ + intent: &command.IDPIntentWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: "intentID", + ProcessedSequence: 123, + ResourceOwner: "ro", + InstanceID: "instanceID", + ChangeDate: time.Date(2019, 4, 1, 1, 1, 1, 1, time.Local), + }, + IDPID: "idpID", + IDPUser: []byte(`{"userID": "idpUserID", "username": "username"}`), + IDPUserID: "idpUserID", + IDPUserName: "username", + IDPEntryAttributes: map[string][]string{ + "id": {"idpUserID"}, + "firstName": {"firstname1", "firstname2"}, + "lastName": {"lastname"}, + }, + UserID: "", + State: domain.IDPIntentStateSucceeded, + }, + }, + res{ + resp: &user.RetrieveIdentityProviderIntentResponse{ + Details: &object_pb.Details{ + Sequence: 123, + ChangeDate: timestamppb.New(time.Date(2019, 4, 1, 1, 1, 1, 1, time.Local)), + ResourceOwner: "ro", + }, + IdpInformation: &user.IDPInformation{ + Access: &user.IDPInformation_Ldap{ + Ldap: &user.IDPLDAPAccessInformation{ + Attributes: func() *structpb.Struct { + s, err := structpb.NewStruct(map[string]interface{}{ + "id": []interface{}{"idpUserID"}, + "firstName": []interface{}{"firstname1", "firstname2"}, + "lastName": []interface{}{"lastname"}, + }) + require.NoError(t, err) + return s + }(), + }, + }, + IdpId: "idpID", + UserId: "idpUserID", + UserName: "username", + RawInformation: func() *structpb.Struct { + s, err := structpb.NewStruct(map[string]interface{}{ + "userID": "idpUserID", + "username": "username", + }) + require.NoError(t, err) + return s + }(), + }, + }, + err: nil, + }, + }, { + "successful ldap with linked user", + args{ + intent: &command.IDPIntentWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: "intentID", + ProcessedSequence: 123, + ResourceOwner: "ro", + InstanceID: "instanceID", + ChangeDate: time.Date(2019, 4, 1, 1, 1, 1, 1, time.Local), + }, + IDPID: "idpID", + IDPUser: []byte(`{"userID": "idpUserID", "username": "username"}`), + IDPUserID: "idpUserID", + IDPUserName: "username", + IDPEntryAttributes: map[string][]string{ + "id": {"idpUserID"}, + "firstName": {"firstname1", "firstname2"}, + "lastName": {"lastname"}, + }, + UserID: "userID", + State: domain.IDPIntentStateSucceeded, + }, + }, + res{ + resp: &user.RetrieveIdentityProviderIntentResponse{ + Details: &object_pb.Details{ + Sequence: 123, + ChangeDate: timestamppb.New(time.Date(2019, 4, 1, 1, 1, 1, 1, time.Local)), + ResourceOwner: "ro", + }, + IdpInformation: &user.IDPInformation{ + Access: &user.IDPInformation_Ldap{ + Ldap: &user.IDPLDAPAccessInformation{ + Attributes: func() *structpb.Struct { + s, err := structpb.NewStruct(map[string]interface{}{ + "id": []interface{}{"idpUserID"}, + "firstName": []interface{}{"firstname1", "firstname2"}, + "lastName": []interface{}{"lastname"}, + }) + require.NoError(t, err) + return s + }(), + }, + }, + IdpId: "idpID", + UserId: "idpUserID", + UserName: "username", + RawInformation: func() *structpb.Struct { + s, err := structpb.NewStruct(map[string]interface{}{ + "userID": "idpUserID", + "username": "username", + }) + require.NoError(t, err) + return s + }(), + }, + UserId: "userID", + }, + err: nil, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := idpIntentToIDPIntentPb(tt.args.intent, tt.args.alg) + require.ErrorIs(t, err, tt.res.err) + grpc.AllFieldsEqual(t, tt.res.resp.ProtoReflect(), got.ProtoReflect(), grpc.CustomMappers) + }) + } +} + +func Test_authMethodTypesToPb(t *testing.T) { + tests := []struct { + name string + methodTypes []domain.UserAuthMethodType + want []user.AuthenticationMethodType + }{ + { + "empty list", + nil, + []user.AuthenticationMethodType{}, + }, + { + "list", + []domain.UserAuthMethodType{ + domain.UserAuthMethodTypePasswordless, + }, + []user.AuthenticationMethodType{ + user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_PASSKEY, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equalf(t, tt.want, authMethodTypesToPb(tt.methodTypes), "authMethodTypesToPb(%v)", tt.methodTypes) + }) + } +} + +func Test_authMethodTypeToPb(t *testing.T) { + tests := []struct { + name string + methodType domain.UserAuthMethodType + want user.AuthenticationMethodType + }{ + { + "uspecified", + domain.UserAuthMethodTypeUnspecified, + user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_UNSPECIFIED, + }, + { + "totp", + domain.UserAuthMethodTypeTOTP, + user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_TOTP, + }, + { + "u2f", + domain.UserAuthMethodTypeU2F, + user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_U2F, + }, + { + "passkey", + domain.UserAuthMethodTypePasswordless, + user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_PASSKEY, + }, + { + "password", + domain.UserAuthMethodTypePassword, + user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_PASSWORD, + }, + { + "idp", + domain.UserAuthMethodTypeIDP, + user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_IDP, + }, + { + "otp sms", + domain.UserAuthMethodTypeOTPSMS, + user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_OTP_SMS, + }, + { + "otp email", + domain.UserAuthMethodTypeOTPEmail, + user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_OTP_EMAIL, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equalf(t, tt.want, authMethodTypeToPb(tt.methodType), "authMethodTypeToPb(%v)", tt.methodType) + }) + } +} diff --git a/internal/api/http/domain_check.go b/internal/api/http/domain_check.go index 00ab0597dc..616c28cdfc 100644 --- a/internal/api/http/domain_check.go +++ b/internal/api/http/domain_check.go @@ -3,7 +3,7 @@ package http import ( errorsAs "errors" "fmt" - "io/ioutil" + "io" "net" "net/http" @@ -43,7 +43,7 @@ func ValidateDomainHTTP(domain, token, verifier string) error { return zerrors.ThrowInternal(err, "HTTP-G2zsw", "Errors.Internal") } defer resp.Body.Close() - body, err := ioutil.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) if err != nil { return zerrors.ThrowInternal(err, "HTTP-HB432", "Errors.Internal") } diff --git a/internal/api/http/marshal.go b/internal/api/http/marshal.go index 35fdda5f51..193196e07f 100644 --- a/internal/api/http/marshal.go +++ b/internal/api/http/marshal.go @@ -19,5 +19,5 @@ func MarshalJSON(w http.ResponseWriter, i interface{}, err error, statusCode int } w.Header().Set("content-type", "application/json") _, err = w.Write(b) - logging.Log("HTTP-sdgT2").OnError(err).Error("error writing response") + logging.WithFields("logID", "HTTP-sdgT2").OnError(err).Error("error writing response") } diff --git a/internal/api/idp/idp_integration_test.go b/internal/api/idp/idp_integration_test.go index 8fdc24539c..51d9bbfeea 100644 --- a/internal/api/idp/idp_integration_test.go +++ b/internal/api/idp/idp_integration_test.go @@ -27,7 +27,7 @@ import ( http_util "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/integration" - user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) var ( diff --git a/internal/api/oidc/auth_request_integration_test.go b/internal/api/oidc/auth_request_integration_test.go index 630b20bc09..faa5126676 100644 --- a/internal/api/oidc/auth_request_integration_test.go +++ b/internal/api/oidc/auth_request_integration_test.go @@ -18,8 +18,8 @@ import ( oidc_api "github.com/zitadel/zitadel/internal/api/oidc" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/integration" - oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta" - session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta" + oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2" + "github.com/zitadel/zitadel/pkg/grpc/session/v2" ) var ( diff --git a/internal/api/oidc/client_integration_test.go b/internal/api/oidc/client_integration_test.go index 65cc9309d5..9ff0b104d9 100644 --- a/internal/api/oidc/client_integration_test.go +++ b/internal/api/oidc/client_integration_test.go @@ -20,7 +20,7 @@ import ( "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/pkg/grpc/authn" "github.com/zitadel/zitadel/pkg/grpc/management" - oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta" + oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2" ) func TestServer_Introspect(t *testing.T) { diff --git a/internal/api/oidc/oidc_integration_test.go b/internal/api/oidc/oidc_integration_test.go index 0baeb53363..1f7f615809 100644 --- a/internal/api/oidc/oidc_integration_test.go +++ b/internal/api/oidc/oidc_integration_test.go @@ -19,9 +19,9 @@ import ( "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/pkg/grpc/auth" mgmt "github.com/zitadel/zitadel/pkg/grpc/management" - oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta" - session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta" - user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" + oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2" + "github.com/zitadel/zitadel/pkg/grpc/session/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) var ( diff --git a/internal/api/oidc/token_exchange_integration_test.go b/internal/api/oidc/token_exchange_integration_test.go index 2636b85d86..b8b4b0bbfe 100644 --- a/internal/api/oidc/token_exchange_integration_test.go +++ b/internal/api/oidc/token_exchange_integration_test.go @@ -22,7 +22,7 @@ import ( oidc_api "github.com/zitadel/zitadel/internal/api/oidc" "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/pkg/grpc/admin" - feature "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/feature/v2" ) func setTokenExchangeFeature(t *testing.T, value bool) { diff --git a/internal/api/oidc/userinfo_integration_test.go b/internal/api/oidc/userinfo_integration_test.go index 350b2267d3..ad95defd6f 100644 --- a/internal/api/oidc/userinfo_integration_test.go +++ b/internal/api/oidc/userinfo_integration_test.go @@ -18,9 +18,9 @@ import ( oidc_api "github.com/zitadel/zitadel/internal/api/oidc" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/integration" - feature "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/feature/v2" "github.com/zitadel/zitadel/pkg/grpc/management" - oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta" + oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2" ) // TestServer_UserInfo is a top-level test which re-executes the actual diff --git a/internal/command/org_idp_config_test.go b/internal/command/org_idp_config_test.go index 2897997695..eb38ca962c 100644 --- a/internal/command/org_idp_config_test.go +++ b/internal/command/org_idp_config_test.go @@ -398,7 +398,8 @@ func newIDPConfigChangedEvent(ctx context.Context, orgID, configID, oldName, new func TestCommands_RemoveIDPConfig(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore *eventstore.Eventstore + checkPermission domain.PermissionCheck } type args struct { ctx context.Context @@ -423,6 +424,7 @@ func TestCommands_RemoveIDPConfig(t *testing.T) { eventstore: eventstoreExpect(t, expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), }, args{ context.Background(), @@ -460,6 +462,7 @@ func TestCommands_RemoveIDPConfig(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args{ context.Background(), @@ -532,6 +535,84 @@ func TestCommands_RemoveIDPConfig(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args{ + context.Background(), + "idp1", + "org1", + true, + []*domain.UserIDPLink{ + { + ObjectRoot: models.ObjectRoot{ + AggregateID: "user1", + }, + IDPConfigID: "idp1", + ExternalUserID: "id1", + DisplayName: "name", + }, + }, + }, + res{ + &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + nil, + }, + }, + { + "cascade, permission error", + fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + org.NewIDPConfigAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "idp1", + "name1", + domain.IDPConfigTypeOIDC, + domain.IDPConfigStylingTypeGoogle, + false, + ), + ), + ), + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayName", + language.German, + domain.GenderUnspecified, + "email@test.com", + true, + ), + ), + eventFromEventPusher( + user.NewUserIDPLinkAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "idp1", + "name", + "id1", + ), + ), + ), + expectPush( + org.NewIDPConfigRemovedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "idp1", + "name1", + ), + org.NewIdentityProviderCascadeRemovedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "idp1", + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), }, args{ context.Background(), @@ -560,7 +641,8 @@ func TestCommands_RemoveIDPConfig(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore, + checkPermission: tt.fields.checkPermission, } got, err := c.RemoveIDPConfig(tt.args.ctx, tt.args.idpID, tt.args.orgID, tt.args.cascadeRemoveProvider, tt.args.cascadeExternalIDPs...) if tt.res.err == nil { diff --git a/internal/command/user_human_webauthn.go b/internal/command/user_human_webauthn.go index 6988b0052a..3555466359 100644 --- a/internal/command/user_human_webauthn.go +++ b/internal/command/user_human_webauthn.go @@ -603,6 +603,11 @@ func (c *Commands) removeHumanWebAuthN(ctx context.Context, userID, webAuthNID, if existingWebAuthN.State == domain.MFAStateUnspecified || existingWebAuthN.State == domain.MFAStateRemoved { return nil, zerrors.ThrowNotFound(nil, "COMMAND-DAfb2", "Errors.User.WebAuthN.NotFound") } + if userID != authz.GetCtxData(ctx).UserID { + if err := c.checkPermission(ctx, domain.PermissionUserWrite, existingWebAuthN.ResourceOwner, existingWebAuthN.AggregateID); err != nil { + return nil, err + } + } userAgg := UserAggregateFromWriteModel(&existingWebAuthN.WriteModel) pushedEvents, err := c.eventstore.Push(ctx, preparedEvent(userAgg)) diff --git a/internal/command/user_idp_link.go b/internal/command/user_idp_link.go index 14f05964a1..761cebb7d2 100644 --- a/internal/command/user_idp_link.go +++ b/internal/command/user_idp_link.go @@ -126,6 +126,11 @@ func (c *Commands) removeUserIDPLink(ctx context.Context, link *domain.UserIDPLi if existingLink.State == domain.UserIDPLinkStateUnspecified || existingLink.State == domain.UserIDPLinkStateRemoved { return nil, nil, zerrors.ThrowNotFound(nil, "COMMAND-1M9xR", "Errors.User.ExternalIDP.NotFound") } + if existingLink.AggregateID != authz.GetCtxData(ctx).UserID { + if err := c.checkPermission(ctx, domain.PermissionUserWrite, existingLink.ResourceOwner, existingLink.AggregateID); err != nil { + return nil, nil, err + } + } userAgg := UserAggregateFromWriteModel(&existingLink.WriteModel) if cascade { return user.NewUserIDPLinkCascadeRemovedEvent(ctx, userAgg, link.IDPConfigID, link.ExternalUserID), existingLink, nil diff --git a/internal/command/user_idp_link_test.go b/internal/command/user_idp_link_test.go index f1f7929686..67d1c005ef 100644 --- a/internal/command/user_idp_link_test.go +++ b/internal/command/user_idp_link_test.go @@ -519,7 +519,8 @@ func TestCommandSide_BulkAddUserIDPLinks(t *testing.T) { func TestCommandSide_RemoveUserIDPLink(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore *eventstore.Eventstore + checkPermission domain.PermissionCheck } type args struct { ctx context.Context @@ -541,6 +542,7 @@ func TestCommandSide_RemoveUserIDPLink(t *testing.T) { eventstore: eventstoreExpect( t, ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -562,6 +564,7 @@ func TestCommandSide_RemoveUserIDPLink(t *testing.T) { eventstore: eventstoreExpect( t, ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -598,6 +601,7 @@ func TestCommandSide_RemoveUserIDPLink(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -620,6 +624,7 @@ func TestCommandSide_RemoveUserIDPLink(t *testing.T) { t, expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -635,6 +640,38 @@ func TestCommandSide_RemoveUserIDPLink(t *testing.T) { err: zerrors.IsNotFound, }, }, + { + name: "remove external idp, permission error", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + user.NewUserIDPLinkAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "config1", + "name", + "externaluser1", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + link: &domain.UserIDPLink{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "user1", + }, + IDPConfigID: "config1", + ExternalUserID: "externaluser1", + }, + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, { name: "remove external idp, ok", fields: fields{ @@ -658,6 +695,7 @@ func TestCommandSide_RemoveUserIDPLink(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -679,7 +717,8 @@ func TestCommandSide_RemoveUserIDPLink(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore, + checkPermission: tt.fields.checkPermission, } got, err := r.RemoveUserIDPLink(tt.args.ctx, tt.args.link) if tt.res.err == nil { diff --git a/internal/config/config.go b/internal/config/config.go index b454cea41f..a0e7eceb3b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,7 +2,6 @@ package config import ( "encoding/json" - "io/ioutil" "os" "path/filepath" @@ -50,7 +49,7 @@ func Read(obj interface{}, configFiles ...string) error { func readConfigFile(readerFunc ReaderFunc, configFile string, obj interface{}) error { configFile = os.ExpandEnv(configFile) - configStr, err := ioutil.ReadFile(configFile) + configStr, err := os.ReadFile(configFile) if err != nil { return zerrors.ThrowInternalf(err, "CONFI-nJk2a", "failed to read config file %s", configFile) } diff --git a/internal/integration/assert.go b/internal/integration/assert.go index 610c48cf31..6928054e8e 100644 --- a/internal/integration/assert.go +++ b/internal/integration/assert.go @@ -9,8 +9,6 @@ import ( "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" - - object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" ) // Details is the interface that covers both v1 and v2 proto generated object details. @@ -26,8 +24,14 @@ type DetailsMsg[D Details] interface { GetDetails() D } -type ListDetailsMsg interface { - GetDetails() *object.ListDetails +type ListDetails interface { + comparable + GetTotalResult() uint64 + GetTimestamp() *timestamppb.Timestamp +} + +type ListDetailsMsg[L ListDetails] interface { + GetDetails() L } // AssertDetails asserts values in a message's object Details, @@ -59,13 +63,13 @@ func AssertDetails[D Details, M DetailsMsg[D]](t testing.TB, expected, actual M) assert.Equal(t, wantDetails.GetResourceOwner(), gotDetails.GetResourceOwner()) } -func AssertListDetails[D ListDetailsMsg](t testing.TB, expected, actual D) { +func AssertListDetails[L ListDetails, D ListDetailsMsg[L]](t testing.TB, expected, actual D) { wantDetails, gotDetails := expected.GetDetails(), actual.GetDetails() - if wantDetails == nil { + var nilDetails L + if wantDetails == nilDetails { assert.Nil(t, gotDetails) return } - assert.Equal(t, wantDetails.GetTotalResult(), gotDetails.GetTotalResult()) if wantDetails.GetTimestamp() != nil { diff --git a/internal/integration/client.go b/internal/integration/client.go index bd7c8eb400..7cb3af4c7b 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -28,51 +28,69 @@ import ( action "github.com/zitadel/zitadel/pkg/grpc/action/v3alpha" "github.com/zitadel/zitadel/pkg/grpc/admin" "github.com/zitadel/zitadel/pkg/grpc/auth" - feature "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/feature/v2" + feature_v2beta "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta" mgmt "github.com/zitadel/zitadel/pkg/grpc/management" - object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" - oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta" - org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" - organisation "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" - session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta" - settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/object/v2" + oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2" + oidc_pb_v2beta "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/org/v2" + organisation "github.com/zitadel/zitadel/pkg/grpc/org/v2" + org_v2beta "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/session/v2" + session_v2beta "github.com/zitadel/zitadel/pkg/grpc/session/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/settings/v2" + settings_v2beta "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta" "github.com/zitadel/zitadel/pkg/grpc/system" user_pb "github.com/zitadel/zitadel/pkg/grpc/user" schema "github.com/zitadel/zitadel/pkg/grpc/user/schema/v3alpha" - user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" + user_v2beta "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" ) type Client struct { - CC *grpc.ClientConn - Admin admin.AdminServiceClient - Mgmt mgmt.ManagementServiceClient - Auth auth.AuthServiceClient - UserV2 user.UserServiceClient - SessionV2 session.SessionServiceClient - SettingsV2 settings.SettingsServiceClient - OIDCv2 oidc_pb.OIDCServiceClient - OrgV2 organisation.OrganizationServiceClient - System system.SystemServiceClient - ActionV3 action.ActionServiceClient - FeatureV2 feature.FeatureServiceClient - UserSchemaV3 schema.UserSchemaServiceClient + CC *grpc.ClientConn + Admin admin.AdminServiceClient + Mgmt mgmt.ManagementServiceClient + Auth auth.AuthServiceClient + UserV2beta user_v2beta.UserServiceClient + UserV2 user.UserServiceClient + SessionV2beta session_v2beta.SessionServiceClient + SessionV2 session.SessionServiceClient + SettingsV2beta settings_v2beta.SettingsServiceClient + SettingsV2 settings.SettingsServiceClient + OIDCv2beta oidc_pb_v2beta.OIDCServiceClient + OIDCv2 oidc_pb.OIDCServiceClient + OrgV2beta org_v2beta.OrganizationServiceClient + OrgV2 organisation.OrganizationServiceClient + System system.SystemServiceClient + ActionV3 action.ActionServiceClient + FeatureV2beta feature_v2beta.FeatureServiceClient + FeatureV2 feature.FeatureServiceClient + UserSchemaV3 schema.UserSchemaServiceClient } func newClient(cc *grpc.ClientConn) Client { return Client{ - CC: cc, - Admin: admin.NewAdminServiceClient(cc), - Mgmt: mgmt.NewManagementServiceClient(cc), - Auth: auth.NewAuthServiceClient(cc), - UserV2: user.NewUserServiceClient(cc), - SessionV2: session.NewSessionServiceClient(cc), - SettingsV2: settings.NewSettingsServiceClient(cc), - OIDCv2: oidc_pb.NewOIDCServiceClient(cc), - OrgV2: organisation.NewOrganizationServiceClient(cc), - System: system.NewSystemServiceClient(cc), - ActionV3: action.NewActionServiceClient(cc), - FeatureV2: feature.NewFeatureServiceClient(cc), - UserSchemaV3: schema.NewUserSchemaServiceClient(cc), + CC: cc, + Admin: admin.NewAdminServiceClient(cc), + Mgmt: mgmt.NewManagementServiceClient(cc), + Auth: auth.NewAuthServiceClient(cc), + UserV2beta: user_v2beta.NewUserServiceClient(cc), + UserV2: user.NewUserServiceClient(cc), + SessionV2beta: session_v2beta.NewSessionServiceClient(cc), + SessionV2: session.NewSessionServiceClient(cc), + SettingsV2beta: settings_v2beta.NewSettingsServiceClient(cc), + SettingsV2: settings.NewSettingsServiceClient(cc), + OIDCv2beta: oidc_pb_v2beta.NewOIDCServiceClient(cc), + OIDCv2: oidc_pb.NewOIDCServiceClient(cc), + OrgV2beta: org_v2beta.NewOrganizationServiceClient(cc), + OrgV2: organisation.NewOrganizationServiceClient(cc), + System: system.NewSystemServiceClient(cc), + ActionV3: action.NewActionServiceClient(cc), + FeatureV2beta: feature_v2beta.NewFeatureServiceClient(cc), + FeatureV2: feature.NewFeatureServiceClient(cc), + UserSchemaV3: schema.NewUserSchemaServiceClient(cc), } } diff --git a/internal/notification/channels/log/channel.go b/internal/notification/channels/log/channel.go index ecdb59c642..42efbbe842 100644 --- a/internal/notification/channels/log/channel.go +++ b/internal/notification/channels/log/channel.go @@ -11,7 +11,7 @@ import ( func InitStdoutChannel(config Config) channels.NotificationChannel { - logging.Log("NOTIF-D0164").Debug("successfully initialized stdout email and sms channel") + logging.WithFields("logID", "NOTIF-D0164").Debug("successfully initialized stdout email and sms channel") return channels.HandleMessageFunc(func(message channels.Message) error { @@ -23,7 +23,7 @@ func InitStdoutChannel(config Config) channels.NotificationChannel { content = html2text.HTML2Text(content) } - logging.Log("NOTIF-c73ba").WithFields(map[string]interface{}{ + logging.WithFields("logID", "NOTIF-c73ba").WithFields(map[string]interface{}{ "type": fmt.Sprintf("%T", message), "content": content, }).Info("handling notification message") diff --git a/internal/notification/handlers/telemetry_pusher_integration_test.go b/internal/notification/handlers/telemetry_pusher_integration_test.go index b84f02fa79..8f207b9de3 100644 --- a/internal/notification/handlers/telemetry_pusher_integration_test.go +++ b/internal/notification/handlers/telemetry_pusher_integration_test.go @@ -19,7 +19,7 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/app" "github.com/zitadel/zitadel/pkg/grpc/management" "github.com/zitadel/zitadel/pkg/grpc/object" - oidc_v2 "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta" + oidc_v2 "github.com/zitadel/zitadel/pkg/grpc/oidc/v2" "github.com/zitadel/zitadel/pkg/grpc/project" "github.com/zitadel/zitadel/pkg/grpc/system" ) diff --git a/internal/query/idp_user_link.go b/internal/query/idp_user_link.go index 50e780c372..4f13d9d315 100644 --- a/internal/query/idp_user_link.go +++ b/internal/query/idp_user_link.go @@ -89,6 +89,29 @@ var ( } ) +func (l *IDPUserLinks) RemoveNoPermission(ctx context.Context, permissionCheck domain.PermissionCheck) { + removableIndexes := make([]int, 0) + for i := range l.Links { + ctxData := authz.GetCtxData(ctx) + if ctxData.UserID != l.Links[i].UserID { + if err := permissionCheck(ctx, domain.PermissionUserRead, l.Links[i].ResourceOwner, l.Links[i].UserID); err != nil { + removableIndexes = append(removableIndexes, i) + } + } + } + removed := 0 + for _, removeIndex := range removableIndexes { + l.Links = removeIDPLink(l.Links, removeIndex-removed) + removed++ + } + // reset count as some users could be removed + l.SearchResponse.Count = uint64(len(l.Links)) +} + +func removeIDPLink(slice []*IDPUserLink, s int) []*IDPUserLink { + return append(slice[:s], slice[s+1:]...) +} + func (q *Queries) IDPUserLinks(ctx context.Context, queries *IDPUserLinksSearchQuery, withOwnerRemoved bool) (idps *IDPUserLinks, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() diff --git a/internal/query/user_auth_method.go b/internal/query/user_auth_method.go index 243c465a84..6a93f069b0 100644 --- a/internal/query/user_auth_method.go +++ b/internal/query/user_auth_method.go @@ -98,6 +98,29 @@ type AuthMethods struct { AuthMethods []*AuthMethod } +func (l *AuthMethods) RemoveNoPermission(ctx context.Context, permissionCheck domain.PermissionCheck) { + removableIndexes := make([]int, 0) + for i := range l.AuthMethods { + ctxData := authz.GetCtxData(ctx) + if ctxData.UserID != l.AuthMethods[i].UserID { + if err := permissionCheck(ctx, domain.PermissionUserRead, l.AuthMethods[i].ResourceOwner, l.AuthMethods[i].UserID); err != nil { + removableIndexes = append(removableIndexes, i) + } + } + } + removed := 0 + for _, removeIndex := range removableIndexes { + l.AuthMethods = removeAuthMethod(l.AuthMethods, removeIndex-removed) + removed++ + } + // reset count as some users could be removed + l.SearchResponse.Count = uint64(len(l.AuthMethods)) +} + +func removeAuthMethod(slice []*AuthMethod, s int) []*AuthMethod { + return append(slice[:s], slice[s+1:]...) +} + type AuthMethod struct { UserID string CreationDate time.Time diff --git a/internal/repository/user/machine_key.go b/internal/repository/user/machine_key.go index aff1c3750e..b532616883 100644 --- a/internal/repository/user/machine_key.go +++ b/internal/repository/user/machine_key.go @@ -60,8 +60,9 @@ func MachineKeyAddedEventMapper(event eventstore.Event) (eventstore.Event, error } err := event.Unmarshal(machineKeyAdded) if err != nil { - //first events had wrong payload. + // first events had wrong payload. // the keys were removed later, that's why we ignore them here. + //nolint:errorlint if unwrapErr, ok := err.(*json.UnmarshalTypeError); ok && unwrapErr.Field == "publicKey" { return machineKeyAdded, nil } diff --git a/internal/telemetry/tracing/caller.go b/internal/telemetry/tracing/caller.go index da9cc2940b..a7612cab8d 100644 --- a/internal/telemetry/tracing/caller.go +++ b/internal/telemetry/tracing/caller.go @@ -10,12 +10,12 @@ func GetCaller() string { fpcs := make([]uintptr, 1) n := runtime.Callers(3, fpcs) if n == 0 { - logging.Log("TRACE-rWjfC").Debug("no caller") + logging.WithFields("logID", "TRACE-rWjfC").Debug("no caller") return "" } caller := runtime.FuncForPC(fpcs[0] - 1) if caller == nil { - logging.Log("TRACE-25POw").Debug("caller was nil") + logging.WithFields("logID", "TRACE-25POw").Debug("caller was nil") return "" } return caller.Name() diff --git a/pkg/grpc/user/v2/user.go b/pkg/grpc/user/v2/user.go new file mode 100644 index 0000000000..ec9245c8eb --- /dev/null +++ b/pkg/grpc/user/v2/user.go @@ -0,0 +1,3 @@ +package user + +type UserType = isUser_Type diff --git a/proto/zitadel/action/v3alpha/action_service.proto b/proto/zitadel/action/v3alpha/action_service.proto index da174b45d0..938c9e88fc 100644 --- a/proto/zitadel/action/v3alpha/action_service.proto +++ b/proto/zitadel/action/v3alpha/action_service.proto @@ -11,7 +11,7 @@ import "validate/validate.proto"; import "zitadel/action/v3alpha/target.proto"; import "zitadel/action/v3alpha/execution.proto"; import "zitadel/action/v3alpha/query.proto"; -import "zitadel/object/v2beta/object.proto"; +import "zitadel/object/v2/object.proto"; import "zitadel/protoc_gen_zitadel/v2/options.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/action/v3alpha;action"; @@ -449,7 +449,7 @@ message CreateTargetResponse { // ID is the read-only unique identifier of the target. string id = 1; // Details provide some base information (such as the last change date) of the target. - zitadel.object.v2beta.Details details = 2; + zitadel.object.v2.Details details = 2; } message UpdateTargetRequest { @@ -498,7 +498,7 @@ message UpdateTargetRequest { message UpdateTargetResponse { // Details provide some base information (such as the last change date) of the target. - zitadel.object.v2beta.Details details = 1; + zitadel.object.v2.Details details = 1; } message DeleteTargetRequest { @@ -516,12 +516,12 @@ message DeleteTargetRequest { message DeleteTargetResponse { // Details provide some base information (such as the last change date) of the target. - zitadel.object.v2beta.Details details = 1; + zitadel.object.v2.Details details = 1; } message ListTargetsRequest { // list limitations and ordering. - zitadel.object.v2beta.ListQuery query = 1; + zitadel.object.v2.ListQuery query = 1; // the field the result is sorted. zitadel.action.v3alpha.TargetFieldName sorting_column = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -534,7 +534,7 @@ message ListTargetsRequest { message ListTargetsResponse { // Details provides information about the returned result including total amount found. - zitadel.object.v2beta.ListDetails details = 1; + zitadel.object.v2.ListDetails details = 1; // States by which field the results are sorted. zitadel.action.v3alpha.TargetFieldName sorting_column = 2; // The result contains the user schemas, which matched the queries. @@ -567,7 +567,7 @@ message SetExecutionRequest { message SetExecutionResponse { // Details provide some base information (such as the last change date) of the execution. - zitadel.object.v2beta.Details details = 2; + zitadel.object.v2.Details details = 2; } message DeleteExecutionRequest { @@ -577,19 +577,19 @@ message DeleteExecutionRequest { message DeleteExecutionResponse { // Details provide some base information (such as the last change date) of the execution. - zitadel.object.v2beta.Details details = 1; + zitadel.object.v2.Details details = 1; } message ListExecutionsRequest { // list limitations and ordering. - zitadel.object.v2beta.ListQuery query = 1; + zitadel.object.v2.ListQuery query = 1; // Define the criteria to query for. repeated zitadel.action.v3alpha.SearchQuery queries = 2; } message ListExecutionsResponse { // Details provides information about the returned result including total amount found. - zitadel.object.v2beta.ListDetails details = 1; + zitadel.object.v2.ListDetails details = 1; // The result contains the executions, which matched the queries. repeated zitadel.action.v3alpha.Execution result = 2; } diff --git a/proto/zitadel/action/v3alpha/execution.proto b/proto/zitadel/action/v3alpha/execution.proto index 6f24471185..797f997cd8 100644 --- a/proto/zitadel/action/v3alpha/execution.proto +++ b/proto/zitadel/action/v3alpha/execution.proto @@ -8,7 +8,7 @@ import "google/protobuf/duration.proto"; import "google/protobuf/struct.proto"; import "protoc-gen-openapiv2/options/annotations.proto"; import "validate/validate.proto"; -import "zitadel/object/v2beta/object.proto"; +import "zitadel/object/v2/object.proto"; import "zitadel/protoc_gen_zitadel/v2/options.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/action/v3alpha;action"; @@ -16,7 +16,7 @@ option go_package = "github.com/zitadel/zitadel/pkg/grpc/action/v3alpha;action"; message Execution { Condition Condition = 1; // Details provide some base information (such as the last change date) of the target. - zitadel.object.v2beta.Details details = 2; + zitadel.object.v2.Details details = 2; // List of ordered list of targets/includes called during the execution. repeated ExecutionTargetType targets = 3; } @@ -55,7 +55,7 @@ message RequestExecution { (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { min_length: 1, max_length: 1000, - example: "\"/zitadel.session.v2beta.SessionService/ListSessions\""; + example: "\"/zitadel.session.v2.SessionService/ListSessions\""; } ]; // GRPC-service as condition. @@ -64,7 +64,7 @@ message RequestExecution { (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { min_length: 1, max_length: 1000, - example: "\"zitadel.session.v2beta.SessionService\""; + example: "\"zitadel.session.v2.SessionService\""; } ]; // All calls to any available service and endpoint as condition. @@ -81,7 +81,7 @@ message ResponseExecution { (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { min_length: 1, max_length: 1000, - example: "\"/zitadel.session.v2beta.SessionService/ListSessions\""; + example: "\"/zitadel.session.v2.SessionService/ListSessions\""; } ]; // GRPC-service as condition. @@ -90,7 +90,7 @@ message ResponseExecution { (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { min_length: 1, max_length: 1000, - example: "\"zitadel.session.v2beta.SessionService\""; + example: "\"zitadel.session.v2.SessionService\""; } ]; // All calls to any available service and endpoint as condition. diff --git a/proto/zitadel/action/v3alpha/query.proto b/proto/zitadel/action/v3alpha/query.proto index a32caacfba..e32bda1d84 100644 --- a/proto/zitadel/action/v3alpha/query.proto +++ b/proto/zitadel/action/v3alpha/query.proto @@ -7,7 +7,7 @@ option go_package = "github.com/zitadel/zitadel/pkg/grpc/action/v3alpha;action"; import "google/api/field_behavior.proto"; import "protoc-gen-openapiv2/options/annotations.proto"; import "validate/validate.proto"; -import "zitadel/object/v2beta/object.proto"; +import "zitadel/object/v2/object.proto"; import "zitadel/action/v3alpha/execution.proto"; message SearchQuery { @@ -46,7 +46,7 @@ message IncludeQuery { Condition include = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { description: "the id of the include" - example: "\"request.zitadel.session.v2beta.SessionService\""; + example: "\"request.zitadel.session.v2.SessionService\""; } ]; } @@ -70,7 +70,7 @@ message TargetNameQuery { } ]; // Defines which text comparison method used for the name query. - zitadel.object.v2beta.TextQueryMethod method = 2 [ + zitadel.object.v2.TextQueryMethod method = 2 [ (validate.rules).enum.defined_only = true, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { description: "defines which text equality method is used"; diff --git a/proto/zitadel/action/v3alpha/target.proto b/proto/zitadel/action/v3alpha/target.proto index 92dda32bbb..bea5a4b756 100644 --- a/proto/zitadel/action/v3alpha/target.proto +++ b/proto/zitadel/action/v3alpha/target.proto @@ -8,7 +8,7 @@ import "google/protobuf/duration.proto"; import "google/protobuf/struct.proto"; import "protoc-gen-openapiv2/options/annotations.proto"; import "validate/validate.proto"; -import "zitadel/object/v2beta/object.proto"; +import "zitadel/object/v2/object.proto"; import "zitadel/protoc_gen_zitadel/v2/options.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/action/v3alpha;action"; @@ -36,7 +36,7 @@ message Target { } ]; // Details provide some base information (such as the last change date) of the target. - zitadel.object.v2beta.Details details = 2; + zitadel.object.v2.Details details = 2; // Unique name of the target. string name = 3 [ diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto index 64ad68c53a..e02e23da6b 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -1005,6 +1005,7 @@ service ManagementService { } // Deprecated: not used anymore in user state + // To resend a verification email use the user service v2 ResendEmailCode rpc ResendHumanInitialization(ResendHumanInitializationRequest) returns (ResendHumanInitializationResponse) { option (google.api.http) = { post: "/users/{user_id}/_resend_initialization" diff --git a/proto/zitadel/user/schema/v3alpha/user_schema.proto b/proto/zitadel/user/schema/v3alpha/user_schema.proto index c75d7d8194..e7a7a0737a 100644 --- a/proto/zitadel/user/schema/v3alpha/user_schema.proto +++ b/proto/zitadel/user/schema/v3alpha/user_schema.proto @@ -6,7 +6,7 @@ import "google/api/field_behavior.proto"; import "google/protobuf/struct.proto"; import "validate/validate.proto"; import "protoc-gen-openapiv2/options/annotations.proto"; -import "zitadel/object/v2beta/object.proto"; +import "zitadel/object/v2/object.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/user/schema/v3alpha"; @@ -19,7 +19,7 @@ message UserSchema { } ]; // Details provide some base information (such as the last change date) of the schema. - zitadel.object.v2beta.Details details = 2; + zitadel.object.v2.Details details = 2; // Type is a human readable text describing the schema. string type = 3 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -119,7 +119,7 @@ message IDQuery { } ]; // Defines which text comparison method used for the id query. - zitadel.object.v2beta.TextQueryMethod method = 2 [ + zitadel.object.v2.TextQueryMethod method = 2 [ (validate.rules).enum.defined_only = true ]; } @@ -135,7 +135,7 @@ message TypeQuery { } ]; // Defines which text comparison method used for the type query. - zitadel.object.v2beta.TextQueryMethod method = 2 [ + zitadel.object.v2.TextQueryMethod method = 2 [ (validate.rules).enum.defined_only = true ]; } diff --git a/proto/zitadel/user/schema/v3alpha/user_schema_service.proto b/proto/zitadel/user/schema/v3alpha/user_schema_service.proto index f9d2181dbf..14e59f1eab 100644 --- a/proto/zitadel/user/schema/v3alpha/user_schema_service.proto +++ b/proto/zitadel/user/schema/v3alpha/user_schema_service.proto @@ -8,7 +8,7 @@ import "google/protobuf/duration.proto"; import "google/protobuf/struct.proto"; import "protoc-gen-openapiv2/options/annotations.proto"; import "validate/validate.proto"; -import "zitadel/object/v2beta/object.proto"; +import "zitadel/object/v2/object.proto"; import "zitadel/protoc_gen_zitadel/v2/options.proto"; import "zitadel/user/schema/v3alpha/user_schema.proto"; @@ -299,7 +299,7 @@ service UserSchemaService { message ListUserSchemasRequest { // list limitations and ordering. - zitadel.object.v2beta.ListQuery query = 1; + zitadel.object.v2.ListQuery query = 1; // the field the result is sorted. zitadel.user.schema.v3alpha.FieldName sorting_column = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -312,7 +312,7 @@ message ListUserSchemasRequest { message ListUserSchemasResponse { // Details provides information about the returned result including total amount found. - zitadel.object.v2beta.ListDetails details = 1; + zitadel.object.v2.ListDetails details = 1; // States by which field the results are sorted. zitadel.user.schema.v3alpha.FieldName sorting_column = 2; // The result contains the user schemas, which matched the queries. @@ -376,7 +376,7 @@ message CreateUserSchemaResponse { // ID is the read-only unique identifier of the schema. string id = 1; // Details provide some base information (such as the last change date) of the schema. - zitadel.object.v2beta.Details details = 2; + zitadel.object.v2.Details details = 2; } @@ -416,7 +416,7 @@ message UpdateUserSchemaRequest { message UpdateUserSchemaResponse { // Details provide some base information (such as the last change date) of the schema. - zitadel.object.v2beta.Details details = 1; + zitadel.object.v2.Details details = 1; } message DeactivateUserSchemaRequest { @@ -426,7 +426,7 @@ message DeactivateUserSchemaRequest { message DeactivateUserSchemaResponse { // Details provide some base information (such as the last change date) of the schema. - zitadel.object.v2beta.Details details = 1; + zitadel.object.v2.Details details = 1; } message ReactivateUserSchemaRequest { @@ -436,7 +436,7 @@ message ReactivateUserSchemaRequest { message ReactivateUserSchemaResponse { // Details provide some base information (such as the last change date) of the schema. - zitadel.object.v2beta.Details details = 1; + zitadel.object.v2.Details details = 1; } message DeleteUserSchemaRequest { @@ -446,7 +446,7 @@ message DeleteUserSchemaRequest { message DeleteUserSchemaResponse { // Details provide some base information (such as the last change date) of the schema. - zitadel.object.v2beta.Details details = 1; + zitadel.object.v2.Details details = 1; } diff --git a/statik/doc.go b/statik/doc.go index bbcf4923da..6cbe78ef8d 100644 --- a/statik/doc.go +++ b/statik/doc.go @@ -1,2 +1,2 @@ -//Package statik is needed for building migration files into binary +// Package statik is needed for building migration files into binary package statik