From c2cb84cd24067394340e45fb6e8a11ca5dcef86f Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Fri, 5 May 2023 17:34:53 +0200 Subject: [PATCH] feat(api): new session service (#5801) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * backup new protoc plugin * backup * session * backup * initial implementation * change to specific events * implement tests * cleanup * refactor: use new protoc plugin for api v2 * change package * simplify code * cleanup * cleanup * fix merge * start queries * fix tests * improve returned values * add token to projection * tests * test db map * update query * permission checks * fix tests and linting * rework token creation * i18n * refactor token check and fix tests * session to PB test * request to query tests * cleanup proto * test user check * add comment * simplify database map type * Update docs/docs/guides/integrate/access-zitadel-system-api.md Co-authored-by: Tim Möhlmann * fix test * cleanup * docs --------- Co-authored-by: Tim Möhlmann --- cmd/setup/03.go | 1 + cmd/setup/config_change.go | 1 + cmd/start/start.go | 43 +- .../integrate/access-zitadel-system-api.md | 2 +- docs/docusaurus.config.js | 7 + docs/sidebars.js | 14 + go.mod | 2 +- internal/api/authz/authorization.go | 9 +- internal/api/authz/context.go | 11 +- internal/api/authz/permissions.go | 8 +- internal/api/authz/permissions_test.go | 4 +- internal/api/authz/token.go | 28 +- internal/api/grpc/object/v2/converter.go | 19 + .../server/middleware/auth_interceptor.go | 4 +- .../middleware/auth_interceptor_test.go | 4 +- internal/api/grpc/session/v2/server.go | 14 +- internal/api/grpc/session/v2/session.go | 256 +++++++- internal/api/grpc/session/v2/session_test.go | 379 ++++++++++++ internal/api/grpc/user/v2/user.go | 7 +- .../api/http/middleware/auth_interceptor.go | 2 +- internal/command/command.go | 31 +- internal/command/main_test.go | 16 +- internal/command/permission.go | 11 - internal/command/session.go | 225 +++++++ internal/command/session_model.go | 139 +++++ internal/command/session_test.go | 547 ++++++++++++++++++ internal/command/user_v2_email.go | 9 +- internal/command/user_v2_email_test.go | 10 +- internal/database/type.go | 31 +- internal/database/type_test.go | 119 ++++ internal/domain/permission.go | 11 + internal/domain/session.go | 9 + internal/id/mock/generator.mock.impl.go | 6 + internal/query/org.go | 14 +- internal/query/projection/projection.go | 3 + internal/query/projection/session.go | 221 +++++++ internal/query/projection/session_test.go | 260 +++++++++ internal/query/query.go | 17 +- internal/query/session.go | 320 ++++++++++ internal/query/sessions_test.go | 396 +++++++++++++ internal/repository/session/aggregate.go | 25 + internal/repository/session/eventstore.go | 12 + internal/repository/session/session.go | 255 ++++++++ internal/static/i18n/de.yaml | 5 + internal/static/i18n/en.yaml | 5 + internal/static/i18n/es.yaml | 5 + internal/static/i18n/fr.yaml | 5 + internal/static/i18n/it.yaml | 5 + internal/static/i18n/ja.yaml | 5 + internal/static/i18n/pl.yaml | 5 + internal/static/i18n/zh.yaml | 5 + pkg/grpc/user/v2alpha/user.go | 5 - proto/zitadel/object/v2alpha/object.proto | 44 ++ proto/zitadel/session/v2alpha/session.proto | 85 ++- .../session/v2alpha/session_service.proto | 341 ++++++++++- 55 files changed, 3911 insertions(+), 106 deletions(-) create mode 100644 internal/api/grpc/session/v2/session_test.go delete mode 100644 internal/command/permission.go create mode 100644 internal/command/session.go create mode 100644 internal/command/session_model.go create mode 100644 internal/command/session_test.go create mode 100644 internal/database/type_test.go create mode 100644 internal/domain/session.go create mode 100644 internal/query/projection/session.go create mode 100644 internal/query/projection/session_test.go create mode 100644 internal/query/session.go create mode 100644 internal/query/sessions_test.go create mode 100644 internal/repository/session/aggregate.go create mode 100644 internal/repository/session/eventstore.go create mode 100644 internal/repository/session/session.go delete mode 100644 pkg/grpc/user/v2alpha/user.go diff --git a/cmd/setup/03.go b/cmd/setup/03.go index 9774ab7b7..416010946 100644 --- a/cmd/setup/03.go +++ b/cmd/setup/03.go @@ -77,6 +77,7 @@ func (mig *FirstInstance) Execute(ctx context.Context) error { nil, nil, nil, + nil, ) if err != nil { diff --git a/cmd/setup/config_change.go b/cmd/setup/config_change.go index f112dd8c7..14a6849ce 100644 --- a/cmd/setup/config_change.go +++ b/cmd/setup/config_change.go @@ -52,6 +52,7 @@ func (mig *externalConfigChange) Execute(ctx context.Context) error { nil, nil, nil, + nil, ) if err != nil { diff --git a/cmd/start/start.go b/cmd/start/start.go index 58571abc1..6f248c108 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -50,6 +50,7 @@ import ( "github.com/zitadel/zitadel/internal/crypto" cryptoDB "github.com/zitadel/zitadel/internal/crypto/database" "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/id" "github.com/zitadel/zitadel/internal/logstore" @@ -129,7 +130,21 @@ func startZitadel(config *Config, masterKey string, server chan<- *Server) error return fmt.Errorf("cannot start eventstore for queries: %w", err) } - queries, err := query.StartQueries(ctx, eventstoreClient, dbClient, config.Projections, config.SystemDefaults, keys.IDPConfig, keys.OTP, keys.OIDC, keys.SAML, config.InternalAuthZ.RolePermissionMappings) + sessionTokenVerifier := internal_authz.SessionTokenVerifier(keys.OIDC) + + queries, err := query.StartQueries( + ctx, + eventstoreClient, + dbClient, + config.Projections, + config.SystemDefaults, + keys.IDPConfig, + keys.OTP, + keys.OIDC, + keys.SAML, + config.InternalAuthZ.RolePermissionMappings, + sessionTokenVerifier, + ) if err != nil { return fmt.Errorf("cannot start queries: %w", err) } @@ -138,6 +153,9 @@ func startZitadel(config *Config, masterKey string, server chan<- *Server) error if err != nil { return fmt.Errorf("error starting authz repo: %w", err) } + permissionCheck := func(ctx context.Context, permission, orgID, resourceID string) (err error) { + return internal_authz.CheckPermission(ctx, authZRepo, config.InternalAuthZ.RolePermissionMappings, permission, orgID, resourceID) + } storage, err := config.AssetStorage.NewStorage(dbClient.DB) if err != nil { @@ -165,7 +183,8 @@ func startZitadel(config *Config, masterKey string, server chan<- *Server) error keys.OIDC, keys.SAML, &http.Client{}, - authZRepo, + permissionCheck, + sessionTokenVerifier, ) if err != nil { return fmt.Errorf("cannot start commands: %w", err) @@ -195,7 +214,22 @@ func startZitadel(config *Config, masterKey string, server chan<- *Server) error if err != nil { return err } - err = startAPIs(ctx, clock, router, commands, queries, eventstoreClient, dbClient, config, storage, authZRepo, keys, queries, usageReporter) + err = startAPIs( + ctx, + clock, + router, + commands, + queries, + eventstoreClient, + dbClient, + config, + storage, + authZRepo, + keys, + queries, + usageReporter, + permissionCheck, + ) if err != nil { return err } @@ -239,6 +273,7 @@ func startAPIs( keys *encryptionKeys, quotaQuerier logstore.QuotaQuerier, usageReporter logstore.UsageReporter, + permissionCheck domain.PermissionCheck, ) error { repo := struct { authz_repo.Repository @@ -294,7 +329,7 @@ func startAPIs( if err := apis.RegisterService(ctx, user.CreateServer(commands, queries, keys.User)); err != nil { return err } - if err := apis.RegisterService(ctx, session.CreateServer(commands, queries)); err != nil { + if err := apis.RegisterService(ctx, session.CreateServer(commands, queries, permissionCheck)); err != nil { return err } instanceInterceptor := middleware.InstanceInterceptor(queries, config.HTTP1HostHeader, login.IgnoreInstanceEndpoints...) diff --git a/docs/docs/guides/integrate/access-zitadel-system-api.md b/docs/docs/guides/integrate/access-zitadel-system-api.md index 1f6d87c8a..e8297b64a 100644 --- a/docs/docs/guides/integrate/access-zitadel-system-api.md +++ b/docs/docs/guides/integrate/access-zitadel-system-api.md @@ -86,7 +86,7 @@ If your system is exposed without TLS or on a dedicated port, be sure to provide If you want to manually create a JWT for a test, you can also use our [ZITADEL Tools](https://github.com/zitadel/zitadel-tools). Download the latest release and run: ```bash -./key2jwt -audience=https://custom-domain.com -key=system-user-1.pem -issuer=system-user-1 +zitadel-tools key2jwt --audience=https://custom-domain.com --key=system-user-1.pem --issuer=system-user-1 ``` ## Call the System API diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index a660dae88..5ed0a099d 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -266,6 +266,13 @@ module.exports = { sidebarOptions: { groupPathsBy: "tag", }, + }, + session: { + specPath: ".artifacts/openapi/zitadel/session/v2alpha/session_service.swagger.json", + outputDir: "docs/apis/session_service", + sidebarOptions: { + groupPathsBy: "tag", + }, } } }, diff --git a/docs/sidebars.js b/docs/sidebars.js index 1858d4357..25e84b4b9 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -412,6 +412,20 @@ module.exports = { }, items: require("./docs/apis/user_service/sidebar.js"), }, + { + type: "category", + label: "Session Lifecycle (Alpha)", + link: { + type: "generated-index", + title: "Session Service API (Alpha)", + slug: "/apis/session_service", + description: + "This API is intended to manage sessions in a ZITADEL instance.\n"+ + "\n"+ + "This project is in alpha state. It can AND will continue breaking until the services provide the same functionality as the current login.", + }, + items: require("./docs/apis/session_service/sidebar.js"), + }, { type: "category", label: "Assets", diff --git a/go.mod b/go.mod index 668aa0230..24774459b 100644 --- a/go.mod +++ b/go.mod @@ -192,7 +192,7 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.14.0 // indirect go.opentelemetry.io/proto/otlp v0.19.0 // indirect golang.org/x/mod v0.10.0 // indirect - golang.org/x/sys v0.7.0 // indirect + golang.org/x/sys v0.7.0 golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect google.golang.org/appengine v1.6.7 // indirect gopkg.in/ini.v1 v1.67.0 // indirect diff --git a/internal/api/authz/authorization.go b/internal/api/authz/authorization.go index 0df781f4b..2db8f14ad 100644 --- a/internal/api/authz/authorization.go +++ b/internal/api/authz/authorization.go @@ -14,11 +14,16 @@ const ( authenticated = "authenticated" ) -func CheckUserAuthorization(ctx context.Context, req interface{}, token, orgIDHeader string, verifier *TokenVerifier, authConfig Config, requiredAuthOption Option, method string) (ctxSetter func(context.Context) context.Context, err error) { +// CheckUserAuthorization verifies that: +// - the token is active, +// - the organisation (**either** provided by ID or verified domain) exists +// - the user is permitted to call the requested endpoint (permission option in proto) +// it will pass the [CtxData] and permission of the user into the ctx [context.Context] +func CheckUserAuthorization(ctx context.Context, req interface{}, token, orgID, orgDomain string, verifier *TokenVerifier, authConfig Config, requiredAuthOption Option, method string) (ctxSetter func(context.Context) context.Context, err error) { ctx, span := tracing.NewServerInterceptorSpan(ctx) defer func() { span.EndWithError(err) }() - ctxData, err := VerifyTokenAndCreateCtxData(ctx, token, orgIDHeader, verifier, method) + ctxData, err := VerifyTokenAndCreateCtxData(ctx, token, orgID, orgDomain, verifier, method) if err != nil { return nil, err } diff --git a/internal/api/authz/context.go b/internal/api/authz/context.go index 938241232..ec2e13d0e 100644 --- a/internal/api/authz/context.go +++ b/internal/api/authz/context.go @@ -60,7 +60,7 @@ const ( MemberTypeIam ) -func VerifyTokenAndCreateCtxData(ctx context.Context, token, orgID string, t *TokenVerifier, method string) (_ CtxData, err error) { +func VerifyTokenAndCreateCtxData(ctx context.Context, token, orgID, orgDomain string, t *TokenVerifier, method string) (_ CtxData, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -82,14 +82,15 @@ func VerifyTokenAndCreateCtxData(ctx context.Context, token, orgID string, t *To if err := checkOrigin(ctx, origins); err != nil { return CtxData{}, err } - if orgID == "" { + if orgID == "" && orgDomain == "" { orgID = resourceOwner } - err = t.ExistsOrg(ctx, orgID) + verifiedOrgID, err := t.ExistsOrg(ctx, orgID, orgDomain) if err != nil { err = retry(func() error { - return t.ExistsOrg(ctx, orgID) + verifiedOrgID, err = t.ExistsOrg(ctx, orgID, orgDomain) + return err }) if err != nil { return CtxData{}, errors.ThrowPermissionDenied(nil, "AUTH-Bs7Ds", "Organisation doesn't exist") @@ -98,7 +99,7 @@ func VerifyTokenAndCreateCtxData(ctx context.Context, token, orgID string, t *To return CtxData{ UserID: userID, - OrgID: orgID, + OrgID: verifiedOrgID, ProjectID: projectID, AgentID: agentID, PreferredLanguage: prefLang, diff --git a/internal/api/authz/permissions.go b/internal/api/authz/permissions.go index b392ca795..4312651bd 100644 --- a/internal/api/authz/permissions.go +++ b/internal/api/authz/permissions.go @@ -7,12 +7,8 @@ import ( "github.com/zitadel/zitadel/internal/telemetry/tracing" ) -func CheckPermission(ctx context.Context, resolver MembershipsResolver, roleMappings []RoleMapping, permission, orgID, resourceID string, allowSelf bool) (err error) { - ctxData := GetCtxData(ctx) - if allowSelf && ctxData.UserID == resourceID { - return nil - } - requestedPermissions, _, err := getUserPermissions(ctx, resolver, permission, roleMappings, ctxData, orgID) +func CheckPermission(ctx context.Context, resolver MembershipsResolver, roleMappings []RoleMapping, permission, orgID, resourceID string) (err error) { + requestedPermissions, _, err := getUserPermissions(ctx, resolver, permission, roleMappings, GetCtxData(ctx), orgID) if err != nil { return err } diff --git a/internal/api/authz/permissions_test.go b/internal/api/authz/permissions_test.go index ea217e1b8..01cfa3543 100644 --- a/internal/api/authz/permissions_test.go +++ b/internal/api/authz/permissions_test.go @@ -26,8 +26,8 @@ func (v *testVerifier) ProjectIDAndOriginsByClientID(ctx context.Context, client return "", nil, nil } -func (v *testVerifier) ExistsOrg(ctx context.Context, orgID string) error { - return nil +func (v *testVerifier) ExistsOrg(ctx context.Context, orgID, domain string) (string, error) { + return orgID, nil } func (v *testVerifier) VerifierClientID(ctx context.Context, appName string) (string, string, error) { diff --git a/internal/api/authz/token.go b/internal/api/authz/token.go index a8a9b3afb..b56d042da 100644 --- a/internal/api/authz/token.go +++ b/internal/api/authz/token.go @@ -3,6 +3,8 @@ package authz import ( "context" "crypto/rsa" + "encoding/base64" + "fmt" "os" "strings" "sync" @@ -17,7 +19,8 @@ import ( ) const ( - BearerPrefix = "Bearer " + BearerPrefix = "Bearer " + SessionTokenFormat = "sess_%s:%s" ) type TokenVerifier struct { @@ -36,7 +39,7 @@ type authZRepo interface { VerifierClientID(ctx context.Context, name string) (clientID, projectID string, err error) SearchMyMemberships(ctx context.Context, orgID string) ([]*Membership, error) ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (projectID string, origins []string, err error) - ExistsOrg(ctx context.Context, orgID string) error + ExistsOrg(ctx context.Context, id, domain string) (string, error) } func Start(authZRepo authZRepo, issuer string, keys map[string]*SystemAPIUser) (v *TokenVerifier) { @@ -144,10 +147,10 @@ func (v *TokenVerifier) ProjectIDAndOriginsByClientID(ctx context.Context, clien return v.authZRepo.ProjectIDAndOriginsByClientID(ctx, clientID) } -func (v *TokenVerifier) ExistsOrg(ctx context.Context, orgID string) (err error) { +func (v *TokenVerifier) ExistsOrg(ctx context.Context, id, domain string) (orgID string, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - return v.authZRepo.ExistsOrg(ctx, orgID) + return v.authZRepo.ExistsOrg(ctx, id, domain) } func (v *TokenVerifier) CheckAuthMethod(method string) (Option, bool) { @@ -165,3 +168,20 @@ func verifyAccessToken(ctx context.Context, token string, t *TokenVerifier, meth } return t.VerifyAccessToken(ctx, parts[1], method) } + +func SessionTokenVerifier(algorithm crypto.EncryptionAlgorithm) func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) { + return func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) { + decodedToken, err := base64.RawURLEncoding.DecodeString(sessionToken) + if err != nil { + return err + } + _, spanPasswordComparison := tracing.NewNamedSpan(ctx, "crypto.CompareHash") + var token string + token, err = algorithm.DecryptString(decodedToken, algorithm.EncryptionKeyID()) + spanPasswordComparison.EndWithError(err) + if err != nil || token != fmt.Sprintf(SessionTokenFormat, sessionID, tokenID) { + return caos_errs.ThrowPermissionDenied(err, "COMMAND-sGr42", "Errors.Session.Token.Invalid") + } + return nil + } +} diff --git a/internal/api/grpc/object/v2/converter.go b/internal/api/grpc/object/v2/converter.go index eca168f70..feb8dbd62 100644 --- a/internal/api/grpc/object/v2/converter.go +++ b/internal/api/grpc/object/v2/converter.go @@ -4,6 +4,7 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha" ) @@ -17,3 +18,21 @@ func DomainToDetailsPb(objectDetail *domain.ObjectDetails) *object.Details { } return details } + +func ToListDetails(response query.SearchResponse) *object.ListDetails { + details := &object.ListDetails{ + TotalResult: response.Count, + ProcessedSequence: response.Sequence, + } + if !response.Timestamp.IsZero() { + details.Timestamp = timestamppb.New(response.Timestamp) + } + + return details +} +func ListQueryToQuery(query *object.ListQuery) (offset, limit uint64, asc bool) { + if query == nil { + return 0, 0, false + } + return query.Offset, uint64(query.Limit), query.Asc +} diff --git a/internal/api/grpc/server/middleware/auth_interceptor.go b/internal/api/grpc/server/middleware/auth_interceptor.go index d87e57233..e9d90c7cf 100644 --- a/internal/api/grpc/server/middleware/auth_interceptor.go +++ b/internal/api/grpc/server/middleware/auth_interceptor.go @@ -34,12 +34,14 @@ func authorize(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, return nil, status.Error(codes.Unauthenticated, "auth header missing") } + var orgDomain string orgID := grpc_util.GetHeader(authCtx, http.ZitadelOrgID) if o, ok := req.(OrganisationFromRequest); ok { orgID = o.OrganisationFromRequest().GetOrgId() + orgDomain = o.OrganisationFromRequest().GetOrgDomain() } - ctxSetter, err := authz.CheckUserAuthorization(authCtx, req, authToken, orgID, verifier, authConfig, authOpt, info.FullMethod) + ctxSetter, err := authz.CheckUserAuthorization(authCtx, req, authToken, orgID, orgDomain, verifier, authConfig, authOpt, info.FullMethod) if err != nil { return nil, err } diff --git a/internal/api/grpc/server/middleware/auth_interceptor_test.go b/internal/api/grpc/server/middleware/auth_interceptor_test.go index c041d9ca4..ac091e1d1 100644 --- a/internal/api/grpc/server/middleware/auth_interceptor_test.go +++ b/internal/api/grpc/server/middleware/auth_interceptor_test.go @@ -31,8 +31,8 @@ func (v *verifierMock) SearchMyMemberships(ctx context.Context, orgID string) ([ func (v *verifierMock) ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (string, []string, error) { return "", nil, nil } -func (v *verifierMock) ExistsOrg(ctx context.Context, orgID string) error { - return nil +func (v *verifierMock) ExistsOrg(ctx context.Context, orgID, domain string) (string, error) { + return orgID, nil } func (v *verifierMock) VerifierClientID(ctx context.Context, appName string) (string, string, error) { return "", "", nil diff --git a/internal/api/grpc/session/v2/server.go b/internal/api/grpc/session/v2/server.go index 22919fdb7..3dc5a0c83 100644 --- a/internal/api/grpc/session/v2/server.go +++ b/internal/api/grpc/session/v2/server.go @@ -6,16 +6,18 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" - "github.com/zitadel/zitadel/pkg/grpc/session/v2alpha" + session "github.com/zitadel/zitadel/pkg/grpc/session/v2alpha" ) var _ session.SessionServiceServer = (*Server)(nil) type Server struct { session.UnimplementedSessionServiceServer - command *command.Commands - query *query.Queries + command *command.Commands + query *query.Queries + checkPermission domain.PermissionCheck } type Config struct{} @@ -23,10 +25,12 @@ type Config struct{} func CreateServer( command *command.Commands, query *query.Queries, + checkPermission domain.PermissionCheck, ) *Server { return &Server{ - command: command, - query: query, + command: command, + query: query, + checkPermission: checkPermission, } } diff --git a/internal/api/grpc/session/v2/session.go b/internal/api/grpc/session/v2/session.go index 3e90e76a9..413989ba1 100644 --- a/internal/api/grpc/session/v2/session.go +++ b/internal/api/grpc/session/v2/session.go @@ -3,16 +3,260 @@ package session import ( "context" + "google.golang.org/protobuf/types/known/timestamppb" + "github.com/zitadel/zitadel/internal/api/authz" - "github.com/zitadel/zitadel/pkg/grpc/session/v2alpha" - "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha" + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" + "github.com/zitadel/zitadel/internal/command" + caos_errs "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/query" + session "github.com/zitadel/zitadel/pkg/grpc/session/v2alpha" ) func (s *Server) GetSession(ctx context.Context, req *session.GetSessionRequest) (*session.GetSessionResponse, error) { + res, err := s.query.SessionByID(ctx, req.GetSessionId(), req.GetSessionToken()) + if err != nil { + return nil, err + } return &session.GetSessionResponse{ - Session: &session.Session{ - Id: req.Id, - User: &user.User{Id: authz.GetCtxData(ctx).UserID}, - }, + Session: sessionToPb(res), }, nil } + +func (s *Server) ListSessions(ctx context.Context, req *session.ListSessionsRequest) (*session.ListSessionsResponse, error) { + queries, err := listSessionsRequestToQuery(ctx, req) + if err != nil { + return nil, err + } + sessions, err := s.query.SearchSessions(ctx, queries) + if err != nil { + return nil, err + } + return &session.ListSessionsResponse{ + Details: object.ToListDetails(sessions.SearchResponse), + Sessions: sessionsToPb(sessions.Sessions), + }, nil +} + +func (s *Server) CreateSession(ctx context.Context, req *session.CreateSessionRequest) (*session.CreateSessionResponse, error) { + checks, metadata, err := s.createSessionRequestToCommand(ctx, req) + if err != nil { + return nil, err + } + set, err := s.command.CreateSession(ctx, checks, metadata) + if err != nil { + return nil, err + } + return &session.CreateSessionResponse{ + Details: object.DomainToDetailsPb(set.ObjectDetails), + SessionId: set.ID, + SessionToken: set.NewToken, + }, nil +} + +func (s *Server) SetSession(ctx context.Context, req *session.SetSessionRequest) (*session.SetSessionResponse, error) { + checks, err := s.setSessionRequestToCommand(ctx, req) + if err != nil { + return nil, err + } + set, err := s.command.UpdateSession(ctx, req.GetSessionId(), req.GetSessionToken(), checks, req.GetMetadata()) + if err != nil { + return nil, err + } + // if there's no new token, just return the current + if set.NewToken == "" { + set.NewToken = req.GetSessionToken() + } + return &session.SetSessionResponse{ + Details: object.DomainToDetailsPb(set.ObjectDetails), + SessionToken: set.NewToken, + }, nil +} + +func (s *Server) DeleteSession(ctx context.Context, req *session.DeleteSessionRequest) (*session.DeleteSessionResponse, error) { + details, err := s.command.TerminateSession(ctx, req.GetSessionId(), req.GetSessionToken()) + if err != nil { + return nil, err + } + return &session.DeleteSessionResponse{ + Details: object.DomainToDetailsPb(details), + }, nil +} + +func sessionsToPb(sessions []*query.Session) []*session.Session { + s := make([]*session.Session, len(sessions)) + for i, session := range sessions { + s[i] = sessionToPb(session) + } + return s +} + +func sessionToPb(s *query.Session) *session.Session { + return &session.Session{ + Id: s.ID, + CreationDate: timestamppb.New(s.CreationDate), + ChangeDate: timestamppb.New(s.ChangeDate), + Sequence: s.Sequence, + Factors: factorsToPb(s), + Metadata: s.Metadata, + } +} + +func factorsToPb(s *query.Session) *session.Factors { + user := userFactorToPb(s.UserFactor) + pw := passwordFactorToPb(s.PasswordFactor) + if user == nil && pw == nil { + return nil + } + return &session.Factors{ + User: user, + Password: pw, + } +} + +func passwordFactorToPb(factor query.SessionPasswordFactor) *session.PasswordFactor { + if factor.PasswordCheckedAt.IsZero() { + return nil + } + return &session.PasswordFactor{ + VerifiedAt: timestamppb.New(factor.PasswordCheckedAt), + } +} + +func userFactorToPb(factor query.SessionUserFactor) *session.UserFactor { + if factor.UserID == "" || factor.UserCheckedAt.IsZero() { + return nil + } + return &session.UserFactor{ + VerifiedAt: timestamppb.New(factor.UserCheckedAt), + Id: factor.UserID, + LoginName: factor.LoginName, + DisplayName: factor.DisplayName, + } +} + +func listSessionsRequestToQuery(ctx context.Context, req *session.ListSessionsRequest) (*query.SessionsSearchQueries, error) { + offset, limit, asc := object.ListQueryToQuery(req.Query) + queries, err := sessionQueriesToQuery(ctx, req.GetQueries()) + if err != nil { + return nil, err + } + return &query.SessionsSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + }, + Queries: queries, + }, nil +} + +func sessionQueriesToQuery(ctx context.Context, queries []*session.SearchQuery) (_ []query.SearchQuery, err error) { + q := make([]query.SearchQuery, len(queries)+1) + for i, query := range queries { + q[i], err = sessionQueryToQuery(query) + if err != nil { + return nil, err + } + } + creatorQuery, err := query.NewSessionCreatorSearchQuery(authz.GetCtxData(ctx).UserID) + if err != nil { + return nil, err + } + q[len(queries)] = creatorQuery + return q, nil +} + +func sessionQueryToQuery(query *session.SearchQuery) (query.SearchQuery, error) { + switch q := query.Query.(type) { + case *session.SearchQuery_IdsQuery: + return idsQueryToQuery(q.IdsQuery) + default: + return nil, caos_errs.ThrowInvalidArgument(nil, "GRPC-Sfefs", "List.Query.Invalid") + } +} + +func idsQueryToQuery(q *session.IDsQuery) (query.SearchQuery, error) { + return query.NewSessionIDsSearchQuery(q.Ids) +} + +func (s *Server) createSessionRequestToCommand(ctx context.Context, req *session.CreateSessionRequest) ([]command.SessionCheck, map[string][]byte, error) { + checks, err := s.checksToCommand(ctx, req.Checks) + if err != nil { + return nil, nil, err + } + return checks, req.GetMetadata(), nil +} + +func (s *Server) setSessionRequestToCommand(ctx context.Context, req *session.SetSessionRequest) ([]command.SessionCheck, error) { + checks, err := s.checksToCommand(ctx, req.Checks) + if err != nil { + return nil, err + } + return checks, nil +} + +func (s *Server) checksToCommand(ctx context.Context, checks *session.Checks) ([]command.SessionCheck, error) { + checkUser, err := userCheck(checks.GetUser()) + if err != nil { + return nil, err + } + sessionChecks := make([]command.SessionCheck, 0, 2) + if checkUser != nil { + user, err := checkUser.search(ctx, s.query) + if err != nil { + return nil, err + } + sessionChecks = append(sessionChecks, command.CheckUser(user.ID)) + } + if password := checks.GetPassword(); password != nil { + sessionChecks = append(sessionChecks, command.CheckPassword(password.GetPassword())) + } + return sessionChecks, nil +} + +func userCheck(user *session.CheckUser) (userSearch, error) { + if user == nil { + return nil, nil + } + switch s := user.GetSearch().(type) { + case *session.CheckUser_UserId: + return userByID(s.UserId), nil + case *session.CheckUser_LoginName: + return userByLoginName(s.LoginName) + default: + return nil, caos_errs.ThrowUnimplementedf(nil, "SESSION-d3b4g0", "user search %T not implemented", s) + } +} + +type userSearch interface { + search(ctx context.Context, q *query.Queries) (*query.User, error) +} + +func userByID(userID string) userSearch { + return userSearchByID{userID} +} + +func userByLoginName(loginName string) (userSearch, error) { + loginNameQuery, err := query.NewUserLoginNamesSearchQuery(loginName) + if err != nil { + return nil, err + } + return userSearchByLoginName{loginNameQuery}, nil +} + +type userSearchByID struct { + id string +} + +func (u userSearchByID) search(ctx context.Context, q *query.Queries) (*query.User, error) { + return q.GetUserByID(ctx, true, u.id, false) +} + +type userSearchByLoginName struct { + loginNameQuery query.SearchQuery +} + +func (u userSearchByLoginName) search(ctx context.Context, q *query.Queries) (*query.User, error) { + return q.GetUser(ctx, true, false, u.loginNameQuery) +} diff --git a/internal/api/grpc/session/v2/session_test.go b/internal/api/grpc/session/v2/session_test.go new file mode 100644 index 000000000..6d2f38606 --- /dev/null +++ b/internal/api/grpc/session/v2/session_test.go @@ -0,0 +1,379 @@ +package session + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/domain" + caos_errs "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/query" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha" + session "github.com/zitadel/zitadel/pkg/grpc/session/v2alpha" +) + +func Test_sessionsToPb(t *testing.T) { + now := time.Now() + past := now.Add(-time.Hour) + + sessions := []*query.Session{ + { // no factor + ID: "999", + CreationDate: now, + ChangeDate: now, + Sequence: 123, + State: domain.SessionStateActive, + ResourceOwner: "me", + Creator: "he", + Metadata: map[string][]byte{"hello": []byte("world")}, + }, + { // user factor + ID: "999", + CreationDate: now, + ChangeDate: now, + Sequence: 123, + State: domain.SessionStateActive, + ResourceOwner: "me", + Creator: "he", + UserFactor: query.SessionUserFactor{ + UserID: "345", + UserCheckedAt: past, + LoginName: "donald", + DisplayName: "donald duck", + }, + Metadata: map[string][]byte{"hello": []byte("world")}, + }, + { // no factor + ID: "999", + CreationDate: now, + ChangeDate: now, + Sequence: 123, + State: domain.SessionStateActive, + ResourceOwner: "me", + Creator: "he", + PasswordFactor: query.SessionPasswordFactor{ + PasswordCheckedAt: past, + }, + Metadata: map[string][]byte{"hello": []byte("world")}, + }, + } + + want := []*session.Session{ + { // no factor + Id: "999", + CreationDate: timestamppb.New(now), + ChangeDate: timestamppb.New(now), + Sequence: 123, + Factors: nil, + Metadata: map[string][]byte{"hello": []byte("world")}, + }, + { // user factor + Id: "999", + CreationDate: timestamppb.New(now), + ChangeDate: timestamppb.New(now), + Sequence: 123, + Factors: &session.Factors{ + User: &session.UserFactor{ + VerifiedAt: timestamppb.New(past), + Id: "345", + LoginName: "donald", + DisplayName: "donald duck", + }, + }, + Metadata: map[string][]byte{"hello": []byte("world")}, + }, + { // password factor + Id: "999", + CreationDate: timestamppb.New(now), + ChangeDate: timestamppb.New(now), + Sequence: 123, + Factors: &session.Factors{ + Password: &session.PasswordFactor{ + VerifiedAt: timestamppb.New(past), + }, + }, + Metadata: map[string][]byte{"hello": []byte("world")}, + }, + } + + out := sessionsToPb(sessions) + require.Len(t, out, len(want)) + + for i, got := range out { + if !proto.Equal(got, want[i]) { + t.Errorf("session %d got:\n%v\nwant:\n%v", i, got, want) + } + } +} + +func mustNewTextQuery(t testing.TB, column query.Column, value string, compare query.TextComparison) query.SearchQuery { + q, err := query.NewTextQuery(column, value, compare) + require.NoError(t, err) + return q +} + +func mustNewListQuery(t testing.TB, column query.Column, list []any, compare query.ListComparison) query.SearchQuery { + q, err := query.NewListQuery(query.SessionColumnID, list, compare) + require.NoError(t, err) + return q +} + +func Test_listSessionsRequestToQuery(t *testing.T) { + type args struct { + ctx context.Context + req *session.ListSessionsRequest + } + tests := []struct { + name string + args args + want *query.SessionsSearchQueries + wantErr error + }{ + { + name: "default request", + args: args{ + ctx: authz.NewMockContext("123", "456", "789"), + req: &session.ListSessionsRequest{}, + }, + want: &query.SessionsSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: 0, + Limit: 0, + Asc: false, + }, + Queries: []query.SearchQuery{ + mustNewTextQuery(t, query.SessionColumnCreator, "789", query.TextEquals), + }, + }, + }, + { + name: "with list query and sessions", + args: args{ + ctx: authz.NewMockContext("123", "456", "789"), + req: &session.ListSessionsRequest{ + Query: &object.ListQuery{ + Offset: 10, + Limit: 20, + Asc: true, + }, + Queries: []*session.SearchQuery{ + {Query: &session.SearchQuery_IdsQuery{ + IdsQuery: &session.IDsQuery{ + Ids: []string{"1", "2", "3"}, + }, + }}, + {Query: &session.SearchQuery_IdsQuery{ + IdsQuery: &session.IDsQuery{ + Ids: []string{"4", "5", "6"}, + }, + }}, + }, + }, + }, + want: &query.SessionsSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: 10, + Limit: 20, + Asc: true, + }, + Queries: []query.SearchQuery{ + mustNewListQuery(t, query.SessionColumnID, []interface{}{"1", "2", "3"}, query.ListIn), + mustNewListQuery(t, query.SessionColumnID, []interface{}{"4", "5", "6"}, query.ListIn), + mustNewTextQuery(t, query.SessionColumnCreator, "789", query.TextEquals), + }, + }, + }, + { + name: "invalid argument error", + args: args{ + ctx: authz.NewMockContext("123", "456", "789"), + req: &session.ListSessionsRequest{ + Query: &object.ListQuery{ + Offset: 10, + Limit: 20, + Asc: true, + }, + Queries: []*session.SearchQuery{ + {Query: &session.SearchQuery_IdsQuery{ + IdsQuery: &session.IDsQuery{ + Ids: []string{"1", "2", "3"}, + }, + }}, + {Query: nil}, + }, + }, + }, + wantErr: caos_errs.ThrowInvalidArgument(nil, "GRPC-Sfefs", "List.Query.Invalid"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := listSessionsRequestToQuery(tt.args.ctx, tt.args.req) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_sessionQueriesToQuery(t *testing.T) { + type args struct { + ctx context.Context + queries []*session.SearchQuery + } + tests := []struct { + name string + args args + want []query.SearchQuery + wantErr error + }{ + { + name: "creator only", + args: args{ + ctx: authz.NewMockContext("123", "456", "789"), + }, + want: []query.SearchQuery{ + mustNewTextQuery(t, query.SessionColumnCreator, "789", query.TextEquals), + }, + }, + { + name: "invalid argument", + args: args{ + ctx: authz.NewMockContext("123", "456", "789"), + queries: []*session.SearchQuery{ + {Query: nil}, + }, + }, + wantErr: caos_errs.ThrowInvalidArgument(nil, "GRPC-Sfefs", "List.Query.Invalid"), + }, + { + name: "creator and sessions", + args: args{ + ctx: authz.NewMockContext("123", "456", "789"), + queries: []*session.SearchQuery{ + {Query: &session.SearchQuery_IdsQuery{ + IdsQuery: &session.IDsQuery{ + Ids: []string{"1", "2", "3"}, + }, + }}, + {Query: &session.SearchQuery_IdsQuery{ + IdsQuery: &session.IDsQuery{ + Ids: []string{"4", "5", "6"}, + }, + }}, + }, + }, + want: []query.SearchQuery{ + mustNewListQuery(t, query.SessionColumnID, []interface{}{"1", "2", "3"}, query.ListIn), + mustNewListQuery(t, query.SessionColumnID, []interface{}{"4", "5", "6"}, query.ListIn), + mustNewTextQuery(t, query.SessionColumnCreator, "789", query.TextEquals), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := sessionQueriesToQuery(tt.args.ctx, tt.args.queries) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_sessionQueryToQuery(t *testing.T) { + type args struct { + query *session.SearchQuery + } + tests := []struct { + name string + args args + want query.SearchQuery + wantErr error + }{ + { + name: "invalid argument", + args: args{&session.SearchQuery{ + Query: nil, + }}, + wantErr: caos_errs.ThrowInvalidArgument(nil, "GRPC-Sfefs", "List.Query.Invalid"), + }, + { + name: "query", + args: args{&session.SearchQuery{ + Query: &session.SearchQuery_IdsQuery{ + IdsQuery: &session.IDsQuery{ + Ids: []string{"1", "2", "3"}, + }, + }, + }}, + want: mustNewListQuery(t, query.SessionColumnID, []interface{}{"1", "2", "3"}, query.ListIn), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := sessionQueryToQuery(tt.args.query) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.want, got) + }) + } +} + +func mustUserLoginNamesSearchQuery(t testing.TB, value string) query.SearchQuery { + loginNameQuery, err := query.NewUserLoginNamesSearchQuery("bar") + require.NoError(t, err) + return loginNameQuery +} + +func Test_userCheck(t *testing.T) { + type args struct { + user *session.CheckUser + } + tests := []struct { + name string + args args + want userSearch + wantErr error + }{ + { + name: "nil user", + args: args{nil}, + want: nil, + }, + { + name: "by user id", + args: args{&session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: "foo", + }, + }}, + want: userSearchByID{"foo"}, + }, + { + name: "by user id", + args: args{&session.CheckUser{ + Search: &session.CheckUser_LoginName{ + LoginName: "bar", + }, + }}, + want: userSearchByLoginName{mustUserLoginNamesSearchQuery(t, "bar")}, + }, + { + name: "unimplemented error", + args: args{&session.CheckUser{ + Search: nil, + }}, + wantErr: caos_errs.ThrowUnimplementedf(nil, "SESSION-d3b4g0", "user search %T not implemented", nil), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := userCheck(tt.args.user) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/api/grpc/user/v2/user.go b/internal/api/grpc/user/v2/user.go index 02b7e0628..4388f69f9 100644 --- a/internal/api/grpc/user/v2/user.go +++ b/internal/api/grpc/user/v2/user.go @@ -11,7 +11,7 @@ import ( "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/errors" - "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha" ) func (s *Server) AddHumanUser(ctx context.Context, req *user.AddHumanUserRequest) (_ *user.AddHumanUserResponse, err error) { @@ -19,10 +19,7 @@ func (s *Server) AddHumanUser(ctx context.Context, req *user.AddHumanUserRequest if err != nil { return nil, err } - orgID := req.GetOrganisation().GetOrgId() - if orgID == "" { - orgID = authz.GetCtxData(ctx).OrgID - } + orgID := authz.GetCtxData(ctx).OrgID err = s.command.AddHuman(ctx, orgID, human, false) if err != nil { return nil, err diff --git a/internal/api/http/middleware/auth_interceptor.go b/internal/api/http/middleware/auth_interceptor.go index 9af48a274..047ee7754 100644 --- a/internal/api/http/middleware/auth_interceptor.go +++ b/internal/api/http/middleware/auth_interceptor.go @@ -62,7 +62,7 @@ func authorize(r *http.Request, verifier *authz.TokenVerifier, authConfig authz. return nil, errors.New("auth header missing") } - ctxSetter, err := authz.CheckUserAuthorization(authCtx, &httpReq{}, authToken, http_util.GetOrgID(r), verifier, authConfig, authOpt, r.RequestURI) + ctxSetter, err := authz.CheckUserAuthorization(authCtx, &httpReq{}, authToken, http_util.GetOrgID(r), "", verifier, authConfig, authOpt, r.RequestURI) if err != nil { return nil, err } diff --git a/internal/command/command.go b/internal/command/command.go index 82f2a4c45..e9fa9f739 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -20,6 +20,7 @@ import ( "github.com/zitadel/zitadel/internal/repository/org" proj_repo "github.com/zitadel/zitadel/internal/repository/project" "github.com/zitadel/zitadel/internal/repository/quota" + "github.com/zitadel/zitadel/internal/repository/session" usr_repo "github.com/zitadel/zitadel/internal/repository/user" usr_grant_repo "github.com/zitadel/zitadel/internal/repository/usergrant" "github.com/zitadel/zitadel/internal/static" @@ -29,7 +30,7 @@ import ( type Commands struct { httpClient *http.Client - checkPermission permissionCheck + checkPermission domain.PermissionCheck newEmailCode func(ctx context.Context, filter preparation.FilterToQueryReducer, codeAlg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error) eventstore *eventstore.Eventstore @@ -50,6 +51,8 @@ type Commands struct { domainVerificationAlg crypto.EncryptionAlgorithm domainVerificationGenerator crypto.Generator domainVerificationValidator func(domain, token, verifier string, checkType api_http.CheckType) error + sessionTokenCreator func(sessionID string) (id string, token string, err error) + sessionTokenVerifier func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) multifactors domain.MultifactorConfigs webauthnConfig *webauthn_helper.Config @@ -71,24 +74,21 @@ func StartCommands( externalDomain string, externalSecure bool, externalPort uint16, - idpConfigEncryption, - otpEncryption, - smtpEncryption, - smsEncryption, - userEncryption, - domainVerificationEncryption, - oidcEncryption, - samlEncryption crypto.EncryptionAlgorithm, + idpConfigEncryption, otpEncryption, smtpEncryption, smsEncryption, userEncryption, domainVerificationEncryption, oidcEncryption, samlEncryption crypto.EncryptionAlgorithm, httpClient *http.Client, - membershipsResolver authz.MembershipsResolver, + permissionCheck domain.PermissionCheck, + sessionTokenVerifier func(ctx context.Context, sessionToken string, sessionID string, tokenID string) (err error), ) (repo *Commands, err error) { if externalDomain == "" { return nil, errors.ThrowInvalidArgument(nil, "COMMAND-Df21s", "no external domain specified") } + idGenerator := id.SonyFlakeGenerator() + // reuse the oidcEncryption to be able to handle both tokens in the interceptor later on + sessionAlg := oidcEncryption repo = &Commands{ eventstore: es, static: staticStore, - idGenerator: id.SonyFlakeGenerator(), + idGenerator: idGenerator, zitadelRoles: zitadelRoles, externalDomain: externalDomain, externalSecure: externalSecure, @@ -107,10 +107,10 @@ func StartCommands( certificateAlgorithm: samlEncryption, webauthnConfig: webAuthN, httpClient: httpClient, - checkPermission: func(ctx context.Context, permission, orgID, resourceID string, allowSelf bool) (err error) { - return authz.CheckPermission(ctx, membershipsResolver, zitadelRoles, permission, orgID, resourceID, allowSelf) - }, - newEmailCode: newEmailCode, + checkPermission: permissionCheck, + newEmailCode: newEmailCode, + sessionTokenCreator: sessionTokenCreator(idGenerator, sessionAlg), + sessionTokenVerifier: sessionTokenVerifier, } instance_repo.RegisterEventMappers(repo.eventstore) @@ -121,6 +121,7 @@ func StartCommands( keypair.RegisterEventMappers(repo.eventstore) action.RegisterEventMappers(repo.eventstore) quota.RegisterEventMappers(repo.eventstore) + session.RegisterEventMappers(repo.eventstore) repo.userPasswordAlg = crypto.NewBCrypt(defaults.SecretGenerators.PasswordSaltCost) repo.machineKeySize = int(defaults.SecretGenerators.MachineKeySize) diff --git a/internal/command/main_test.go b/internal/command/main_test.go index 2f516133f..10fc1c2f6 100644 --- a/internal/command/main_test.go +++ b/internal/command/main_test.go @@ -10,6 +10,7 @@ import ( "golang.org/x/text/language" "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/errors" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/repository" @@ -19,6 +20,7 @@ import ( key_repo "github.com/zitadel/zitadel/internal/repository/keypair" "github.com/zitadel/zitadel/internal/repository/org" proj_repo "github.com/zitadel/zitadel/internal/repository/project" + "github.com/zitadel/zitadel/internal/repository/session" usr_repo "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/repository/usergrant" ) @@ -38,6 +40,7 @@ func eventstoreExpect(t *testing.T, expects ...expect) *eventstore.Eventstore { usergrant.RegisterEventMappers(es) key_repo.RegisterEventMappers(es) action_repo.RegisterEventMappers(es) + session.RegisterEventMappers(es) return es } @@ -125,6 +128,11 @@ func expectFilter(events ...*repository.Event) expect { m.ExpectFilterEvents(events...) } } +func expectFilterError(err error) expect { + return func(m *mock.MockRepository) { + m.ExpectFilterEventsError(err) + } +} func expectFilterOrgDomainNotFound() expect { return func(m *mock.MockRepository) { @@ -250,14 +258,14 @@ func (m *mockInstance) SecurityPolicyAllowedOrigins() []string { return nil } -func newMockPermissionCheckAllowed() permissionCheck { - return func(ctx context.Context, permission, orgID, resourceID string, allowSelf bool) (err error) { +func newMockPermissionCheckAllowed() domain.PermissionCheck { + return func(ctx context.Context, permission, orgID, resourceID string) (err error) { return nil } } -func newMockPermissionCheckNotAllowed() permissionCheck { - return func(ctx context.Context, permission, orgID, resourceID string, allowSelf bool) (err error) { +func newMockPermissionCheckNotAllowed() domain.PermissionCheck { + return func(ctx context.Context, permission, orgID, resourceID string) (err error) { return errors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied") } } diff --git a/internal/command/permission.go b/internal/command/permission.go deleted file mode 100644 index 138df0a44..000000000 --- a/internal/command/permission.go +++ /dev/null @@ -1,11 +0,0 @@ -package command - -import ( - "context" -) - -type permissionCheck func(ctx context.Context, permission, orgID, resourceID string, allowSelf bool) (err error) - -const ( - permissionUserWrite = "user.write" -) diff --git a/internal/command/session.go b/internal/command/session.go new file mode 100644 index 000000000..cb652ea19 --- /dev/null +++ b/internal/command/session.go @@ -0,0 +1,225 @@ +package command + +import ( + "context" + "encoding/base64" + "fmt" + "time" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + caos_errs "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/id" + "github.com/zitadel/zitadel/internal/repository/session" + "github.com/zitadel/zitadel/internal/telemetry/tracing" +) + +type SessionCheck func(ctx context.Context, cmd *SessionChecks) error + +type SessionChecks struct { + checks []SessionCheck + + sessionWriteModel *SessionWriteModel + passwordWriteModel *HumanPasswordWriteModel + eventstore *eventstore.Eventstore + userPasswordAlg crypto.HashAlgorithm + createToken func(sessionID string) (id string, token string, err error) + now func() time.Time +} + +func (c *Commands) NewSessionChecks(checks []SessionCheck, session *SessionWriteModel) *SessionChecks { + return &SessionChecks{ + checks: checks, + sessionWriteModel: session, + eventstore: c.eventstore, + userPasswordAlg: c.userPasswordAlg, + createToken: c.sessionTokenCreator, + now: time.Now, + } +} + +// CheckUser defines a user check to be executed for a session update +func CheckUser(id string) SessionCheck { + return func(ctx context.Context, cmd *SessionChecks) error { + if cmd.sessionWriteModel.UserID != "" && id != "" && cmd.sessionWriteModel.UserID != id { + return caos_errs.ThrowInvalidArgument(nil, "", "user change not possible") + } + return cmd.sessionWriteModel.UserChecked(ctx, id, cmd.now()) + } +} + +// CheckPassword defines a password check to be executed for a session update +func CheckPassword(password string) SessionCheck { + return func(ctx context.Context, cmd *SessionChecks) error { + if cmd.sessionWriteModel.UserID == "" { + return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Sfw3f", "Errors.User.UserIDMissing") + } + cmd.passwordWriteModel = NewHumanPasswordWriteModel(cmd.sessionWriteModel.UserID, "") + err := cmd.eventstore.FilterToQueryReducer(ctx, cmd.passwordWriteModel) + if err != nil { + return err + } + if cmd.passwordWriteModel.UserState == domain.UserStateUnspecified || cmd.passwordWriteModel.UserState == domain.UserStateDeleted { + return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Df4b3", "Errors.User.NotFound") + } + + if cmd.passwordWriteModel.Secret == nil { + return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-WEf3t", "Errors.User.Password.NotSet") + } + ctx, spanPasswordComparison := tracing.NewNamedSpan(ctx, "crypto.CompareHash") + err = crypto.CompareHash(cmd.passwordWriteModel.Secret, []byte(password), cmd.userPasswordAlg) + spanPasswordComparison.EndWithError(err) + if err != nil { + //TODO: maybe we want to reset the session in the future https://github.com/zitadel/zitadel/issues/5807 + return caos_errs.ThrowInvalidArgument(err, "COMMAND-SAF3g", "Errors.User.Password.Invalid") + } + cmd.sessionWriteModel.PasswordChecked(ctx, cmd.now()) + return nil + } +} + +// Check will execute the checks specified and return an error on the first occurrence +func (s *SessionChecks) Check(ctx context.Context) error { + for _, check := range s.checks { + if err := check(ctx, s); err != nil { + return err + } + } + return nil +} + +func (s *SessionChecks) commands(ctx context.Context) (string, []eventstore.Command, error) { + if len(s.sessionWriteModel.commands) == 0 { + return "", nil, nil + } + + tokenID, token, err := s.createToken(s.sessionWriteModel.AggregateID) + if err != nil { + return "", nil, err + } + s.sessionWriteModel.SetToken(ctx, tokenID) + return token, s.sessionWriteModel.commands, nil +} + +func (c *Commands) CreateSession(ctx context.Context, checks []SessionCheck, metadata map[string][]byte) (set *SessionChanged, err error) { + sessionID, err := c.idGenerator.Next() + if err != nil { + return nil, err + } + sessionWriteModel := NewSessionWriteModel(sessionID, authz.GetCtxData(ctx).OrgID) + err = c.eventstore.FilterToQueryReducer(ctx, sessionWriteModel) + if err != nil { + return nil, err + } + cmd := c.NewSessionChecks(checks, sessionWriteModel) + cmd.sessionWriteModel.Start(ctx) + return c.updateSession(ctx, cmd, metadata) +} + +func (c *Commands) UpdateSession(ctx context.Context, sessionID, sessionToken string, checks []SessionCheck, metadata map[string][]byte) (set *SessionChanged, err error) { + sessionWriteModel := NewSessionWriteModel(sessionID, authz.GetCtxData(ctx).OrgID) + err = c.eventstore.FilterToQueryReducer(ctx, sessionWriteModel) + if err != nil { + return nil, err + } + if err := c.sessionPermission(ctx, sessionWriteModel, sessionToken, domain.PermissionSessionWrite); err != nil { + return nil, err + } + cmd := c.NewSessionChecks(checks, sessionWriteModel) + return c.updateSession(ctx, cmd, metadata) +} + +func (c *Commands) TerminateSession(ctx context.Context, sessionID, sessionToken string) (*domain.ObjectDetails, error) { + sessionWriteModel := NewSessionWriteModel(sessionID, "") + if err := c.eventstore.FilterToQueryReducer(ctx, sessionWriteModel); err != nil { + return nil, err + } + if err := c.sessionPermission(ctx, sessionWriteModel, sessionToken, domain.PermissionSessionDelete); err != nil { + return nil, err + } + if sessionWriteModel.State != domain.SessionStateActive { + return writeModelToObjectDetails(&sessionWriteModel.WriteModel), nil + } + terminate := session.NewTerminateEvent(ctx, &session.NewAggregate(sessionWriteModel.AggregateID, sessionWriteModel.ResourceOwner).Aggregate) + pushedEvents, err := c.eventstore.Push(ctx, terminate) + if err != nil { + return nil, err + } + err = AppendAndReduce(sessionWriteModel, pushedEvents...) + if err != nil { + return nil, err + } + return writeModelToObjectDetails(&sessionWriteModel.WriteModel), nil +} + +// updateSession execute the [SessionChecks] where new events will be created and as well as for metadata (changes) +func (c *Commands) updateSession(ctx context.Context, checks *SessionChecks, metadata map[string][]byte) (set *SessionChanged, err error) { + if checks.sessionWriteModel.State == domain.SessionStateTerminated { + return nil, caos_errs.ThrowPreconditionFailed(nil, "COMAND-SAjeh", "Errors.Session.Terminated") + } + if err := checks.Check(ctx); err != nil { + // TODO: how to handle failed checks (e.g. pw wrong) https://github.com/zitadel/zitadel/issues/5807 + return nil, err + } + checks.sessionWriteModel.ChangeMetadata(ctx, metadata) + sessionToken, cmds, err := checks.commands(ctx) + if err != nil { + return nil, err + } + if len(cmds) == 0 { + return sessionWriteModelToSessionChanged(checks.sessionWriteModel), nil + } + pushedEvents, err := c.eventstore.Push(ctx, cmds...) + if err != nil { + return nil, err + } + err = AppendAndReduce(checks.sessionWriteModel, pushedEvents...) + if err != nil { + return nil, err + } + changed := sessionWriteModelToSessionChanged(checks.sessionWriteModel) + changed.NewToken = sessionToken + return changed, nil +} + +// sessionPermission will check that the provided sessionToken is correct or +// if empty, check that the caller is granted the necessary permission +func (c *Commands) sessionPermission(ctx context.Context, sessionWriteModel *SessionWriteModel, sessionToken, permission string) (err error) { + if sessionToken == "" { + return c.checkPermission(ctx, permission, authz.GetCtxData(ctx).OrgID, sessionWriteModel.AggregateID) + } + return c.sessionTokenVerifier(ctx, sessionToken, sessionWriteModel.AggregateID, sessionWriteModel.TokenID) +} + +func sessionTokenCreator(idGenerator id.Generator, sessionAlg crypto.EncryptionAlgorithm) func(sessionID string) (id string, token string, err error) { + return func(sessionID string) (id string, token string, err error) { + id, err = idGenerator.Next() + if err != nil { + return "", "", err + } + encrypted, err := sessionAlg.Encrypt([]byte(fmt.Sprintf(authz.SessionTokenFormat, sessionID, id))) + if err != nil { + return "", "", err + } + return id, base64.RawURLEncoding.EncodeToString(encrypted), nil + } +} + +type SessionChanged struct { + *domain.ObjectDetails + ID string + NewToken string +} + +func sessionWriteModelToSessionChanged(wm *SessionWriteModel) *SessionChanged { + return &SessionChanged{ + ObjectDetails: &domain.ObjectDetails{ + Sequence: wm.ProcessedSequence, + EventDate: wm.ChangeDate, + ResourceOwner: wm.ResourceOwner, + }, + ID: wm.AggregateID, + } +} diff --git a/internal/command/session_model.go b/internal/command/session_model.go new file mode 100644 index 000000000..e33169381 --- /dev/null +++ b/internal/command/session_model.go @@ -0,0 +1,139 @@ +package command + +import ( + "bytes" + "context" + "time" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/session" +) + +type SessionWriteModel struct { + eventstore.WriteModel + + TokenID string + UserID string + UserCheckedAt time.Time + PasswordCheckedAt time.Time + Metadata map[string][]byte + State domain.SessionState + + commands []eventstore.Command + aggregate *eventstore.Aggregate +} + +func NewSessionWriteModel(sessionID string, resourceOwner string) *SessionWriteModel { + return &SessionWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: sessionID, + ResourceOwner: resourceOwner, + }, + Metadata: make(map[string][]byte), + aggregate: &session.NewAggregate(sessionID, resourceOwner).Aggregate, + } +} + +func (wm *SessionWriteModel) Reduce() error { + for _, event := range wm.Events { + switch e := event.(type) { + case *session.AddedEvent: + wm.reduceAdded(e) + case *session.UserCheckedEvent: + wm.reduceUserChecked(e) + case *session.PasswordCheckedEvent: + wm.reducePasswordChecked(e) + case *session.TokenSetEvent: + wm.reduceTokenSet(e) + case *session.TerminateEvent: + wm.reduceTerminate() + } + } + return wm.WriteModel.Reduce() +} + +func (wm *SessionWriteModel) Query() *eventstore.SearchQueryBuilder { + query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + AddQuery(). + AggregateTypes(session.AggregateType). + AggregateIDs(wm.AggregateID). + EventTypes( + session.AddedType, + session.UserCheckedType, + session.PasswordCheckedType, + session.TokenSetType, + session.MetadataSetType, + session.TerminateType, + ). + Builder() + + if wm.ResourceOwner != "" { + query.ResourceOwner(wm.ResourceOwner) + } + return query +} + +func (wm *SessionWriteModel) reduceAdded(e *session.AddedEvent) { + wm.State = domain.SessionStateActive +} + +func (wm *SessionWriteModel) reduceUserChecked(e *session.UserCheckedEvent) { + wm.UserID = e.UserID + wm.UserCheckedAt = e.CheckedAt +} + +func (wm *SessionWriteModel) reducePasswordChecked(e *session.PasswordCheckedEvent) { + wm.PasswordCheckedAt = e.CheckedAt +} + +func (wm *SessionWriteModel) reduceTokenSet(e *session.TokenSetEvent) { + wm.TokenID = e.TokenID +} + +func (wm *SessionWriteModel) reduceTerminate() { + wm.State = domain.SessionStateTerminated +} + +func (wm *SessionWriteModel) Start(ctx context.Context) { + wm.commands = append(wm.commands, session.NewAddedEvent(ctx, wm.aggregate)) +} + +func (wm *SessionWriteModel) UserChecked(ctx context.Context, userID string, checkedAt time.Time) error { + wm.commands = append(wm.commands, session.NewUserCheckedEvent(ctx, wm.aggregate, userID, checkedAt)) + // set the userID so other checks can use it + wm.UserID = userID + return nil +} + +func (wm *SessionWriteModel) PasswordChecked(ctx context.Context, checkedAt time.Time) { + wm.commands = append(wm.commands, session.NewPasswordCheckedEvent(ctx, wm.aggregate, checkedAt)) +} + +func (wm *SessionWriteModel) SetToken(ctx context.Context, tokenID string) { + wm.commands = append(wm.commands, session.NewTokenSetEvent(ctx, wm.aggregate, tokenID)) +} + +func (wm *SessionWriteModel) ChangeMetadata(ctx context.Context, metadata map[string][]byte) { + var changed bool + for key, value := range metadata { + currentValue, exists := wm.Metadata[key] + + if len(value) != 0 { + // if a value is provided, and it's not equal, change it + if !bytes.Equal(currentValue, value) { + wm.Metadata[key] = value + changed = true + } + } else { + // if there's no / an empty value, we only need to remove it on existing entries + if exists { + delete(wm.Metadata, key) + changed = true + } + } + } + if changed { + wm.commands = append(wm.commands, session.NewMetadataSetEvent(ctx, wm.aggregate, wm.Metadata)) + } +} diff --git a/internal/command/session_test.go b/internal/command/session_test.go new file mode 100644 index 000000000..eb080480f --- /dev/null +++ b/internal/command/session_test.go @@ -0,0 +1,547 @@ +package command + +import ( + "context" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + caos_errs "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/id" + "github.com/zitadel/zitadel/internal/id/mock" + "github.com/zitadel/zitadel/internal/repository/session" + "github.com/zitadel/zitadel/internal/repository/user" +) + +func TestCommands_CreateSession(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + idGenerator id.Generator + tokenCreator func(sessionID string) (string, string, error) + } + type args struct { + ctx context.Context + checks []SessionCheck + metadata map[string][]byte + } + type res struct { + want *SessionChanged + err error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "id generator fails", + fields{ + idGenerator: mock.NewIDGeneratorExpectError(t, caos_errs.ThrowInternal(nil, "id", "generator failed")), + }, + args{ + ctx: context.Background(), + }, + res{ + err: caos_errs.ThrowInternal(nil, "id", "generator failed"), + }, + }, + { + "eventstore failed", + fields{ + idGenerator: mock.NewIDGeneratorExpectIDs(t, "sessionID"), + eventstore: eventstoreExpect(t, + expectFilterError(caos_errs.ThrowInternal(nil, "id", "filter failed")), + ), + }, + args{ + ctx: context.Background(), + }, + res{ + err: caos_errs.ThrowInternal(nil, "id", "filter failed"), + }, + }, + { + "empty session", + fields{ + idGenerator: mock.NewIDGeneratorExpectIDs(t, "sessionID"), + eventstore: eventstoreExpect(t, + expectFilter(), + expectPush( + eventPusherToEvents( + session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate), + session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, + "tokenID", + ), + ), + ), + ), + tokenCreator: func(sessionID string) (string, string, error) { + return "tokenID", + "token", + nil + }, + }, + args{ + ctx: authz.NewMockContext("", "org1", ""), + }, + res{ + want: &SessionChanged{ + ObjectDetails: &domain.ObjectDetails{ResourceOwner: "org1"}, + ID: "sessionID", + NewToken: "token", + }, + }, + }, + // the rest is tested in the Test_updateSession + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + idGenerator: tt.fields.idGenerator, + sessionTokenCreator: tt.fields.tokenCreator, + } + got, err := c.CreateSession(tt.args.ctx, tt.args.checks, tt.args.metadata) + require.ErrorIs(t, err, tt.res.err) + assert.Equal(t, tt.res.want, got) + }) + } +} + +func TestCommands_UpdateSession(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + tokenVerifier func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) + } + type args struct { + ctx context.Context + sessionID string + sessionToken string + checks []SessionCheck + metadata map[string][]byte + } + type res struct { + want *SessionChanged + err error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "eventstore failed", + fields{ + eventstore: eventstoreExpect(t, + expectFilterError(caos_errs.ThrowInternal(nil, "id", "filter failed")), + ), + }, + args{ + ctx: context.Background(), + }, + res{ + err: caos_errs.ThrowInternal(nil, "id", "filter failed"), + }, + }, + { + "invalid session token", + fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)), + eventFromEventPusher( + session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, + "tokenID")), + ), + ), + tokenVerifier: func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) { + return caos_errs.ThrowPermissionDenied(nil, "COMMAND-sGr42", "Errors.Session.Token.Invalid") + }, + }, + args{ + ctx: context.Background(), + sessionID: "sessionID", + sessionToken: "invalid", + }, + res{ + err: caos_errs.ThrowPermissionDenied(nil, "COMMAND-sGr42", "Errors.Session.Token.Invalid"), + }, + }, + { + "no change", + fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)), + eventFromEventPusher( + session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, + "tokenID")), + ), + ), + tokenVerifier: func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) { + return nil + }, + }, + args{ + ctx: context.Background(), + sessionID: "sessionID", + sessionToken: "token", + }, + res{ + want: &SessionChanged{ + ObjectDetails: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + ID: "sessionID", + NewToken: "", + }, + }, + }, + // the rest is tested in the Test_updateSession + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + sessionTokenVerifier: tt.fields.tokenVerifier, + } + got, err := c.UpdateSession(tt.args.ctx, tt.args.sessionID, tt.args.sessionToken, tt.args.checks, tt.args.metadata) + require.ErrorIs(t, err, tt.res.err) + assert.Equal(t, tt.res.want, got) + }) + } +} + +func TestCommands_updateSession(t *testing.T) { + testNow := time.Now() + type fields struct { + eventstore *eventstore.Eventstore + } + type args struct { + ctx context.Context + checks *SessionChecks + metadata map[string][]byte + } + type res struct { + want *SessionChanged + err error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "terminated", + fields{ + eventstore: eventstoreExpect(t), + }, + args{ + ctx: context.Background(), + checks: &SessionChecks{ + sessionWriteModel: &SessionWriteModel{State: domain.SessionStateTerminated}, + }, + }, + res{ + err: caos_errs.ThrowPreconditionFailed(nil, "COMAND-SAjeh", "Errors.Session.Terminated"), + }, + }, + { + "check failed", + fields{ + eventstore: eventstoreExpect(t), + }, + args{ + ctx: context.Background(), + checks: &SessionChecks{ + sessionWriteModel: NewSessionWriteModel("sessionID", "org1"), + checks: []SessionCheck{ + func(ctx context.Context, cmd *SessionChecks) error { + return caos_errs.ThrowInternal(nil, "id", "check failed") + }, + }, + }, + }, + res{ + err: caos_errs.ThrowInternal(nil, "id", "check failed"), + }, + }, + { + "no change", + fields{ + eventstore: eventstoreExpect(t), + }, + args{ + ctx: context.Background(), + checks: &SessionChecks{ + sessionWriteModel: NewSessionWriteModel("sessionID", "org1"), + checks: []SessionCheck{}, + }, + }, + res{ + want: &SessionChanged{ + ObjectDetails: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + ID: "sessionID", + NewToken: "", + }, + }, + }, + { + "set user, password, metadata and token", + fields{ + eventstore: eventstoreExpect(t, + expectPush( + eventPusherToEvents( + session.NewUserCheckedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, + "userID", testNow), + session.NewPasswordCheckedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, + testNow), + session.NewMetadataSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, + map[string][]byte{"key": []byte("value")}), + session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, + "tokenID"), + ), + ), + ), + }, + args{ + ctx: context.Background(), + checks: &SessionChecks{ + sessionWriteModel: NewSessionWriteModel("sessionID", "org1"), + checks: []SessionCheck{ + CheckUser("userID"), + CheckPassword("password"), + }, + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate, + "username", "", "", "", "", language.English, domain.GenderUnspecified, "", false), + ), + eventFromEventPusher( + user.NewHumanPasswordChangedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeHash, + Algorithm: "hash", + KeyID: "", + Crypted: []byte("password"), + }, false, ""), + ), + ), + ), + createToken: func(sessionID string) (string, string, error) { + return "tokenID", + "token", + nil + }, + userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), + now: func() time.Time { + return testNow + }, + }, + metadata: map[string][]byte{ + "key": []byte("value"), + }, + }, + res{ + want: &SessionChanged{ + ObjectDetails: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + ID: "sessionID", + NewToken: "token", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + } + got, err := c.updateSession(tt.args.ctx, tt.args.checks, tt.args.metadata) + require.ErrorIs(t, err, tt.res.err) + assert.Equal(t, tt.res.want, got) + }) + } +} + +func TestCommands_TerminateSession(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + tokenVerifier func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) + } + type args struct { + ctx context.Context + sessionID string + sessionToken string + } + type res struct { + want *domain.ObjectDetails + err error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + "eventstore failed", + fields{ + eventstore: eventstoreExpect(t, + expectFilterError(caos_errs.ThrowInternal(nil, "id", "filter failed")), + ), + }, + args{ + ctx: context.Background(), + }, + res{ + err: caos_errs.ThrowInternal(nil, "id", "filter failed"), + }, + }, + { + "invalid session token", + fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)), + eventFromEventPusher( + session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, + "tokenID")), + ), + ), + tokenVerifier: func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) { + return caos_errs.ThrowPermissionDenied(nil, "COMMAND-sGr42", "Errors.Session.Token.Invalid") + }, + }, + args{ + ctx: context.Background(), + sessionID: "sessionID", + sessionToken: "invalid", + }, + res{ + err: caos_errs.ThrowPermissionDenied(nil, "COMMAND-sGr42", "Errors.Session.Token.Invalid"), + }, + }, + { + "not active", + fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)), + eventFromEventPusher( + session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, + "tokenID")), + eventFromEventPusher( + session.NewTerminateEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)), + ), + ), + tokenVerifier: func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) { + return nil + }, + }, + args{ + ctx: context.Background(), + sessionID: "sessionID", + sessionToken: "token", + }, + res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + "push failed", + fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)), + eventFromEventPusher( + session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, + "tokenID"), + ), + ), + expectPushFailed( + caos_errs.ThrowInternal(nil, "id", "pushed failed"), + eventPusherToEvents( + session.NewTerminateEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)), + ), + ), + tokenVerifier: func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) { + return nil + }, + }, + args{ + ctx: context.Background(), + sessionID: "sessionID", + sessionToken: "token", + }, + res{ + err: caos_errs.ThrowInternal(nil, "id", "pushed failed"), + }, + }, + { + "terminate", + fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)), + eventFromEventPusher( + session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, + "tokenID"), + ), + ), + expectPush( + eventPusherToEvents( + session.NewTerminateEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)), + ), + ), + tokenVerifier: func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) { + return nil + }, + }, + args{ + ctx: context.Background(), + sessionID: "sessionID", + sessionToken: "token", + }, + res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + sessionTokenVerifier: tt.fields.tokenVerifier, + } + got, err := c.TerminateSession(tt.args.ctx, tt.args.sessionID, tt.args.sessionToken) + require.ErrorIs(t, err, tt.res.err) + assert.Equal(t, tt.res.want, got) + }) + } +} diff --git a/internal/command/user_v2_email.go b/internal/command/user_v2_email.go index d636b21a7..d00eb0f04 100644 --- a/internal/command/user_v2_email.go +++ b/internal/command/user_v2_email.go @@ -6,6 +6,7 @@ import ( "github.com/zitadel/logging" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" caos_errs "github.com/zitadel/zitadel/internal/errors" @@ -42,7 +43,7 @@ func (c *Commands) ChangeUserEmailVerified(ctx context.Context, userID, resource if err != nil { return nil, err } - if err = c.checkPermission(ctx, permissionUserWrite, cmd.aggregate.ResourceOwner, userID, false); err != nil { + if err = c.checkPermission(ctx, domain.PermissionUserWrite, cmd.aggregate.ResourceOwner, userID); err != nil { return nil, err } if err = cmd.Change(ctx, domain.EmailAddress(email)); err != nil { @@ -70,8 +71,10 @@ func (c *Commands) changeUserEmailWithGenerator(ctx context.Context, userID, res if err != nil { return nil, err } - if err = c.checkPermission(ctx, permissionUserWrite, cmd.aggregate.ResourceOwner, userID, true); err != nil { - return nil, err + if authz.GetCtxData(ctx).UserID != userID { + if err = c.checkPermission(ctx, domain.PermissionUserWrite, cmd.aggregate.ResourceOwner, userID); err != nil { + return nil, err + } } if err = cmd.Change(ctx, domain.EmailAddress(email)); err != nil { return nil, err diff --git a/internal/command/user_v2_email_test.go b/internal/command/user_v2_email_test.go index 9989229ef..af6478473 100644 --- a/internal/command/user_v2_email_test.go +++ b/internal/command/user_v2_email_test.go @@ -24,7 +24,7 @@ import ( func TestCommands_ChangeUserEmail(t *testing.T) { type fields struct { eventstore *eventstore.Eventstore - checkPermission permissionCheck + checkPermission domain.PermissionCheck } type args struct { userID string @@ -174,7 +174,7 @@ func TestCommands_ChangeUserEmail(t *testing.T) { func TestCommands_ChangeUserEmailURLTemplate(t *testing.T) { type fields struct { eventstore *eventstore.Eventstore - checkPermission permissionCheck + checkPermission domain.PermissionCheck } type args struct { userID string @@ -300,7 +300,7 @@ func TestCommands_ChangeUserEmailURLTemplate(t *testing.T) { func TestCommands_ChangeUserEmailReturnCode(t *testing.T) { type fields struct { eventstore *eventstore.Eventstore - checkPermission permissionCheck + checkPermission domain.PermissionCheck } type args struct { userID string @@ -410,7 +410,7 @@ func TestCommands_ChangeUserEmailReturnCode(t *testing.T) { func TestCommands_ChangeUserEmailVerified(t *testing.T) { type fields struct { eventstore *eventstore.Eventstore - checkPermission permissionCheck + checkPermission domain.PermissionCheck } type args struct { userID string @@ -569,7 +569,7 @@ func TestCommands_ChangeUserEmailVerified(t *testing.T) { func TestCommands_changeUserEmailWithGenerator(t *testing.T) { type fields struct { eventstore *eventstore.Eventstore - checkPermission permissionCheck + checkPermission domain.PermissionCheck } type args struct { userID string diff --git a/internal/database/type.go b/internal/database/type.go index 5757529c8..8360bc1f1 100644 --- a/internal/database/type.go +++ b/internal/database/type.go @@ -2,13 +2,14 @@ package database import ( "database/sql/driver" + "encoding/json" "github.com/jackc/pgtype" ) type StringArray []string -// Scan implements the `database/sql.Scanner` interface. +// Scan implements the [database/sql.Scanner] interface. func (s *StringArray) Scan(src any) error { array := new(pgtype.TextArray) if err := array.Scan(src); err != nil { @@ -20,7 +21,7 @@ func (s *StringArray) Scan(src any) error { return nil } -// Value implements the `database/sql/driver.Valuer` interface. +// Value implements the [database/sql/driver.Valuer] interface. func (s StringArray) Value() (driver.Value, error) { if len(s) == 0 { return nil, nil @@ -40,7 +41,7 @@ type enumField interface { type EnumArray[F enumField] []F -// Scan implements the `database/sql.Scanner` interface. +// Scan implements the [database/sql.Scanner] interface. func (s *EnumArray[F]) Scan(src any) error { array := new(pgtype.Int2Array) if err := array.Scan(src); err != nil { @@ -57,7 +58,7 @@ func (s *EnumArray[F]) Scan(src any) error { return nil } -// Value implements the `database/sql/driver.Valuer` interface. +// Value implements the [database/sql/driver.Valuer] interface. func (s EnumArray[F]) Value() (driver.Value, error) { if len(s) == 0 { return nil, nil @@ -70,3 +71,25 @@ func (s EnumArray[F]) Value() (driver.Value, error) { return array.Value() } + +type Map[V any] map[string]V + +// Scan implements the [database/sql.Scanner] interface. +func (m *Map[V]) Scan(src any) error { + bytea := new(pgtype.Bytea) + if err := bytea.Scan(src); err != nil { + return err + } + if len(bytea.Bytes) == 0 { + return nil + } + return json.Unmarshal(bytea.Bytes, &m) +} + +// Value implements the [database/sql/driver.Valuer] interface. +func (m Map[V]) Value() (driver.Value, error) { + if len(m) == 0 { + return nil, nil + } + return json.Marshal(m) +} diff --git a/internal/database/type_test.go b/internal/database/type_test.go new file mode 100644 index 000000000..4586f1413 --- /dev/null +++ b/internal/database/type_test.go @@ -0,0 +1,119 @@ +package database + +import ( + "database/sql/driver" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMap_Scan(t *testing.T) { + type args struct { + src any + } + type res[V any] struct { + want Map[V] + err bool + } + type testCase[V any] struct { + name string + m Map[V] + args args + res[V] + } + tests := []testCase[string]{ + { + "null", + Map[string]{}, + args{src: "invalid"}, + res[string]{ + want: Map[string]{}, + err: true, + }, + }, + { + "null", + Map[string]{}, + args{src: nil}, + res[string]{ + want: Map[string]{}, + }, + }, + { + "empty", + Map[string]{}, + args{src: []byte(`{}`)}, + res[string]{ + want: Map[string]{}, + }, + }, + { + "set", + Map[string]{}, + args{src: []byte(`{"key": "value"}`)}, + res[string]{ + want: Map[string]{ + "key": "value", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := tt.m.Scan(tt.args.src); (err != nil) != tt.res.err { + t.Errorf("Scan() error = %v, wantErr %v", err, tt.res.err) + } + assert.Equal(t, tt.res.want, tt.m) + }) + } +} + +func TestMap_Value(t *testing.T) { + type res struct { + want driver.Value + err bool + } + type testCase[V any] struct { + name string + m Map[V] + res res + } + tests := []testCase[string]{ + { + "nil", + nil, + res{ + want: nil, + }, + }, + { + "empty", + Map[string]{}, + res{ + want: nil, + }, + }, + { + "set", + Map[string]{ + "key": "value", + }, + res{ + want: driver.Value([]byte(`{"key":"value"}`)), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.m.Value() + if tt.res.err { + assert.Error(t, err) + } + if !tt.res.err { + require.NoError(t, err) + assert.Equalf(t, tt.res.want, got, "Value()") + } + }) + } +} diff --git a/internal/domain/permission.go b/internal/domain/permission.go index a9a1df5f4..5a51d7bdb 100644 --- a/internal/domain/permission.go +++ b/internal/domain/permission.go @@ -1,5 +1,7 @@ package domain +import "context" + type Permissions struct { Permissions []string } @@ -21,3 +23,12 @@ func (p *Permissions) appendPermission(ctxID, permission string) { } p.Permissions = append(p.Permissions, permission) } + +type PermissionCheck func(ctx context.Context, permission, orgID, resourceID string) (err error) + +const ( + PermissionUserWrite = "user.write" + PermissionSessionRead = "session.read" + PermissionSessionWrite = "session.write" + PermissionSessionDelete = "session.delete" +) diff --git a/internal/domain/session.go b/internal/domain/session.go new file mode 100644 index 000000000..84a74a8f6 --- /dev/null +++ b/internal/domain/session.go @@ -0,0 +1,9 @@ +package domain + +type SessionState int32 + +const ( + SessionStateUnspecified SessionState = iota + SessionStateActive + SessionStateTerminated +) diff --git a/internal/id/mock/generator.mock.impl.go b/internal/id/mock/generator.mock.impl.go index e61f80398..f61a48b9d 100644 --- a/internal/id/mock/generator.mock.impl.go +++ b/internal/id/mock/generator.mock.impl.go @@ -25,3 +25,9 @@ func ExpectID(t *testing.T, id string) *MockGenerator { m.EXPECT().Next().Return(id, nil) return m } + +func NewIDGeneratorExpectError(t *testing.T, err error) *MockGenerator { + m := NewMockGenerator(gomock.NewController(t)) + m.EXPECT().Next().Return("", err) + return m +} diff --git a/internal/query/org.go b/internal/query/org.go index 26bd3cd67..d5c90f08f 100644 --- a/internal/query/org.go +++ b/internal/query/org.go @@ -180,12 +180,20 @@ func (q *Queries) IsOrgUnique(ctx context.Context, name, domain string) (isUniqu return scan(row) } -func (q *Queries) ExistsOrg(ctx context.Context, id string) (err error) { +func (q *Queries) ExistsOrg(ctx context.Context, id, domain string) (verifiedID string, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - _, err = q.OrgByID(ctx, true, id) - return err + var org *Org + if id != "" { + org, err = q.OrgByID(ctx, true, id) + } else { + org, err = q.OrgByVerifiedDomain(ctx, domain) + } + if err != nil { + return "", err + } + return org.ID, nil } func (q *Queries) SearchOrgs(ctx context.Context, queries *OrgSearchQueries) (orgs *Orgs, err error) { diff --git a/internal/query/projection/projection.go b/internal/query/projection/projection.go index fb461927c..45670b8ba 100644 --- a/internal/query/projection/projection.go +++ b/internal/query/projection/projection.go @@ -65,6 +65,7 @@ var ( NotificationsProjection interface{} NotificationsQuotaProjection interface{} DeviceAuthProjection *deviceAuthProjection + SessionProjection *sessionProjection ) type projection interface { @@ -141,6 +142,7 @@ func Create(ctx context.Context, sqlClient *database.DB, es *eventstore.Eventsto SecurityPolicyProjection = newSecurityPolicyProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["security_policies"])) NotificationPolicyProjection = newNotificationPolicyProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["notification_policies"])) DeviceAuthProjection = newDeviceAuthProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["device_auth"])) + SessionProjection = newSessionProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["sessions"])) newProjectionsList() return nil } @@ -237,5 +239,6 @@ func newProjectionsList() { SecurityPolicyProjection, NotificationPolicyProjection, DeviceAuthProjection, + SessionProjection, } } diff --git a/internal/query/projection/session.go b/internal/query/projection/session.go new file mode 100644 index 000000000..9858de473 --- /dev/null +++ b/internal/query/projection/session.go @@ -0,0 +1,221 @@ +package projection + +import ( + "context" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/handler" + "github.com/zitadel/zitadel/internal/eventstore/handler/crdb" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/session" +) + +const ( + SessionsProjectionTable = "projections.sessions" + + SessionColumnID = "id" + SessionColumnCreationDate = "creation_date" + SessionColumnChangeDate = "change_date" + SessionColumnSequence = "sequence" + SessionColumnState = "state" + SessionColumnResourceOwner = "resource_owner" + SessionColumnInstanceID = "instance_id" + SessionColumnCreator = "creator" + SessionColumnUserID = "user_id" + SessionColumnUserCheckedAt = "user_checked_at" + SessionColumnPasswordCheckedAt = "password_checked_at" + SessionColumnMetadata = "metadata" + SessionColumnTokenID = "token_id" +) + +type sessionProjection struct { + crdb.StatementHandler +} + +func newSessionProjection(ctx context.Context, config crdb.StatementHandlerConfig) *sessionProjection { + p := new(sessionProjection) + config.ProjectionName = SessionsProjectionTable + config.Reducers = p.reducers() + config.InitCheck = crdb.NewMultiTableCheck( + crdb.NewTable([]*crdb.Column{ + crdb.NewColumn(SessionColumnID, crdb.ColumnTypeText), + crdb.NewColumn(SessionColumnCreationDate, crdb.ColumnTypeTimestamp), + crdb.NewColumn(SessionColumnChangeDate, crdb.ColumnTypeTimestamp), + crdb.NewColumn(SessionColumnSequence, crdb.ColumnTypeInt64), + crdb.NewColumn(SessionColumnState, crdb.ColumnTypeEnum), + crdb.NewColumn(SessionColumnResourceOwner, crdb.ColumnTypeText), + crdb.NewColumn(SessionColumnInstanceID, crdb.ColumnTypeText), + crdb.NewColumn(SessionColumnCreator, crdb.ColumnTypeText), + crdb.NewColumn(SessionColumnUserID, crdb.ColumnTypeText, crdb.Nullable()), + crdb.NewColumn(SessionColumnUserCheckedAt, crdb.ColumnTypeTimestamp, crdb.Nullable()), + crdb.NewColumn(SessionColumnPasswordCheckedAt, crdb.ColumnTypeTimestamp, crdb.Nullable()), + crdb.NewColumn(SessionColumnMetadata, crdb.ColumnTypeJSONB, crdb.Nullable()), + crdb.NewColumn(SessionColumnTokenID, crdb.ColumnTypeText, crdb.Nullable()), + }, + crdb.NewPrimaryKey(SessionColumnInstanceID, SessionColumnID), + ), + ) + p.StatementHandler = crdb.NewStatementHandler(ctx, config) + return p +} + +func (p *sessionProjection) reducers() []handler.AggregateReducer { + return []handler.AggregateReducer{ + { + Aggregate: session.AggregateType, + EventRedusers: []handler.EventReducer{ + { + Event: session.AddedType, + Reduce: p.reduceSessionAdded, + }, + { + Event: session.UserCheckedType, + Reduce: p.reduceUserChecked, + }, + { + Event: session.PasswordCheckedType, + Reduce: p.reducePasswordChecked, + }, + { + Event: session.TokenSetType, + Reduce: p.reduceTokenSet, + }, + { + Event: session.MetadataSetType, + Reduce: p.reduceMetadataSet, + }, + { + Event: session.TerminateType, + Reduce: p.reduceSessionTerminated, + }, + }, + }, + { + Aggregate: instance.AggregateType, + EventRedusers: []handler.EventReducer{ + { + Event: instance.InstanceRemovedEventType, + Reduce: reduceInstanceRemovedHelper(SMSColumnInstanceID), + }, + }, + }, + } +} + +func (p *sessionProjection) reduceSessionAdded(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*session.AddedEvent) + if !ok { + return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-Sfrgf", "reduce.wrong.event.type %s", session.AddedType) + } + + return crdb.NewCreateStatement( + e, + []handler.Column{ + handler.NewCol(SessionColumnID, e.Aggregate().ID), + handler.NewCol(SessionColumnInstanceID, e.Aggregate().InstanceID), + handler.NewCol(SessionColumnCreationDate, e.CreationDate()), + handler.NewCol(SessionColumnChangeDate, e.CreationDate()), + handler.NewCol(SessionColumnResourceOwner, e.Aggregate().ResourceOwner), + handler.NewCol(SessionColumnState, domain.SessionStateActive), + handler.NewCol(SessionColumnSequence, e.Sequence()), + handler.NewCol(SessionColumnCreator, e.User), + }, + ), nil +} + +func (p *sessionProjection) reduceUserChecked(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*session.UserCheckedEvent) + if !ok { + return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-saDg5", "reduce.wrong.event.type %s", session.UserCheckedType) + } + return crdb.NewUpdateStatement( + e, + []handler.Column{ + handler.NewCol(SessionColumnChangeDate, e.CreationDate()), + handler.NewCol(SessionColumnSequence, e.Sequence()), + handler.NewCol(SessionColumnUserID, e.UserID), + handler.NewCol(SessionColumnUserCheckedAt, e.CheckedAt), + }, + []handler.Condition{ + handler.NewCond(SessionColumnID, e.Aggregate().ID), + handler.NewCond(SessionColumnInstanceID, e.Aggregate().InstanceID), + }, + ), nil +} + +func (p *sessionProjection) reducePasswordChecked(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*session.PasswordCheckedEvent) + if !ok { + return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-SDgrb", "reduce.wrong.event.type %s", session.PasswordCheckedType) + } + + return crdb.NewUpdateStatement( + e, + []handler.Column{ + handler.NewCol(SessionColumnChangeDate, e.CreationDate()), + handler.NewCol(SessionColumnSequence, e.Sequence()), + handler.NewCol(SessionColumnPasswordCheckedAt, e.CheckedAt), + }, + []handler.Condition{ + handler.NewCond(SessionColumnID, e.Aggregate().ID), + handler.NewCond(SessionColumnInstanceID, e.Aggregate().InstanceID), + }, + ), nil +} + +func (p *sessionProjection) reduceTokenSet(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*session.TokenSetEvent) + if !ok { + return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-SAfd3", "reduce.wrong.event.type %s", session.TokenSetType) + } + + return crdb.NewUpdateStatement( + e, + []handler.Column{ + handler.NewCol(SessionColumnChangeDate, e.CreationDate()), + handler.NewCol(SessionColumnSequence, e.Sequence()), + handler.NewCol(SessionColumnTokenID, e.TokenID), + }, + []handler.Condition{ + handler.NewCond(SessionColumnID, e.Aggregate().ID), + handler.NewCond(SessionColumnInstanceID, e.Aggregate().InstanceID), + }, + ), nil +} + +func (p *sessionProjection) reduceMetadataSet(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*session.MetadataSetEvent) + if !ok { + return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-SAfd3", "reduce.wrong.event.type %s", session.MetadataSetType) + } + + return crdb.NewUpdateStatement( + e, + []handler.Column{ + handler.NewCol(SessionColumnChangeDate, e.CreationDate()), + handler.NewCol(SessionColumnSequence, e.Sequence()), + handler.NewCol(SessionColumnMetadata, e.Metadata), + }, + []handler.Condition{ + handler.NewCond(SessionColumnID, e.Aggregate().ID), + handler.NewCond(SessionColumnInstanceID, e.Aggregate().InstanceID), + }, + ), nil +} + +func (p *sessionProjection) reduceSessionTerminated(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*session.TerminateEvent) + if !ok { + return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-SAftn", "reduce.wrong.event.type %s", session.TerminateType) + } + + return crdb.NewDeleteStatement( + e, + []handler.Condition{ + handler.NewCond(SessionColumnID, e.Aggregate().ID), + handler.NewCond(SessionColumnInstanceID, e.Aggregate().InstanceID), + }, + ), nil +} diff --git a/internal/query/projection/session_test.go b/internal/query/projection/session_test.go new file mode 100644 index 000000000..6cd3ae2e5 --- /dev/null +++ b/internal/query/projection/session_test.go @@ -0,0 +1,260 @@ +package projection + +import ( + "testing" + "time" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/handler" + "github.com/zitadel/zitadel/internal/eventstore/repository" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/session" +) + +func TestSessionProjection_reduces(t *testing.T) { + type args struct { + event func(t *testing.T) eventstore.Event + } + tests := []struct { + name string + args args + reduce func(event eventstore.Event) (*handler.Statement, error) + want wantReduce + }{ + { + name: "instance reduceSessionAdded", + args: args{ + event: getEvent(testEvent( + session.AddedType, + session.AggregateType, + []byte(`{}`), + ), session.AddedEventMapper), + }, + reduce: (&sessionProjection{}).reduceSessionAdded, + want: wantReduce{ + aggregateType: eventstore.AggregateType("session"), + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "INSERT INTO projections.sessions (id, instance_id, creation_date, change_date, resource_owner, state, sequence, creator) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + expectedArgs: []interface{}{ + "agg-id", + "instance-id", + anyArg{}, + anyArg{}, + "ro-id", + domain.SessionStateActive, + uint64(15), + "editor-user", + }, + }, + }, + }, + }, + }, + { + name: "instance reduceUserChecked", + args: args{ + event: getEvent(testEvent( + session.AddedType, + session.AggregateType, + []byte(`{ + "userId": "user-id", + "checkedAt": "2023-05-04T00:00:00Z" + }`), + ), session.UserCheckedEventMapper), + }, + reduce: (&sessionProjection{}).reduceUserChecked, + want: wantReduce{ + aggregateType: eventstore.AggregateType("session"), + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE projections.sessions SET (change_date, sequence, user_id, user_checked_at) = ($1, $2, $3, $4) WHERE (id = $5) AND (instance_id = $6)", + expectedArgs: []interface{}{ + anyArg{}, + anyArg{}, + "user-id", + time.Date(2023, time.May, 4, 0, 0, 0, 0, time.UTC), + "agg-id", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "instance reducePasswordChecked", + args: args{ + event: getEvent(testEvent( + session.AddedType, + session.AggregateType, + []byte(`{ + "checkedAt": "2023-05-04T00:00:00Z" + }`), + ), session.PasswordCheckedEventMapper), + }, + reduce: (&sessionProjection{}).reducePasswordChecked, + want: wantReduce{ + aggregateType: eventstore.AggregateType("session"), + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE projections.sessions SET (change_date, sequence, password_checked_at) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", + expectedArgs: []interface{}{ + anyArg{}, + anyArg{}, + time.Date(2023, time.May, 4, 0, 0, 0, 0, time.UTC), + "agg-id", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "instance reduceTokenSet", + args: args{ + event: getEvent(testEvent( + session.TokenSetType, + session.AggregateType, + []byte(`{ + "tokenID": "tokenID" + }`), + ), session.TokenSetEventMapper), + }, + reduce: (&sessionProjection{}).reduceTokenSet, + want: wantReduce{ + aggregateType: eventstore.AggregateType("session"), + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE projections.sessions SET (change_date, sequence, token_id) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", + expectedArgs: []interface{}{ + anyArg{}, + anyArg{}, + "tokenID", + "agg-id", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "instance reduceMetadataSet", + args: args{ + event: getEvent(testEvent( + session.MetadataSetType, + session.AggregateType, + []byte(`{ + "metadata": { + "key": "dmFsdWU=" + } + }`), + ), session.MetadataSetEventMapper), + }, + reduce: (&sessionProjection{}).reduceMetadataSet, + want: wantReduce{ + aggregateType: eventstore.AggregateType("session"), + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE projections.sessions SET (change_date, sequence, metadata) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", + expectedArgs: []interface{}{ + anyArg{}, + anyArg{}, + map[string][]byte{ + "key": []byte("value"), + }, + "agg-id", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "instance reduceSessionTerminated", + args: args{ + event: getEvent(testEvent( + session.TerminateType, + session.AggregateType, + []byte(`{}`), + ), session.TerminateEventMapper), + }, + reduce: (&sessionProjection{}).reduceSessionTerminated, + want: wantReduce{ + aggregateType: eventstore.AggregateType("session"), + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.sessions WHERE (id = $1) AND (instance_id = $2)", + expectedArgs: []interface{}{ + "agg-id", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "instance reduceInstanceRemoved", + args: args{ + event: getEvent(testEvent( + repository.EventType(instance.InstanceRemovedEventType), + instance.AggregateType, + nil, + ), instance.InstanceRemovedEventMapper), + }, + reduce: reduceInstanceRemovedHelper(SessionColumnInstanceID), + want: wantReduce{ + aggregateType: eventstore.AggregateType("instance"), + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.sessions WHERE (instance_id = $1)", + expectedArgs: []interface{}{ + "agg-id", + }, + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event := baseEvent(t) + got, err := tt.reduce(event) + if !errors.IsErrorInvalidArgument(err) { + t.Errorf("no wrong event mapping: %v, got: %v", err, got) + } + + event = tt.args.event(t) + got, err = tt.reduce(event) + assertReduce(t, got, err, SessionsProjectionTable, tt.want) + }) + } +} diff --git a/internal/query/query.go b/internal/query/query.go index 0fc324f84..7e3f10dcd 100644 --- a/internal/query/query.go +++ b/internal/query/query.go @@ -22,6 +22,7 @@ import ( "github.com/zitadel/zitadel/internal/repository/keypair" "github.com/zitadel/zitadel/internal/repository/org" "github.com/zitadel/zitadel/internal/repository/project" + "github.com/zitadel/zitadel/internal/repository/session" usr_repo "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/repository/usergrant" ) @@ -30,7 +31,8 @@ type Queries struct { eventstore *eventstore.Eventstore client *database.DB - idpConfigEncryption crypto.EncryptionAlgorithm + idpConfigEncryption crypto.EncryptionAlgorithm + sessionTokenVerifier func(ctx context.Context, sessionToken string, sessionID string, tokenID string) (err error) DefaultLanguage language.Tag LoginDir http.FileSystem @@ -43,7 +45,16 @@ type Queries struct { multifactors domain.MultifactorConfigs } -func StartQueries(ctx context.Context, es *eventstore.Eventstore, sqlClient *database.DB, projections projection.Config, defaults sd.SystemDefaults, idpConfigEncryption, otpEncryption, keyEncryptionAlgorithm crypto.EncryptionAlgorithm, certEncryptionAlgorithm crypto.EncryptionAlgorithm, zitadelRoles []authz.RoleMapping) (repo *Queries, err error) { +func StartQueries( + ctx context.Context, + es *eventstore.Eventstore, + sqlClient *database.DB, + projections projection.Config, + defaults sd.SystemDefaults, + idpConfigEncryption, otpEncryption, keyEncryptionAlgorithm, certEncryptionAlgorithm crypto.EncryptionAlgorithm, + zitadelRoles []authz.RoleMapping, + sessionTokenVerifier func(ctx context.Context, sessionToken string, sessionID string, tokenID string) (err error), +) (repo *Queries, err error) { statikLoginFS, err := fs.NewWithNamespace("login") if err != nil { return nil, fmt.Errorf("unable to start login statik dir") @@ -63,6 +74,7 @@ func StartQueries(ctx context.Context, es *eventstore.Eventstore, sqlClient *dat LoginTranslationFileContents: make(map[string][]byte), NotificationTranslationFileContents: make(map[string][]byte), zitadelRoles: zitadelRoles, + sessionTokenVerifier: sessionTokenVerifier, } iam_repo.RegisterEventMappers(repo.eventstore) usr_repo.RegisterEventMappers(repo.eventstore) @@ -71,6 +83,7 @@ func StartQueries(ctx context.Context, es *eventstore.Eventstore, sqlClient *dat action.RegisterEventMappers(repo.eventstore) keypair.RegisterEventMappers(repo.eventstore) usergrant.RegisterEventMappers(repo.eventstore) + session.RegisterEventMappers(repo.eventstore) repo.idpConfigEncryption = idpConfigEncryption repo.multifactors = domain.MultifactorConfigs{ diff --git a/internal/query/session.go b/internal/query/session.go new file mode 100644 index 000000000..ba30c64da --- /dev/null +++ b/internal/query/session.go @@ -0,0 +1,320 @@ +package query + +import ( + "context" + "database/sql" + errs "errors" + "time" + + sq "github.com/Masterminds/squirrel" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/call" + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/query/projection" + "github.com/zitadel/zitadel/internal/telemetry/tracing" +) + +type Sessions struct { + SearchResponse + Sessions []*Session +} + +type Session struct { + ID string + CreationDate time.Time + ChangeDate time.Time + Sequence uint64 + State domain.SessionState + ResourceOwner string + Creator string + UserFactor SessionUserFactor + PasswordFactor SessionPasswordFactor + Metadata map[string][]byte +} + +type SessionUserFactor struct { + UserID string + UserCheckedAt time.Time + LoginName string + DisplayName string +} + +type SessionPasswordFactor struct { + PasswordCheckedAt time.Time +} + +type SessionsSearchQueries struct { + SearchRequest + Queries []SearchQuery +} + +func (q *SessionsSearchQueries) toQuery(query sq.SelectBuilder) sq.SelectBuilder { + query = q.SearchRequest.toQuery(query) + for _, q := range q.Queries { + query = q.toQuery(query) + } + return query +} + +var ( + sessionsTable = table{ + name: projection.SessionsProjectionTable, + instanceIDCol: projection.SessionColumnInstanceID, + } + SessionColumnID = Column{ + name: projection.SessionColumnID, + table: sessionsTable, + } + SessionColumnCreationDate = Column{ + name: projection.SessionColumnCreationDate, + table: sessionsTable, + } + SessionColumnChangeDate = Column{ + name: projection.SessionColumnChangeDate, + table: sessionsTable, + } + SessionColumnSequence = Column{ + name: projection.SessionColumnSequence, + table: sessionsTable, + } + SessionColumnState = Column{ + name: projection.SessionColumnState, + table: sessionsTable, + } + SessionColumnResourceOwner = Column{ + name: projection.SessionColumnResourceOwner, + table: sessionsTable, + } + SessionColumnInstanceID = Column{ + name: projection.SessionColumnInstanceID, + table: sessionsTable, + } + SessionColumnCreator = Column{ + name: projection.SessionColumnCreator, + table: sessionsTable, + } + SessionColumnUserID = Column{ + name: projection.SessionColumnUserID, + table: sessionsTable, + } + SessionColumnUserCheckedAt = Column{ + name: projection.SessionColumnUserCheckedAt, + table: sessionsTable, + } + SessionColumnPasswordCheckedAt = Column{ + name: projection.SessionColumnPasswordCheckedAt, + table: sessionsTable, + } + SessionColumnMetadata = Column{ + name: projection.SessionColumnMetadata, + table: sessionsTable, + } + SessionColumnToken = Column{ + name: projection.SessionColumnTokenID, + table: sessionsTable, + } +) + +func (q *Queries) SessionByID(ctx context.Context, id, sessionToken string) (_ *Session, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + query, scan := prepareSessionQuery(ctx, q.client) + stmt, args, err := query.Where( + sq.Eq{ + SessionColumnID.identifier(): id, + SessionColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), + }, + ).ToSql() + if err != nil { + return nil, errors.ThrowInternal(err, "QUERY-dn9JW", "Errors.Query.SQLStatement") + } + + row := q.client.QueryRowContext(ctx, stmt, args...) + session, tokenID, err := scan(row) + if err != nil { + return nil, err + } + if sessionToken == "" { + return session, nil + } + if err := q.sessionTokenVerifier(ctx, sessionToken, session.ID, tokenID); err != nil { + return nil, errors.ThrowPermissionDenied(nil, "QUERY-dsfr3", "Errors.PermissionDenied") + } + return session, nil +} + +func (q *Queries) SearchSessions(ctx context.Context, queries *SessionsSearchQueries) (_ *Sessions, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + query, scan := prepareSessionsQuery(ctx, q.client) + stmt, args, err := queries.toQuery(query). + Where(sq.Eq{ + SessionColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), + }).ToSql() + if err != nil { + return nil, errors.ThrowInvalidArgument(err, "QUERY-sn9Jf", "Errors.Query.InvalidRequest") + } + + rows, err := q.client.QueryContext(ctx, stmt, args...) + if err != nil || rows.Err() != nil { + return nil, errors.ThrowInternal(err, "QUERY-Sfg42", "Errors.Internal") + } + sessions, err := scan(rows) + if err != nil { + return nil, err + } + sessions.LatestSequence, err = q.latestSequence(ctx, sessionsTable) + return sessions, err +} + +func NewSessionIDsSearchQuery(ids []string) (SearchQuery, error) { + list := make([]interface{}, len(ids)) + for i, value := range ids { + list[i] = value + } + return NewListQuery(SessionColumnID, list, ListIn) +} + +func NewSessionCreatorSearchQuery(creator string) (SearchQuery, error) { + return NewTextQuery(SessionColumnCreator, creator, TextEquals) +} + +func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (*Session, string, error)) { + return sq.Select( + SessionColumnID.identifier(), + SessionColumnCreationDate.identifier(), + SessionColumnChangeDate.identifier(), + SessionColumnSequence.identifier(), + SessionColumnState.identifier(), + SessionColumnResourceOwner.identifier(), + SessionColumnCreator.identifier(), + SessionColumnUserID.identifier(), + SessionColumnUserCheckedAt.identifier(), + LoginNameNameCol.identifier(), + HumanDisplayNameCol.identifier(), + SessionColumnPasswordCheckedAt.identifier(), + SessionColumnMetadata.identifier(), + SessionColumnToken.identifier(), + ).From(sessionsTable.identifier()). + LeftJoin(join(LoginNameUserIDCol, SessionColumnUserID)). + LeftJoin(join(HumanUserIDCol, SessionColumnUserID) + db.Timetravel(call.Took(ctx))). + PlaceholderFormat(sq.Dollar), func(row *sql.Row) (*Session, string, error) { + session := new(Session) + + var ( + userID sql.NullString + userCheckedAt sql.NullTime + loginName sql.NullString + displayName sql.NullString + passwordCheckedAt sql.NullTime + metadata database.Map[[]byte] + token sql.NullString + ) + + err := row.Scan( + &session.ID, + &session.CreationDate, + &session.ChangeDate, + &session.Sequence, + &session.State, + &session.ResourceOwner, + &session.Creator, + &userID, + &userCheckedAt, + &loginName, + &displayName, + &passwordCheckedAt, + &metadata, + &token, + ) + + if err != nil { + if errs.Is(err, sql.ErrNoRows) { + return nil, "", errors.ThrowNotFound(err, "QUERY-SFeaa", "Errors.Session.NotExisting") + } + return nil, "", errors.ThrowInternal(err, "QUERY-SAder", "Errors.Internal") + } + + session.UserFactor.UserID = userID.String + session.UserFactor.UserCheckedAt = userCheckedAt.Time + session.UserFactor.LoginName = loginName.String + session.UserFactor.DisplayName = displayName.String + session.PasswordFactor.PasswordCheckedAt = passwordCheckedAt.Time + session.Metadata = metadata + + return session, token.String, nil + } +} + +func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Rows) (*Sessions, error)) { + return sq.Select( + SessionColumnID.identifier(), + SessionColumnCreationDate.identifier(), + SessionColumnChangeDate.identifier(), + SessionColumnSequence.identifier(), + SessionColumnState.identifier(), + SessionColumnResourceOwner.identifier(), + SessionColumnCreator.identifier(), + SessionColumnUserID.identifier(), + SessionColumnUserCheckedAt.identifier(), + LoginNameNameCol.identifier(), + HumanDisplayNameCol.identifier(), + SessionColumnPasswordCheckedAt.identifier(), + SessionColumnMetadata.identifier(), + countColumn.identifier(), + ).From(sessionsTable.identifier()). + LeftJoin(join(LoginNameUserIDCol, SessionColumnUserID)). + LeftJoin(join(HumanUserIDCol, SessionColumnUserID) + db.Timetravel(call.Took(ctx))). + PlaceholderFormat(sq.Dollar), func(rows *sql.Rows) (*Sessions, error) { + sessions := &Sessions{Sessions: []*Session{}} + + for rows.Next() { + session := new(Session) + + var ( + userID sql.NullString + userCheckedAt sql.NullTime + loginName sql.NullString + displayName sql.NullString + passwordCheckedAt sql.NullTime + metadata database.Map[[]byte] + ) + + err := rows.Scan( + &session.ID, + &session.CreationDate, + &session.ChangeDate, + &session.Sequence, + &session.State, + &session.ResourceOwner, + &session.Creator, + &userID, + &userCheckedAt, + &loginName, + &displayName, + &passwordCheckedAt, + &metadata, + &sessions.Count, + ) + + if err != nil { + return nil, errors.ThrowInternal(err, "QUERY-SAfeg", "Errors.Internal") + } + session.UserFactor.UserID = userID.String + session.UserFactor.UserCheckedAt = userCheckedAt.Time + session.UserFactor.LoginName = loginName.String + session.UserFactor.DisplayName = displayName.String + session.PasswordFactor.PasswordCheckedAt = passwordCheckedAt.Time + session.Metadata = metadata + + sessions.Sessions = append(sessions.Sessions, session) + } + + return sessions, nil + } +} diff --git a/internal/query/sessions_test.go b/internal/query/sessions_test.go new file mode 100644 index 000000000..eeff881d8 --- /dev/null +++ b/internal/query/sessions_test.go @@ -0,0 +1,396 @@ +package query + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "fmt" + "regexp" + "testing" + + sq "github.com/Masterminds/squirrel" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/domain" + errs "github.com/zitadel/zitadel/internal/errors" +) + +var ( + expectedSessionQuery = regexp.QuoteMeta(`SELECT projections.sessions.id,` + + ` projections.sessions.creation_date,` + + ` projections.sessions.change_date,` + + ` projections.sessions.sequence,` + + ` projections.sessions.state,` + + ` projections.sessions.resource_owner,` + + ` projections.sessions.creator,` + + ` projections.sessions.user_id,` + + ` projections.sessions.user_checked_at,` + + ` projections.login_names2.login_name,` + + ` projections.users8_humans.display_name,` + + ` projections.sessions.password_checked_at,` + + ` projections.sessions.metadata,` + + ` projections.sessions.token_id` + + ` FROM projections.sessions` + + ` LEFT JOIN projections.login_names2 ON projections.sessions.user_id = projections.login_names2.user_id AND projections.sessions.instance_id = projections.login_names2.instance_id` + + ` LEFT JOIN projections.users8_humans ON projections.sessions.user_id = projections.users8_humans.user_id AND projections.sessions.instance_id = projections.users8_humans.instance_id` + + ` AS OF SYSTEM TIME '-1 ms'`) + expectedSessionsQuery = regexp.QuoteMeta(`SELECT projections.sessions.id,` + + ` projections.sessions.creation_date,` + + ` projections.sessions.change_date,` + + ` projections.sessions.sequence,` + + ` projections.sessions.state,` + + ` projections.sessions.resource_owner,` + + ` projections.sessions.creator,` + + ` projections.sessions.user_id,` + + ` projections.sessions.user_checked_at,` + + ` projections.login_names2.login_name,` + + ` projections.users8_humans.display_name,` + + ` projections.sessions.password_checked_at,` + + ` projections.sessions.metadata,` + + ` COUNT(*) OVER ()` + + ` FROM projections.sessions` + + ` LEFT JOIN projections.login_names2 ON projections.sessions.user_id = projections.login_names2.user_id AND projections.sessions.instance_id = projections.login_names2.instance_id` + + ` LEFT JOIN projections.users8_humans ON projections.sessions.user_id = projections.users8_humans.user_id AND projections.sessions.instance_id = projections.users8_humans.instance_id` + + ` AS OF SYSTEM TIME '-1 ms'`) + + sessionCols = []string{ + "id", + "creation_date", + "change_date", + "sequence", + "state", + "resource_owner", + "creator", + "user_id", + "user_checked_at", + "login_name", + "display_name", + "password_checked_at", + "metadata", + "token", + } + + sessionsCols = []string{ + "id", + "creation_date", + "change_date", + "sequence", + "state", + "resource_owner", + "creator", + "user_id", + "user_checked_at", + "login_name", + "display_name", + "password_checked_at", + "metadata", + "count", + } +) + +func Test_SessionsPrepare(t *testing.T) { + type want struct { + sqlExpectations sqlExpectation + err checkErr + } + tests := []struct { + name string + prepare interface{} + want want + object interface{} + }{ + { + name: "prepareSessionsQuery no result", + prepare: prepareSessionsQuery, + want: want{ + sqlExpectations: mockQueries( + expectedSessionsQuery, + nil, + nil, + ), + }, + object: &Sessions{Sessions: []*Session{}}, + }, + { + name: "prepareSessionQuery", + prepare: prepareSessionsQuery, + want: want{ + sqlExpectations: mockQueries( + expectedSessionsQuery, + sessionsCols, + [][]driver.Value{ + { + "session-id", + testNow, + testNow, + uint64(20211109), + domain.SessionStateActive, + "ro", + "creator", + "user-id", + testNow, + "login-name", + "display-name", + testNow, + []byte(`{"key": "dmFsdWU="}`), + }, + }, + ), + }, + object: &Sessions{ + SearchResponse: SearchResponse{ + Count: 1, + }, + Sessions: []*Session{ + { + ID: "session-id", + CreationDate: testNow, + ChangeDate: testNow, + Sequence: 20211109, + State: domain.SessionStateActive, + ResourceOwner: "ro", + Creator: "creator", + UserFactor: SessionUserFactor{ + UserID: "user-id", + UserCheckedAt: testNow, + LoginName: "login-name", + DisplayName: "display-name", + }, + PasswordFactor: SessionPasswordFactor{ + PasswordCheckedAt: testNow, + }, + Metadata: map[string][]byte{ + "key": []byte("value"), + }, + }, + }, + }, + }, + { + name: "prepareSessionsQuery multiple result", + prepare: prepareSessionsQuery, + want: want{ + sqlExpectations: mockQueries( + expectedSessionsQuery, + sessionsCols, + [][]driver.Value{ + { + "session-id", + testNow, + testNow, + uint64(20211109), + domain.SessionStateActive, + "ro", + "creator", + "user-id", + testNow, + "login-name", + "display-name", + testNow, + []byte(`{"key": "dmFsdWU="}`), + }, + { + "session-id2", + testNow, + testNow, + uint64(20211109), + domain.SessionStateActive, + "ro", + "creator2", + "user-id2", + testNow, + "login-name2", + "display-name2", + testNow, + []byte(`{"key": "dmFsdWU="}`), + }, + }, + ), + }, + object: &Sessions{ + SearchResponse: SearchResponse{ + Count: 2, + }, + Sessions: []*Session{ + { + ID: "session-id", + CreationDate: testNow, + ChangeDate: testNow, + Sequence: 20211109, + State: domain.SessionStateActive, + ResourceOwner: "ro", + Creator: "creator", + UserFactor: SessionUserFactor{ + UserID: "user-id", + UserCheckedAt: testNow, + LoginName: "login-name", + DisplayName: "display-name", + }, + PasswordFactor: SessionPasswordFactor{ + PasswordCheckedAt: testNow, + }, + Metadata: map[string][]byte{ + "key": []byte("value"), + }, + }, + { + ID: "session-id2", + CreationDate: testNow, + ChangeDate: testNow, + Sequence: 20211109, + State: domain.SessionStateActive, + ResourceOwner: "ro", + Creator: "creator2", + UserFactor: SessionUserFactor{ + UserID: "user-id2", + UserCheckedAt: testNow, + LoginName: "login-name2", + DisplayName: "display-name2", + }, + PasswordFactor: SessionPasswordFactor{ + PasswordCheckedAt: testNow, + }, + Metadata: map[string][]byte{ + "key": []byte("value"), + }, + }, + }, + }, + }, + { + name: "prepareSessionsQuery sql err", + prepare: prepareSessionsQuery, + want: want{ + sqlExpectations: mockQueryErr( + expectedSessionsQuery, + sql.ErrConnDone, + ), + err: func(err error) (error, bool) { + if !errors.Is(err, sql.ErrConnDone) { + return fmt.Errorf("err should be sql.ErrConnDone got: %w", err), false + } + return nil, true + }, + }, + object: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertPrepare(t, tt.prepare, tt.object, tt.want.sqlExpectations, tt.want.err, defaultPrepareArgs...) + }) + } +} + +func Test_SessionPrepare(t *testing.T) { + type want struct { + sqlExpectations sqlExpectation + err checkErr + } + tests := []struct { + name string + prepare interface{} + want want + object interface{} + }{ + { + name: "prepareSessionQuery no result", + prepare: prepareSessionQueryTesting(t, ""), + want: want{ + sqlExpectations: mockQueries( + expectedSessionQuery, + nil, + nil, + ), + err: func(err error) (error, bool) { + if !errs.IsNotFound(err) { + return fmt.Errorf("err should be zitadel.NotFoundError got: %w", err), false + } + return nil, true + }, + }, + object: (*Session)(nil), + }, + { + name: "prepareSessionQuery found", + prepare: prepareSessionQueryTesting(t, "tokenID"), + want: want{ + sqlExpectations: mockQuery( + expectedSessionQuery, + sessionCols, + []driver.Value{ + "session-id", + testNow, + testNow, + uint64(20211109), + domain.SessionStateActive, + "ro", + "creator", + "user-id", + testNow, + "login-name", + "display-name", + testNow, + []byte(`{"key": "dmFsdWU="}`), + "tokenID", + }, + ), + }, + object: &Session{ + ID: "session-id", + CreationDate: testNow, + ChangeDate: testNow, + Sequence: 20211109, + State: domain.SessionStateActive, + ResourceOwner: "ro", + Creator: "creator", + UserFactor: SessionUserFactor{ + UserID: "user-id", + UserCheckedAt: testNow, + LoginName: "login-name", + DisplayName: "display-name", + }, + PasswordFactor: SessionPasswordFactor{ + PasswordCheckedAt: testNow, + }, + Metadata: map[string][]byte{ + "key": []byte("value"), + }, + }, + }, + { + name: "prepareSessionQuery sql err", + prepare: prepareSessionQueryTesting(t, ""), + want: want{ + sqlExpectations: mockQueryErr( + expectedSessionQuery, + sql.ErrConnDone, + ), + err: func(err error) (error, bool) { + if !errors.Is(err, sql.ErrConnDone) { + return fmt.Errorf("err should be sql.ErrConnDone got: %w", err), false + } + return nil, true + }, + }, + object: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertPrepare(t, tt.prepare, tt.object, tt.want.sqlExpectations, tt.want.err, defaultPrepareArgs...) + }) + } +} + +func prepareSessionQueryTesting(t *testing.T, token string) func(context.Context, prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (*Session, error)) { + return func(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (*Session, error)) { + builder, scan := prepareSessionQuery(ctx, db) + return builder, func(row *sql.Row) (*Session, error) { + session, tokenID, err := scan(row) + require.Equal(t, tokenID, token) + return session, err + } + } +} diff --git a/internal/repository/session/aggregate.go b/internal/repository/session/aggregate.go new file mode 100644 index 000000000..58c479398 --- /dev/null +++ b/internal/repository/session/aggregate.go @@ -0,0 +1,25 @@ +package session + +import ( + "github.com/zitadel/zitadel/internal/eventstore" +) + +const ( + AggregateType = "session" + AggregateVersion = "v1" +) + +type Aggregate struct { + eventstore.Aggregate +} + +func NewAggregate(id, resourceOwner string) *Aggregate { + return &Aggregate{ + Aggregate: eventstore.Aggregate{ + Type: AggregateType, + Version: AggregateVersion, + ID: id, + ResourceOwner: resourceOwner, + }, + } +} diff --git a/internal/repository/session/eventstore.go b/internal/repository/session/eventstore.go new file mode 100644 index 000000000..3dd1875da --- /dev/null +++ b/internal/repository/session/eventstore.go @@ -0,0 +1,12 @@ +package session + +import "github.com/zitadel/zitadel/internal/eventstore" + +func RegisterEventMappers(es *eventstore.Eventstore) { + es.RegisterFilterEventMapper(AggregateType, AddedType, AddedEventMapper). + RegisterFilterEventMapper(AggregateType, UserCheckedType, UserCheckedEventMapper). + RegisterFilterEventMapper(AggregateType, PasswordCheckedType, PasswordCheckedEventMapper). + RegisterFilterEventMapper(AggregateType, TokenSetType, TokenSetEventMapper). + RegisterFilterEventMapper(AggregateType, MetadataSetType, MetadataSetEventMapper). + RegisterFilterEventMapper(AggregateType, TerminateType, TerminateEventMapper) +} diff --git a/internal/repository/session/session.go b/internal/repository/session/session.go new file mode 100644 index 000000000..688383442 --- /dev/null +++ b/internal/repository/session/session.go @@ -0,0 +1,255 @@ +package session + +import ( + "context" + "encoding/json" + "time" + + "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/repository" +) + +const ( + sessionEventPrefix = "session." + AddedType = sessionEventPrefix + "added" + UserCheckedType = sessionEventPrefix + "user.checked" + PasswordCheckedType = sessionEventPrefix + "password.checked" + TokenSetType = sessionEventPrefix + "token.set" + MetadataSetType = sessionEventPrefix + "metadata.set" + TerminateType = sessionEventPrefix + "terminated" +) + +type AddedEvent struct { + eventstore.BaseEvent `json:"-"` +} + +func (e *AddedEvent) Data() interface{} { + return e +} + +func (e *AddedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func NewAddedEvent(ctx context.Context, + aggregate *eventstore.Aggregate, +) *AddedEvent { + return &AddedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + AddedType, + ), + } +} + +func AddedEventMapper(event *repository.Event) (eventstore.Event, error) { + added := &AddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + } + err := json.Unmarshal(event.Data, added) + if err != nil { + return nil, errors.ThrowInternal(err, "SESSION-DG4gn", "unable to unmarshal session added") + } + + return added, nil +} + +type UserCheckedEvent struct { + eventstore.BaseEvent `json:"-"` + + UserID string `json:"userID"` + CheckedAt time.Time `json:"checkedAt"` +} + +func (e *UserCheckedEvent) Data() interface{} { + return e +} + +func (e *UserCheckedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func NewUserCheckedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + userID string, + checkedAt time.Time, +) *UserCheckedEvent { + return &UserCheckedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + UserCheckedType, + ), + UserID: userID, + CheckedAt: checkedAt, + } +} + +func UserCheckedEventMapper(event *repository.Event) (eventstore.Event, error) { + added := &UserCheckedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + } + err := json.Unmarshal(event.Data, added) + if err != nil { + return nil, errors.ThrowInternal(err, "SESSION-DSGn5", "unable to unmarshal user checked") + } + + return added, nil +} + +type PasswordCheckedEvent struct { + eventstore.BaseEvent `json:"-"` + + CheckedAt time.Time `json:"checkedAt"` +} + +func (e *PasswordCheckedEvent) Data() interface{} { + return e +} + +func (e *PasswordCheckedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func NewPasswordCheckedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + checkedAt time.Time, +) *PasswordCheckedEvent { + return &PasswordCheckedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + PasswordCheckedType, + ), + CheckedAt: checkedAt, + } +} + +func PasswordCheckedEventMapper(event *repository.Event) (eventstore.Event, error) { + added := &PasswordCheckedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + } + err := json.Unmarshal(event.Data, added) + if err != nil { + return nil, errors.ThrowInternal(err, "SESSION-DGt21", "unable to unmarshal password checked") + } + + return added, nil +} + +type TokenSetEvent struct { + eventstore.BaseEvent `json:"-"` + + TokenID string `json:"tokenID"` +} + +func (e *TokenSetEvent) Data() interface{} { + return e +} + +func (e *TokenSetEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func NewTokenSetEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + tokenID string, +) *TokenSetEvent { + return &TokenSetEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + TokenSetType, + ), + TokenID: tokenID, + } +} + +func TokenSetEventMapper(event *repository.Event) (eventstore.Event, error) { + added := &TokenSetEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + } + err := json.Unmarshal(event.Data, added) + if err != nil { + return nil, errors.ThrowInternal(err, "SESSION-Sf3va", "unable to unmarshal token set") + } + + return added, nil +} + +type MetadataSetEvent struct { + eventstore.BaseEvent `json:"-"` + + Metadata map[string][]byte `json:"metadata"` +} + +func (e *MetadataSetEvent) Data() interface{} { + return e +} + +func (e *MetadataSetEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func NewMetadataSetEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + metadata map[string][]byte, +) *MetadataSetEvent { + return &MetadataSetEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + MetadataSetType, + ), + Metadata: metadata, + } +} + +func MetadataSetEventMapper(event *repository.Event) (eventstore.Event, error) { + added := &MetadataSetEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + } + err := json.Unmarshal(event.Data, added) + if err != nil { + return nil, errors.ThrowInternal(err, "SESSION-BD21d", "unable to unmarshal metadata set") + } + + return added, nil +} + +type TerminateEvent struct { + eventstore.BaseEvent `json:"-"` +} + +func (e *TerminateEvent) Data() interface{} { + return e +} + +func (e *TerminateEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func NewTerminateEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, +) *TerminateEvent { + return &TerminateEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + TerminateType, + ), + } +} + +func TerminateEventMapper(event *repository.Event) (eventstore.Event, error) { + return &TerminateEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + }, nil +} diff --git a/internal/static/i18n/de.yaml b/internal/static/i18n/de.yaml index 9cf6f96f4..9e7e9c5c8 100644 --- a/internal/static/i18n/de.yaml +++ b/internal/static/i18n/de.yaml @@ -470,6 +470,11 @@ Errors: Execution: StorageFailed: Das Speichern des Action Logs in der Datenbank ist fehlgeschlagen ScanFailed: Das Abfragen der verbrauchten Actions Sekunden ist fehlgeschlagen + Session: + NotExisting: Session existiert nicht + Terminated: Session bereits beendet + Token: + Invalid: Session Token ist ungültig AggregateTypes: action: Action diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index c5d3c36b2..9362eaf2b 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -470,6 +470,11 @@ Errors: Execution: StorageFailed: Storing action execution log to database failed ScanFailed: Querying usage for action execution seconds failed + Session: + NotExisting: Session does not exist + Terminated: Session already terminated + Token: + Invalid: Session Token is invalid AggregateTypes: action: Action diff --git a/internal/static/i18n/es.yaml b/internal/static/i18n/es.yaml index 63ffb3981..c87d9f20c 100644 --- a/internal/static/i18n/es.yaml +++ b/internal/static/i18n/es.yaml @@ -470,6 +470,11 @@ Errors: Execution: StorageFailed: Ha fallado el almacenaje del registro de ejecución de acciones en la base de datos ScanFailed: La consulta de uso de los segundos de ejecuciónde acciones ha fallado + Session: + NotExisting: La sesión no existe + Terminated: Sesión ya terminada + Token: + Invalid: El identificador de sesión no es válido AggregateTypes: action: Acción diff --git a/internal/static/i18n/fr.yaml b/internal/static/i18n/fr.yaml index 184cd83f0..bc2f111da 100644 --- a/internal/static/i18n/fr.yaml +++ b/internal/static/i18n/fr.yaml @@ -470,6 +470,11 @@ Errors: Execution: StorageFailed: L'enregistrement du journal d'action dans la base de données a échoué ScanFailed: L'interrogation des secondes d'action consommées a échoué + Session: + NotExisting: La session n'existe pas + Terminated: La session est déjà terminée + Token: + Invalid: Le jeton de session n'est pas valide AggregateTypes: action: Action diff --git a/internal/static/i18n/it.yaml b/internal/static/i18n/it.yaml index 1f9056cf5..f270a5f69 100644 --- a/internal/static/i18n/it.yaml +++ b/internal/static/i18n/it.yaml @@ -470,6 +470,11 @@ Errors: Execution: StorageFailed: Il salvataggio del registro delle azioni nel database non è riuscito ScanFailed: La query dei secondi delle azioni utilizzate non è riuscita + Session: + NotExisting: La sessione non esiste + Terminated: Sessione già terminata + Token: + Invalid: Il token della sessione non è valido AggregateTypes: action: Azione diff --git a/internal/static/i18n/ja.yaml b/internal/static/i18n/ja.yaml index 8f347b02d..284c0412a 100644 --- a/internal/static/i18n/ja.yaml +++ b/internal/static/i18n/ja.yaml @@ -459,6 +459,11 @@ Errors: Execution: StorageFailed: アクション実行ログのデータベースへの保存に失敗しました ScanFailed: アクション実行時間を取得する使用状況クエリに失敗しました + Session: + NotExisting: セッションが存在しない + Terminated: セッションはすでに終了しています + Token: + Invalid: セッショントークンが無効です AggregateTypes: action: アクション diff --git a/internal/static/i18n/pl.yaml b/internal/static/i18n/pl.yaml index 0ef614bff..9fac79a25 100644 --- a/internal/static/i18n/pl.yaml +++ b/internal/static/i18n/pl.yaml @@ -470,6 +470,11 @@ Errors: Execution: StorageFailed: Zapisywanie dziennika wykonania akcji do bazy danych nie powiodło się ScanFailed: Zapytanie o użycie dla sekund wykonania akcji nie powiodło się + Session: + NotExisting: Sesja nie istnieje + Terminated: Sesja już zakończona + Token: + Invalid: Token sesji jest nieprawidłowy AggregateTypes: action: Działanie diff --git a/internal/static/i18n/zh.yaml b/internal/static/i18n/zh.yaml index 31c679ea2..c36cbcd11 100644 --- a/internal/static/i18n/zh.yaml +++ b/internal/static/i18n/zh.yaml @@ -470,6 +470,11 @@ Errors: Execution: StorageFailed: 将行动执行日志存储到数据库失败 ScanFailed: Q查询动作执行秒数的使用情况失败 + Session: + NotExisting: 会话不存在 + Terminated: 会话已经终止 + Token: + Invalid: 会话令牌是无效的 AggregateTypes: action: 动作 diff --git a/pkg/grpc/user/v2alpha/user.go b/pkg/grpc/user/v2alpha/user.go deleted file mode 100644 index f419594df..000000000 --- a/pkg/grpc/user/v2alpha/user.go +++ /dev/null @@ -1,5 +0,0 @@ -package user - -func (r *AddHumanUserRequest) AuthContext() string { - return r.GetOrganisation().GetOrgId() -} diff --git a/proto/zitadel/object/v2alpha/object.proto b/proto/zitadel/object/v2alpha/object.proto index 3a209b637..27c6dbb39 100644 --- a/proto/zitadel/object/v2alpha/object.proto +++ b/proto/zitadel/object/v2alpha/object.proto @@ -11,9 +11,35 @@ import "validate/validate.proto"; message Organisation { oneof org { string org_id = 1; + string org_domain = 2; } } +message ListQuery { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { + json_schema: { + title: "General List Query" + description: "Object unspecific list filters like offset, limit and asc/desc." + } + }; + uint64 offset = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"0\""; + } + ]; + uint32 limit = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "100"; + description: "Maximum amount of events returned. The default is set to 1000 in https://github.com/zitadel/zitadel/blob/new-eventstore/cmd/zitadel/startup.yaml. If the limit exceeds the maximum configured ZITADEL will throw an error. If no limit is present the default is taken."; + } + ]; + bool asc = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "default is descending" + } + ]; +} + message Details { //sequence represents the order of events. It's always counting // @@ -38,3 +64,21 @@ message Details { } ]; } + +message ListDetails { + uint64 total_result = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2\""; + } + ]; + uint64 processed_sequence = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"267831\""; + } + ]; + google.protobuf.Timestamp timestamp = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "the last time the projection got updated" + } + ]; +} diff --git a/proto/zitadel/session/v2alpha/session.proto b/proto/zitadel/session/v2alpha/session.proto index 8d4c168b1..279863d14 100644 --- a/proto/zitadel/session/v2alpha/session.proto +++ b/proto/zitadel/session/v2alpha/session.proto @@ -2,11 +2,90 @@ syntax = "proto3"; package zitadel.session.v2alpha; -import "zitadel/user/v2alpha/user.proto"; +import "google/api/field_behavior.proto"; +import "google/protobuf/timestamp.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/session/v2alpha;session"; message Session { - string id = 1; - zitadel.user.v2alpha.User user = 2; + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"id of the session\""; + } + ]; + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"time when the session was created\""; + } + ]; + google.protobuf.Timestamp change_date = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"time when the session was last updated\""; + } + ]; + uint64 sequence = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"sequence of the session\""; + } + ]; + Factors factors = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"checked factors of the session, e.g. the user, password and more\""; + } + ]; + map metadata = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"custom key value list\""; + } + ]; +} + +message Factors { + UserFactor user = 1; + PasswordFactor password = 2; +} + +message UserFactor { + google.protobuf.Timestamp verified_at = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"time when the user was last checked\""; + } + ]; + string id = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"id of the checked user\""; + } + ]; + string login_name = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"login name of the checked user\""; + } + ]; + string display_name = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"display name of the checked user\""; + } + ]; +} + +message PasswordFactor { + google.protobuf.Timestamp verified_at = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"time when the password was last checked\""; + } + ]; +} + +message SearchQuery { + oneof query { + option (validate.required) = true; + + IDsQuery ids_query = 1; + } +} + +message IDsQuery { + repeated string ids = 1; } diff --git a/proto/zitadel/session/v2alpha/session_service.proto b/proto/zitadel/session/v2alpha/session_service.proto index dccf0bc0b..e56462107 100644 --- a/proto/zitadel/session/v2alpha/session_service.proto +++ b/proto/zitadel/session/v2alpha/session_service.proto @@ -3,21 +3,82 @@ syntax = "proto3"; package zitadel.session.v2alpha; +import "zitadel/object/v2alpha/object.proto"; import "zitadel/protoc_gen_zitadel/v2/options.proto"; import "zitadel/session/v2alpha/session.proto"; import "google/api/annotations.proto"; +import "google/api/field_behavior.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; import "validate/validate.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/session/v2alpha;session"; +option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { + info: { + title: "Session Service"; + version: "2.0-alpha"; + description: "This API is intended to manage sessions in a ZITADEL instance. This project is in alpha state. It can AND will continue breaking until the services provide the same functionality as the current login."; + contact:{ + name: "ZITADEL" + url: "https://zitadel.com" + email: "hi@zitadel.com" + } + license: { + name: "Apache 2.0", + url: "https://github.com/zitadel/zitadel/blob/main/LICENSE"; + }; + }; + schemes: HTTPS; + schemes: HTTP; + + consumes: "application/json"; + consumes: "application/grpc"; + + produces: "application/json"; + produces: "application/grpc"; + + consumes: "application/grpc-web+proto"; + produces: "application/grpc-web+proto"; + + host: "$ZITADEL_DOMAIN"; + base_path: "/"; + + external_docs: { + description: "Detailed information about ZITADEL", + url: "https://zitadel.com/docs" + } + + responses: { + key: "403"; + value: { + description: "Returned when the user does not have permission to access the resource."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } + responses: { + key: "404"; + value: { + description: "Returned when the resource does not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } +}; + service SessionService { - // GetSession is to demonstrate an authenticated request, where the authenticated user (usage of another grpc package) is returned - // - // this request is subject to change and currently used for demonstration only - rpc GetSession (GetSessionRequest) returns (GetSessionResponse) { + // Search sessions + rpc ListSessions (ListSessionsRequest) returns (ListSessionsResponse) { option (google.api.http) = { - get: "/v2alpha/sessions/{id}" + post: "/v2alpha/sessions/_search" + body: "*" }; option (zitadel.protoc_gen_zitadel.v2.options) = { @@ -25,12 +86,280 @@ service SessionService { permission: "authenticated" } }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Search sessions"; + description: "Search for sessions" + responses: { + key: "200" + value: { + description: "OK"; + } + }; + responses: { + key: "400"; + value: { + description: "invalid list query"; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + }; + }; + }; + } + + // GetSession a session + rpc GetSession (GetSessionRequest) returns (GetSessionResponse) { + option (google.api.http) = { + get: "/v2alpha/sessions/{session_id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Get a session"; + description: "Get a session and all its information like the time of the user or password verification" + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + // Create a new session + rpc CreateSession (CreateSessionRequest) returns (CreateSessionResponse) { + option (google.api.http) = { + post: "/v2alpha/sessions" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + http_response: { + success_code: 201 + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Create a new session"; + description: "Create a new session. A token will be returned, which is required for further updates of the session." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + // Update a session + rpc SetSession (SetSessionRequest) returns (SetSessionResponse) { + option (google.api.http) = { + patch: "/v2alpha/sessions/{session_id}" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Update an existing session"; + description: "Update an existing session with new information." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + // Terminate a session + rpc DeleteSession (DeleteSessionRequest) returns (DeleteSessionResponse) { + option (google.api.http) = { + delete: "/v2alpha/sessions/{session_id}" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Terminate an existing session"; + description: "Terminate your own session or if granted any other session." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; } } +message ListSessionsRequest{ + zitadel.object.v2alpha.ListQuery query = 1; + repeated SearchQuery queries = 2; +} + +message ListSessionsResponse{ + zitadel.object.v2alpha.ListDetails details = 1; + repeated Session sessions = 2; +} + message GetSessionRequest{ - string id = 1; + string session_id = 1; + optional string session_token = 2; } message GetSessionResponse{ Session session = 1; } + +message CreateSessionRequest{ + Checks checks = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"Check for user and password. Successful checks will be stated as factors on the session.\""; + } + ]; + map metadata = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"custom key value list to be stored on the session\""; + } + ]; +} + +message CreateSessionResponse{ + zitadel.object.v2alpha.Details details = 1; + string session_id = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"id of the session\""; + example: "\"d654e6ba-70a3-48ef-a95d-37c8d8a7901a\""; + } + ]; + string session_token = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"token of the session, which is required for further updates of the session or the request other resources\""; + } + ]; +} + +message SetSessionRequest{ + string session_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + description: "\"id of the session to update\""; + example: "\"d654e6ba-70a3-48ef-a95d-37c8d8a7901a\""; + } + ]; + string session_token = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + description: "\"token of the session, previously returned on the create / update request\""; + } + ]; + Checks checks = 3[ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"Check for user and password. Successful checks will be stated as factors on the session.\""; + } + ]; + map metadata = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"custom key value list to be stored on the session\""; + } + ]; +} + +message SetSessionResponse{ + zitadel.object.v2alpha.Details details = 1; + string session_token = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"token of the session, which is required for further updates of the session or the request other resources\""; + } + ]; +} + +message DeleteSessionRequest{ + string session_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + description: "\"id of the session to terminate\""; + example: "\"d654e6ba-70a3-48ef-a95d-37c8d8a7901a\""; + } + ]; + optional string session_token = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"token of the session, previously returned on the create / update request\""; + } + ]; +} + +message DeleteSessionResponse{ + zitadel.object.v2alpha.Details details = 1; +} + +message Checks { + optional CheckUser user = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"checks the user and updates the session on success\""; + } + ]; + optional CheckPassword password = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"Checks the password and updates the session on success. Requires that the user is already checked, either in the previous or the same request.\""; + } + ]; +} + +message CheckUser { + oneof search { + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"d654e6ba-70a3-48ef-a95d-37c8d8a7901a\""; + } + ]; + string login_name = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"mini@mouse.com\""; + } + ]; + } +} + +message CheckPassword { + string password = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"V3ryS3cure!\""; + } + ]; +}