diff --git a/backend/v3/api/session/v2/session.go b/backend/v3/api/session/v2/session.go new file mode 100644 index 000000000..6152be7a3 --- /dev/null +++ b/backend/v3/api/session/v2/session.go @@ -0,0 +1,34 @@ +package v2 + +import ( + "context" + + "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/backend/v3/domain" + "github.com/zitadel/zitadel/backend/v3/storage/database/repository" + "github.com/zitadel/zitadel/pkg/grpc/object/v2" + "github.com/zitadel/zitadel/pkg/grpc/session/v2" +) + +func DeleteSession(ctx context.Context, request *connect.Request[session.DeleteSessionRequest]) (*connect.Response[session.DeleteSessionResponse], error) { + sessionDeleteCmd := domain.NewDeleteSessionCommand(request.Msg.GetSessionId(), request.Msg.GetSessionToken(), true) + + err := domain.Invoke(ctx, sessionDeleteCmd, + domain.WithSessionRepo(repository.SessionRepository()), + ) + + if err != nil { + return nil, err + } + + details := &object.Details{} + if !sessionDeleteCmd.DeletedAt.IsZero() { + details.ChangeDate = timestamppb.New(sessionDeleteCmd.DeletedAt) + } + + return connect.NewResponse(&session.DeleteSessionResponse{ + Details: details, + }), nil +} diff --git a/backend/v3/domain/defaults.go b/backend/v3/domain/defaults.go index 0d69c9616..0a7c165d0 100644 --- a/backend/v3/domain/defaults.go +++ b/backend/v3/domain/defaults.go @@ -9,12 +9,11 @@ import ( var ( pool database.Pool - // tracer tracing.Tracer = tracing.GlobalTracer() - // runtimeLogger *slog.Logger = logging.New(logging.StreamRuntime, nil) legacyEventstore eventstore.LegacyEventstore sysConfig systemdefaults.SystemDefaults passwordHasher *crypto.Hasher idpEncryptionAlgo crypto.EncryptionAlgorithm + sessionTokenDecryptor SessionTokenDecryptor ) func SetPool(p database.Pool) { @@ -36,3 +35,7 @@ func SetPasswordHasher(hasher *crypto.Hasher) { func SetIDPEncryptionAlgorithm(idpEncryptionAlg crypto.EncryptionAlgorithm) { idpEncryptionAlgo = idpEncryptionAlg } + +func SetSessionTokenDecryptor(decryptor SessionTokenDecryptor) { + sessionTokenDecryptor = decryptor +} diff --git a/backend/v3/domain/invoke_option.go b/backend/v3/domain/invoke_option.go index 1c2f43040..698fd9349 100644 --- a/backend/v3/domain/invoke_option.go +++ b/backend/v3/domain/invoke_option.go @@ -87,17 +87,27 @@ func WithLegacyEventstore(es eventstore.LegacyEventstore) InvokeOpt { } } +// WithSessionTokenDecryptor sets the decryptor for session tokens used by the commands. +// If not set, the default one will be used. +// This is mainly used for testing +func WithSessionTokenDecryptor(decryptor SessionTokenDecryptor) InvokeOpt { + return func(opts *InvokeOpts) { + opts.sessionTokenDecryptor = decryptor + } +} + // InvokeOpts are passed to each command type InvokeOpts struct { // db is the database client. // [Executor]s MUST NOT access this field directly, use [InvokeOpts.DB] to access it. // // [Invoker]s may manipulate this field for example changing it to a transaction. - // Its their responsibility to restore it after ending the transaction. + // It's their responsibility to restore it after ending the transaction. db database.QueryExecutor legacyEventstore eventstore.LegacyEventstore Invoker Invoker Permissions PermissionChecker + sessionTokenDecryptor SessionTokenDecryptor organizationRepo OrganizationRepository organizationDomainRepo OrganizationDomainRepository projectRepo ProjectRepository @@ -139,7 +149,8 @@ func DefaultOpts(invoker Invoker) *InvokeOpts { invoker = &noopInvoker{} } return &InvokeOpts{ - Invoker: invoker, - Permissions: &noopPermissionChecker{}, // prevent panics for now + Invoker: invoker, + Permissions: &noopPermissionChecker{}, // prevent panics for now + sessionTokenDecryptor: sessionTokenDecryptor, } } diff --git a/backend/v3/domain/mock/session.mock.go b/backend/v3/domain/mock/session.mock.go index 27d56dd91..799a4b495 100644 --- a/backend/v3/domain/mock/session.mock.go +++ b/backend/v3/domain/mock/session.mock.go @@ -23,7 +23,6 @@ import ( type MockSessionRepository struct { ctrl *gomock.Controller recorder *MockSessionRepositoryMockRecorder - isgomock struct{} } // MockSessionRepositoryMockRecorder is the mock recorder for MockSessionRepository. @@ -44,17 +43,17 @@ func (m *MockSessionRepository) EXPECT() *MockSessionRepositoryMockRecorder { } // ClearFactor mocks base method. -func (m *MockSessionRepository) ClearFactor(factor domain.SessionFactorType) database.Change { +func (m *MockSessionRepository) ClearFactor(arg0 domain.SessionFactorType) database.Change { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ClearFactor", factor) + ret := m.ctrl.Call(m, "ClearFactor", arg0) ret0, _ := ret[0].(database.Change) return ret0 } // ClearFactor indicates an expected call of ClearFactor. -func (mr *MockSessionRepositoryMockRecorder) ClearFactor(factor any) *MockSessionRepositoryClearFactorCall { +func (mr *MockSessionRepositoryMockRecorder) ClearFactor(arg0 any) *MockSessionRepositoryClearFactorCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClearFactor", reflect.TypeOf((*MockSessionRepository)(nil).ClearFactor), factor) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClearFactor", reflect.TypeOf((*MockSessionRepository)(nil).ClearFactor), arg0) return &MockSessionRepositoryClearFactorCall{Call: call} } @@ -82,17 +81,17 @@ func (c *MockSessionRepositoryClearFactorCall) DoAndReturn(f func(domain.Session } // Create mocks base method. -func (m *MockSessionRepository) Create(ctx context.Context, client database.QueryExecutor, user *domain.Session) error { +func (m *MockSessionRepository) Create(arg0 context.Context, arg1 database.QueryExecutor, arg2 *domain.Session) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Create", ctx, client, user) + ret := m.ctrl.Call(m, "Create", arg0, arg1, arg2) ret0, _ := ret[0].(error) return ret0 } // Create indicates an expected call of Create. -func (mr *MockSessionRepositoryMockRecorder) Create(ctx, client, user any) *MockSessionRepositoryCreateCall { +func (mr *MockSessionRepositoryMockRecorder) Create(arg0, arg1, arg2 any) *MockSessionRepositoryCreateCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockSessionRepository)(nil).Create), ctx, client, user) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockSessionRepository)(nil).Create), arg0, arg1, arg2) return &MockSessionRepositoryCreateCall{Call: call} } @@ -158,17 +157,17 @@ func (c *MockSessionRepositoryCreatedAtColumnCall) DoAndReturn(f func() database } // CreatedAtCondition mocks base method. -func (m *MockSessionRepository) CreatedAtCondition(op database.NumberOperation, createdAt time.Time) database.Condition { +func (m *MockSessionRepository) CreatedAtCondition(arg0 database.NumberOperation, arg1 time.Time) database.Condition { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CreatedAtCondition", op, createdAt) + ret := m.ctrl.Call(m, "CreatedAtCondition", arg0, arg1) ret0, _ := ret[0].(database.Condition) return ret0 } // CreatedAtCondition indicates an expected call of CreatedAtCondition. -func (mr *MockSessionRepositoryMockRecorder) CreatedAtCondition(op, createdAt any) *MockSessionRepositoryCreatedAtConditionCall { +func (mr *MockSessionRepositoryMockRecorder) CreatedAtCondition(arg0, arg1 any) *MockSessionRepositoryCreatedAtConditionCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatedAtCondition", reflect.TypeOf((*MockSessionRepository)(nil).CreatedAtCondition), op, createdAt) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatedAtCondition", reflect.TypeOf((*MockSessionRepository)(nil).CreatedAtCondition), arg0, arg1) return &MockSessionRepositoryCreatedAtConditionCall{Call: call} } @@ -196,17 +195,17 @@ func (c *MockSessionRepositoryCreatedAtConditionCall) DoAndReturn(f func(databas } // CreatorIDCondition mocks base method. -func (m *MockSessionRepository) CreatorIDCondition(creatorID string) database.Condition { +func (m *MockSessionRepository) CreatorIDCondition(arg0 string) database.Condition { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CreatorIDCondition", creatorID) + ret := m.ctrl.Call(m, "CreatorIDCondition", arg0) ret0, _ := ret[0].(database.Condition) return ret0 } // CreatorIDCondition indicates an expected call of CreatorIDCondition. -func (mr *MockSessionRepositoryMockRecorder) CreatorIDCondition(creatorID any) *MockSessionRepositoryCreatorIDConditionCall { +func (mr *MockSessionRepositoryMockRecorder) CreatorIDCondition(arg0 any) *MockSessionRepositoryCreatorIDConditionCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatorIDCondition", reflect.TypeOf((*MockSessionRepository)(nil).CreatorIDCondition), creatorID) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatorIDCondition", reflect.TypeOf((*MockSessionRepository)(nil).CreatorIDCondition), arg0) return &MockSessionRepositoryCreatorIDConditionCall{Call: call} } @@ -234,18 +233,19 @@ func (c *MockSessionRepositoryCreatorIDConditionCall) DoAndReturn(f func(string) } // Delete mocks base method. -func (m *MockSessionRepository) Delete(ctx context.Context, client database.QueryExecutor, condition database.Condition) (int64, error) { +func (m *MockSessionRepository) Delete(arg0 context.Context, arg1 database.QueryExecutor, arg2, arg3 database.Condition) (int64, time.Time, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Delete", ctx, client, condition) + ret := m.ctrl.Call(m, "Delete", arg0, arg1, arg2, arg3) ret0, _ := ret[0].(int64) - ret1, _ := ret[1].(error) - return ret0, ret1 + ret1, _ := ret[1].(time.Time) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 } // Delete indicates an expected call of Delete. -func (mr *MockSessionRepositoryMockRecorder) Delete(ctx, client, condition any) *MockSessionRepositoryDeleteCall { +func (mr *MockSessionRepositoryMockRecorder) Delete(arg0, arg1, arg2, arg3 any) *MockSessionRepositoryDeleteCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockSessionRepository)(nil).Delete), ctx, client, condition) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockSessionRepository)(nil).Delete), arg0, arg1, arg2, arg3) return &MockSessionRepositoryDeleteCall{Call: call} } @@ -255,35 +255,35 @@ type MockSessionRepositoryDeleteCall struct { } // Return rewrite *gomock.Call.Return -func (c *MockSessionRepositoryDeleteCall) Return(arg0 int64, arg1 error) *MockSessionRepositoryDeleteCall { - c.Call = c.Call.Return(arg0, arg1) +func (c *MockSessionRepositoryDeleteCall) Return(arg0 int64, arg1 time.Time, arg2 error) *MockSessionRepositoryDeleteCall { + c.Call = c.Call.Return(arg0, arg1, arg2) return c } // Do rewrite *gomock.Call.Do -func (c *MockSessionRepositoryDeleteCall) Do(f func(context.Context, database.QueryExecutor, database.Condition) (int64, error)) *MockSessionRepositoryDeleteCall { +func (c *MockSessionRepositoryDeleteCall) Do(f func(context.Context, database.QueryExecutor, database.Condition, database.Condition) (int64, time.Time, error)) *MockSessionRepositoryDeleteCall { c.Call = c.Call.Do(f) return c } // DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockSessionRepositoryDeleteCall) DoAndReturn(f func(context.Context, database.QueryExecutor, database.Condition) (int64, error)) *MockSessionRepositoryDeleteCall { +func (c *MockSessionRepositoryDeleteCall) DoAndReturn(f func(context.Context, database.QueryExecutor, database.Condition, database.Condition) (int64, time.Time, error)) *MockSessionRepositoryDeleteCall { c.Call = c.Call.DoAndReturn(f) return c } // ExistsFactor mocks base method. -func (m *MockSessionRepository) ExistsFactor(condition database.Condition) database.Condition { +func (m *MockSessionRepository) ExistsFactor(arg0 database.Condition) database.Condition { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ExistsFactor", condition) + ret := m.ctrl.Call(m, "ExistsFactor", arg0) ret0, _ := ret[0].(database.Condition) return ret0 } // ExistsFactor indicates an expected call of ExistsFactor. -func (mr *MockSessionRepositoryMockRecorder) ExistsFactor(condition any) *MockSessionRepositoryExistsFactorCall { +func (mr *MockSessionRepositoryMockRecorder) ExistsFactor(arg0 any) *MockSessionRepositoryExistsFactorCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExistsFactor", reflect.TypeOf((*MockSessionRepository)(nil).ExistsFactor), condition) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExistsFactor", reflect.TypeOf((*MockSessionRepository)(nil).ExistsFactor), arg0) return &MockSessionRepositoryExistsFactorCall{Call: call} } @@ -311,17 +311,17 @@ func (c *MockSessionRepositoryExistsFactorCall) DoAndReturn(f func(database.Cond } // ExistsMetadata mocks base method. -func (m *MockSessionRepository) ExistsMetadata(condition database.Condition) database.Condition { +func (m *MockSessionRepository) ExistsMetadata(arg0 database.Condition) database.Condition { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ExistsMetadata", condition) + ret := m.ctrl.Call(m, "ExistsMetadata", arg0) ret0, _ := ret[0].(database.Condition) return ret0 } // ExistsMetadata indicates an expected call of ExistsMetadata. -func (mr *MockSessionRepositoryMockRecorder) ExistsMetadata(condition any) *MockSessionRepositoryExistsMetadataCall { +func (mr *MockSessionRepositoryMockRecorder) ExistsMetadata(arg0 any) *MockSessionRepositoryExistsMetadataCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExistsMetadata", reflect.TypeOf((*MockSessionRepository)(nil).ExistsMetadata), condition) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExistsMetadata", reflect.TypeOf((*MockSessionRepository)(nil).ExistsMetadata), arg0) return &MockSessionRepositoryExistsMetadataCall{Call: call} } @@ -387,17 +387,17 @@ func (c *MockSessionRepositoryExpirationColumnCall) DoAndReturn(f func() databas } // ExpirationCondition mocks base method. -func (m *MockSessionRepository) ExpirationCondition(op database.NumberOperation, expiration time.Time) database.Condition { +func (m *MockSessionRepository) ExpirationCondition(arg0 database.NumberOperation, arg1 time.Time) database.Condition { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ExpirationCondition", op, expiration) + ret := m.ctrl.Call(m, "ExpirationCondition", arg0, arg1) ret0, _ := ret[0].(database.Condition) return ret0 } // ExpirationCondition indicates an expected call of ExpirationCondition. -func (mr *MockSessionRepositoryMockRecorder) ExpirationCondition(op, expiration any) *MockSessionRepositoryExpirationConditionCall { +func (mr *MockSessionRepositoryMockRecorder) ExpirationCondition(arg0, arg1 any) *MockSessionRepositoryExpirationConditionCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExpirationCondition", reflect.TypeOf((*MockSessionRepository)(nil).ExpirationCondition), op, expiration) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExpirationCondition", reflect.TypeOf((*MockSessionRepository)(nil).ExpirationCondition), arg0, arg1) return &MockSessionRepositoryExpirationConditionCall{Call: call} } @@ -463,10 +463,10 @@ func (c *MockSessionRepositoryFactorConditionsCall) DoAndReturn(f func() domain. } // Get mocks base method. -func (m *MockSessionRepository) Get(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) (*domain.Session, error) { +func (m *MockSessionRepository) Get(arg0 context.Context, arg1 database.QueryExecutor, arg2 ...database.QueryOption) (*domain.Session, error) { m.ctrl.T.Helper() - varargs := []any{ctx, client} - for _, a := range opts { + varargs := []any{arg0, arg1} + for _, a := range arg2 { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "Get", varargs...) @@ -476,9 +476,9 @@ func (m *MockSessionRepository) Get(ctx context.Context, client database.QueryEx } // Get indicates an expected call of Get. -func (mr *MockSessionRepositoryMockRecorder) Get(ctx, client any, opts ...any) *MockSessionRepositoryGetCall { +func (mr *MockSessionRepositoryMockRecorder) Get(arg0, arg1 any, arg2 ...any) *MockSessionRepositoryGetCall { mr.mock.ctrl.T.Helper() - varargs := append([]any{ctx, client}, opts...) + varargs := append([]any{arg0, arg1}, arg2...) call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockSessionRepository)(nil).Get), varargs...) return &MockSessionRepositoryGetCall{Call: call} } @@ -545,17 +545,17 @@ func (c *MockSessionRepositoryIDColumnCall) DoAndReturn(f func() database.Column } // IDCondition mocks base method. -func (m *MockSessionRepository) IDCondition(sessionID string) database.Condition { +func (m *MockSessionRepository) IDCondition(arg0 string) database.Condition { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "IDCondition", sessionID) + ret := m.ctrl.Call(m, "IDCondition", arg0) ret0, _ := ret[0].(database.Condition) return ret0 } // IDCondition indicates an expected call of IDCondition. -func (mr *MockSessionRepositoryMockRecorder) IDCondition(sessionID any) *MockSessionRepositoryIDConditionCall { +func (mr *MockSessionRepositoryMockRecorder) IDCondition(arg0 any) *MockSessionRepositoryIDConditionCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IDCondition", reflect.TypeOf((*MockSessionRepository)(nil).IDCondition), sessionID) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IDCondition", reflect.TypeOf((*MockSessionRepository)(nil).IDCondition), arg0) return &MockSessionRepositoryIDConditionCall{Call: call} } @@ -621,17 +621,17 @@ func (c *MockSessionRepositoryInstanceIDColumnCall) DoAndReturn(f func() databas } // InstanceIDCondition mocks base method. -func (m *MockSessionRepository) InstanceIDCondition(instanceID string) database.Condition { +func (m *MockSessionRepository) InstanceIDCondition(arg0 string) database.Condition { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "InstanceIDCondition", instanceID) + ret := m.ctrl.Call(m, "InstanceIDCondition", arg0) ret0, _ := ret[0].(database.Condition) return ret0 } // InstanceIDCondition indicates an expected call of InstanceIDCondition. -func (mr *MockSessionRepositoryMockRecorder) InstanceIDCondition(instanceID any) *MockSessionRepositoryInstanceIDConditionCall { +func (mr *MockSessionRepositoryMockRecorder) InstanceIDCondition(arg0 any) *MockSessionRepositoryInstanceIDConditionCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstanceIDCondition", reflect.TypeOf((*MockSessionRepository)(nil).InstanceIDCondition), instanceID) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstanceIDCondition", reflect.TypeOf((*MockSessionRepository)(nil).InstanceIDCondition), arg0) return &MockSessionRepositoryInstanceIDConditionCall{Call: call} } @@ -697,10 +697,10 @@ func (c *MockSessionRepositoryLifetimeColumnCall) DoAndReturn(f func() database. } // List mocks base method. -func (m *MockSessionRepository) List(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) ([]*domain.Session, error) { +func (m *MockSessionRepository) List(arg0 context.Context, arg1 database.QueryExecutor, arg2 ...database.QueryOption) ([]*domain.Session, error) { m.ctrl.T.Helper() - varargs := []any{ctx, client} - for _, a := range opts { + varargs := []any{arg0, arg1} + for _, a := range arg2 { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "List", varargs...) @@ -710,9 +710,9 @@ func (m *MockSessionRepository) List(ctx context.Context, client database.QueryE } // List indicates an expected call of List. -func (mr *MockSessionRepositoryMockRecorder) List(ctx, client any, opts ...any) *MockSessionRepositoryListCall { +func (mr *MockSessionRepositoryMockRecorder) List(arg0, arg1 any, arg2 ...any) *MockSessionRepositoryListCall { mr.mock.ctrl.T.Helper() - varargs := append([]any{ctx, client}, opts...) + varargs := append([]any{arg0, arg1}, arg2...) call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockSessionRepository)(nil).List), varargs...) return &MockSessionRepositoryListCall{Call: call} } @@ -817,17 +817,17 @@ func (c *MockSessionRepositoryPrimaryKeyColumnsCall) DoAndReturn(f func() []data } // PrimaryKeyCondition mocks base method. -func (m *MockSessionRepository) PrimaryKeyCondition(instanceID, sessionID string) database.Condition { +func (m *MockSessionRepository) PrimaryKeyCondition(arg0, arg1 string) database.Condition { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "PrimaryKeyCondition", instanceID, sessionID) + ret := m.ctrl.Call(m, "PrimaryKeyCondition", arg0, arg1) ret0, _ := ret[0].(database.Condition) return ret0 } // PrimaryKeyCondition indicates an expected call of PrimaryKeyCondition. -func (mr *MockSessionRepositoryMockRecorder) PrimaryKeyCondition(instanceID, sessionID any) *MockSessionRepositoryPrimaryKeyConditionCall { +func (mr *MockSessionRepositoryMockRecorder) PrimaryKeyCondition(arg0, arg1 any) *MockSessionRepositoryPrimaryKeyConditionCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PrimaryKeyCondition", reflect.TypeOf((*MockSessionRepository)(nil).PrimaryKeyCondition), instanceID, sessionID) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PrimaryKeyCondition", reflect.TypeOf((*MockSessionRepository)(nil).PrimaryKeyCondition), arg0, arg1) return &MockSessionRepositoryPrimaryKeyConditionCall{Call: call} } @@ -855,17 +855,17 @@ func (c *MockSessionRepositoryPrimaryKeyConditionCall) DoAndReturn(f func(string } // SetChallenge mocks base method. -func (m *MockSessionRepository) SetChallenge(challenge domain.SessionChallenge) database.Change { +func (m *MockSessionRepository) SetChallenge(arg0 domain.SessionChallenge) database.Change { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SetChallenge", challenge) + ret := m.ctrl.Call(m, "SetChallenge", arg0) ret0, _ := ret[0].(database.Change) return ret0 } // SetChallenge indicates an expected call of SetChallenge. -func (mr *MockSessionRepositoryMockRecorder) SetChallenge(challenge any) *MockSessionRepositorySetChallengeCall { +func (mr *MockSessionRepositoryMockRecorder) SetChallenge(arg0 any) *MockSessionRepositorySetChallengeCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetChallenge", reflect.TypeOf((*MockSessionRepository)(nil).SetChallenge), challenge) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetChallenge", reflect.TypeOf((*MockSessionRepository)(nil).SetChallenge), arg0) return &MockSessionRepositorySetChallengeCall{Call: call} } @@ -893,17 +893,17 @@ func (c *MockSessionRepositorySetChallengeCall) DoAndReturn(f func(domain.Sessio } // SetFactor mocks base method. -func (m *MockSessionRepository) SetFactor(factor domain.SessionFactor) database.Change { +func (m *MockSessionRepository) SetFactor(arg0 domain.SessionFactor) database.Change { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SetFactor", factor) + ret := m.ctrl.Call(m, "SetFactor", arg0) ret0, _ := ret[0].(database.Change) return ret0 } // SetFactor indicates an expected call of SetFactor. -func (mr *MockSessionRepositoryMockRecorder) SetFactor(factor any) *MockSessionRepositorySetFactorCall { +func (mr *MockSessionRepositoryMockRecorder) SetFactor(arg0 any) *MockSessionRepositorySetFactorCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetFactor", reflect.TypeOf((*MockSessionRepository)(nil).SetFactor), factor) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetFactor", reflect.TypeOf((*MockSessionRepository)(nil).SetFactor), arg0) return &MockSessionRepositorySetFactorCall{Call: call} } @@ -931,17 +931,17 @@ func (c *MockSessionRepositorySetFactorCall) DoAndReturn(f func(domain.SessionFa } // SetLifetime mocks base method. -func (m *MockSessionRepository) SetLifetime(lifetime time.Duration) database.Change { +func (m *MockSessionRepository) SetLifetime(arg0 time.Duration) database.Change { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SetLifetime", lifetime) + ret := m.ctrl.Call(m, "SetLifetime", arg0) ret0, _ := ret[0].(database.Change) return ret0 } // SetLifetime indicates an expected call of SetLifetime. -func (mr *MockSessionRepositoryMockRecorder) SetLifetime(lifetime any) *MockSessionRepositorySetLifetimeCall { +func (mr *MockSessionRepositoryMockRecorder) SetLifetime(arg0 any) *MockSessionRepositorySetLifetimeCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetLifetime", reflect.TypeOf((*MockSessionRepository)(nil).SetLifetime), lifetime) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetLifetime", reflect.TypeOf((*MockSessionRepository)(nil).SetLifetime), arg0) return &MockSessionRepositorySetLifetimeCall{Call: call} } @@ -969,17 +969,17 @@ func (c *MockSessionRepositorySetLifetimeCall) DoAndReturn(f func(time.Duration) } // SetMetadata mocks base method. -func (m *MockSessionRepository) SetMetadata(metadata []*domain.SessionMetadata) database.Change { +func (m *MockSessionRepository) SetMetadata(arg0 []*domain.SessionMetadata) database.Change { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SetMetadata", metadata) + ret := m.ctrl.Call(m, "SetMetadata", arg0) ret0, _ := ret[0].(database.Change) return ret0 } // SetMetadata indicates an expected call of SetMetadata. -func (mr *MockSessionRepositoryMockRecorder) SetMetadata(metadata any) *MockSessionRepositorySetMetadataCall { +func (mr *MockSessionRepositoryMockRecorder) SetMetadata(arg0 any) *MockSessionRepositorySetMetadataCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetMetadata", reflect.TypeOf((*MockSessionRepository)(nil).SetMetadata), metadata) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetMetadata", reflect.TypeOf((*MockSessionRepository)(nil).SetMetadata), arg0) return &MockSessionRepositorySetMetadataCall{Call: call} } @@ -1007,17 +1007,17 @@ func (c *MockSessionRepositorySetMetadataCall) DoAndReturn(f func([]*domain.Sess } // SetToken mocks base method. -func (m *MockSessionRepository) SetToken(token string) database.Change { +func (m *MockSessionRepository) SetToken(arg0 string) database.Change { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SetToken", token) + ret := m.ctrl.Call(m, "SetToken", arg0) ret0, _ := ret[0].(database.Change) return ret0 } // SetToken indicates an expected call of SetToken. -func (mr *MockSessionRepositoryMockRecorder) SetToken(token any) *MockSessionRepositorySetTokenCall { +func (mr *MockSessionRepositoryMockRecorder) SetToken(arg0 any) *MockSessionRepositorySetTokenCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetToken", reflect.TypeOf((*MockSessionRepository)(nil).SetToken), token) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetToken", reflect.TypeOf((*MockSessionRepository)(nil).SetToken), arg0) return &MockSessionRepositorySetTokenCall{Call: call} } @@ -1045,17 +1045,17 @@ func (c *MockSessionRepositorySetTokenCall) DoAndReturn(f func(string) database. } // SetUpdatedAt mocks base method. -func (m *MockSessionRepository) SetUpdatedAt(updatedAt time.Time) database.Change { +func (m *MockSessionRepository) SetUpdatedAt(arg0 time.Time) database.Change { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SetUpdatedAt", updatedAt) + ret := m.ctrl.Call(m, "SetUpdatedAt", arg0) ret0, _ := ret[0].(database.Change) return ret0 } // SetUpdatedAt indicates an expected call of SetUpdatedAt. -func (mr *MockSessionRepositoryMockRecorder) SetUpdatedAt(updatedAt any) *MockSessionRepositorySetUpdatedAtCall { +func (mr *MockSessionRepositoryMockRecorder) SetUpdatedAt(arg0 any) *MockSessionRepositorySetUpdatedAtCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetUpdatedAt", reflect.TypeOf((*MockSessionRepository)(nil).SetUpdatedAt), updatedAt) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetUpdatedAt", reflect.TypeOf((*MockSessionRepository)(nil).SetUpdatedAt), arg0) return &MockSessionRepositorySetUpdatedAtCall{Call: call} } @@ -1120,11 +1120,49 @@ func (c *MockSessionRepositoryTokenIDColumnCall) DoAndReturn(f func() database.C return c } -// Update mocks base method. -func (m *MockSessionRepository) Update(ctx context.Context, client database.QueryExecutor, condition database.Condition, changes ...database.Change) (int64, error) { +// TokenIDCondition mocks base method. +func (m *MockSessionRepository) TokenIDCondition(arg0 string) database.Condition { m.ctrl.T.Helper() - varargs := []any{ctx, client, condition} - for _, a := range changes { + ret := m.ctrl.Call(m, "TokenIDCondition", arg0) + ret0, _ := ret[0].(database.Condition) + return ret0 +} + +// TokenIDCondition indicates an expected call of TokenIDCondition. +func (mr *MockSessionRepositoryMockRecorder) TokenIDCondition(arg0 any) *MockSessionRepositoryTokenIDConditionCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TokenIDCondition", reflect.TypeOf((*MockSessionRepository)(nil).TokenIDCondition), arg0) + return &MockSessionRepositoryTokenIDConditionCall{Call: call} +} + +// MockSessionRepositoryTokenIDConditionCall wrap *gomock.Call +type MockSessionRepositoryTokenIDConditionCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockSessionRepositoryTokenIDConditionCall) Return(arg0 database.Condition) *MockSessionRepositoryTokenIDConditionCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockSessionRepositoryTokenIDConditionCall) Do(f func(string) database.Condition) *MockSessionRepositoryTokenIDConditionCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockSessionRepositoryTokenIDConditionCall) DoAndReturn(f func(string) database.Condition) *MockSessionRepositoryTokenIDConditionCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// Update mocks base method. +func (m *MockSessionRepository) Update(arg0 context.Context, arg1 database.QueryExecutor, arg2 database.Condition, arg3 ...database.Change) (int64, error) { + m.ctrl.T.Helper() + varargs := []any{arg0, arg1, arg2} + for _, a := range arg3 { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "Update", varargs...) @@ -1134,9 +1172,9 @@ func (m *MockSessionRepository) Update(ctx context.Context, client database.Quer } // Update indicates an expected call of Update. -func (mr *MockSessionRepositoryMockRecorder) Update(ctx, client, condition any, changes ...any) *MockSessionRepositoryUpdateCall { +func (mr *MockSessionRepositoryMockRecorder) Update(arg0, arg1, arg2 any, arg3 ...any) *MockSessionRepositoryUpdateCall { mr.mock.ctrl.T.Helper() - varargs := append([]any{ctx, client, condition}, changes...) + varargs := append([]any{arg0, arg1, arg2}, arg3...) call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockSessionRepository)(nil).Update), varargs...) return &MockSessionRepositoryUpdateCall{Call: call} } @@ -1203,17 +1241,17 @@ func (c *MockSessionRepositoryUpdatedAtColumnCall) DoAndReturn(f func() database } // UpdatedAtCondition mocks base method. -func (m *MockSessionRepository) UpdatedAtCondition(op database.NumberOperation, updatedAt time.Time) database.Condition { +func (m *MockSessionRepository) UpdatedAtCondition(arg0 database.NumberOperation, arg1 time.Time) database.Condition { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdatedAtCondition", op, updatedAt) + ret := m.ctrl.Call(m, "UpdatedAtCondition", arg0, arg1) ret0, _ := ret[0].(database.Condition) return ret0 } // UpdatedAtCondition indicates an expected call of UpdatedAtCondition. -func (mr *MockSessionRepositoryMockRecorder) UpdatedAtCondition(op, updatedAt any) *MockSessionRepositoryUpdatedAtConditionCall { +func (mr *MockSessionRepositoryMockRecorder) UpdatedAtCondition(arg0, arg1 any) *MockSessionRepositoryUpdatedAtConditionCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatedAtCondition", reflect.TypeOf((*MockSessionRepository)(nil).UpdatedAtCondition), op, updatedAt) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatedAtCondition", reflect.TypeOf((*MockSessionRepository)(nil).UpdatedAtCondition), arg0, arg1) return &MockSessionRepositoryUpdatedAtConditionCall{Call: call} } @@ -1241,17 +1279,17 @@ func (c *MockSessionRepositoryUpdatedAtConditionCall) DoAndReturn(f func(databas } // UserAgentIDCondition mocks base method. -func (m *MockSessionRepository) UserAgentIDCondition(userAgentID string) database.Condition { +func (m *MockSessionRepository) UserAgentIDCondition(arg0 string) database.Condition { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UserAgentIDCondition", userAgentID) + ret := m.ctrl.Call(m, "UserAgentIDCondition", arg0) ret0, _ := ret[0].(database.Condition) return ret0 } // UserAgentIDCondition indicates an expected call of UserAgentIDCondition. -func (mr *MockSessionRepositoryMockRecorder) UserAgentIDCondition(userAgentID any) *MockSessionRepositoryUserAgentIDConditionCall { +func (mr *MockSessionRepositoryMockRecorder) UserAgentIDCondition(arg0 any) *MockSessionRepositoryUserAgentIDConditionCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserAgentIDCondition", reflect.TypeOf((*MockSessionRepository)(nil).UserAgentIDCondition), userAgentID) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserAgentIDCondition", reflect.TypeOf((*MockSessionRepository)(nil).UserAgentIDCondition), arg0) return &MockSessionRepositoryUserAgentIDConditionCall{Call: call} } @@ -1317,17 +1355,17 @@ func (c *MockSessionRepositoryUserIDColumnCall) DoAndReturn(f func() database.Co } // UserIDCondition mocks base method. -func (m *MockSessionRepository) UserIDCondition(userID string) database.Condition { +func (m *MockSessionRepository) UserIDCondition(arg0 string) database.Condition { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UserIDCondition", userID) + ret := m.ctrl.Call(m, "UserIDCondition", arg0) ret0, _ := ret[0].(database.Condition) return ret0 } // UserIDCondition indicates an expected call of UserIDCondition. -func (mr *MockSessionRepositoryMockRecorder) UserIDCondition(userID any) *MockSessionRepositoryUserIDConditionCall { +func (mr *MockSessionRepositoryMockRecorder) UserIDCondition(arg0 any) *MockSessionRepositoryUserIDConditionCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserIDCondition", reflect.TypeOf((*MockSessionRepository)(nil).UserIDCondition), userID) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserIDCondition", reflect.TypeOf((*MockSessionRepository)(nil).UserIDCondition), arg0) return &MockSessionRepositoryUserIDConditionCall{Call: call} } diff --git a/backend/v3/domain/mock/session_repo.go b/backend/v3/domain/mock/session_repo.go index 6f4c195c2..5c7b3aa9f 100644 --- a/backend/v3/domain/mock/session_repo.go +++ b/backend/v3/domain/mock/session_repo.go @@ -2,6 +2,7 @@ package domainmock import ( context "context" + "time" gomock "go.uber.org/mock/gomock" @@ -42,6 +43,6 @@ func (s *SessionRepo) Update(ctx context.Context, client database.QueryExecutor, return s.mock.Update(ctx, client, condition, changes...) } -func (s *SessionRepo) Delete(ctx context.Context, client database.QueryExecutor, condition database.Condition) (int64, error) { - return s.mock.Delete(ctx, client, condition) +func (s *SessionRepo) Delete(ctx context.Context, client database.QueryExecutor, condition database.Condition, permissionCondition database.Condition) (int64, time.Time, error) { + return s.mock.Delete(ctx, client, condition, permissionCondition) } diff --git a/backend/v3/domain/permission.go b/backend/v3/domain/permission.go index efedfbe48..ca38521fa 100644 --- a/backend/v3/domain/permission.go +++ b/backend/v3/domain/permission.go @@ -11,6 +11,7 @@ const ( OrganizationWritePermission = "organization.write" SessionReadPermission = "session.read" SessionWritePermission = "session.write" + SessionDeletePermission = "session.delete" ) // PermissionRepository is the interface that manages and checks Zitadel permissions. diff --git a/backend/v3/domain/session.go b/backend/v3/domain/session.go index 1aa8989ab..f2980ead5 100644 --- a/backend/v3/domain/session.go +++ b/backend/v3/domain/session.go @@ -42,7 +42,14 @@ type SessionRepository interface { // The condition must include at least the instanceID of the session to update. Update(ctx context.Context, client database.QueryExecutor, condition database.Condition, changes ...database.Change) (int64, error) // Delete removes sessions based on the given condition. - Delete(ctx context.Context, client database.QueryExecutor, condition database.Condition) (int64, error) + // An additional permission condition can be provided to check if the session can be deleted, for example by checking the token id or user id. + // The primary condition is used to select the session(s) to be deleted, while the permission condition can be used to verify that the session(s) meet certain criteria before deletion + // respectively to return an error if the criteria are not met. In case no session matches the primary condition and a permission condition is provided, + // the method will return a permission denied error, as the session(s) that should be deleted do not meet the criteria defined in the permission condition. + // The method returns the number of deleted rows / session(s) and the latest deletion timestamp of the deleted session in case of a single deleted session. + // In case the session was previously deleted, the method returns 0 and the previous deletion timestamp. + // If multiple sessions are deleted, the method returns the number of deleted sessions and no timestamp, as it cannot be determined which session's deletion timestamp to return. + Delete(ctx context.Context, client database.QueryExecutor, condition database.Condition, permissionCondition database.Condition) (int64, time.Time, error) } // sessionColumns define all the columns of the session table. @@ -77,6 +84,8 @@ type sessionConditions interface { UserAgentIDCondition(userAgentID string) database.Condition // UserIDCondition returns an equal filter on the user id field. UserIDCondition(userID string) database.Condition + // TokenIDCondition returns an equal filter on the token id field. + TokenIDCondition(tokenID string) database.Condition // CreatorIDCondition returns an equal filter on the creator id field. CreatorIDCondition(creatorID string) database.Condition // ExpirationCondition returns a filter on the expiration field. diff --git a/backend/v3/domain/session_delete.go b/backend/v3/domain/session_delete.go new file mode 100644 index 000000000..1ae5542cd --- /dev/null +++ b/backend/v3/domain/session_delete.go @@ -0,0 +1,127 @@ +package domain + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/zitadel/zitadel/backend/v3/storage/database" + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/session" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type DeleteSessionCommand struct { + ID string `json:"id"` + Token string `json:"token"` + MustCheckPermission bool `json:"mustCheckPermission,omitempty"` + + // DeletedAt is set after successful execution. + DeletedAt time.Time `json:"deletedAt,omitempty"` + // deletedRows is set after successful execution, used to determine if the session was deleted or not. + deletedRows int64 +} + +func NewDeleteSessionCommand( + id string, + token string, + mustCheckPermission bool, +) *DeleteSessionCommand { + return &DeleteSessionCommand{ + ID: id, + Token: token, + MustCheckPermission: mustCheckPermission, + } +} + +// RequiresTransaction implements [Transactional]. +func (cmd *DeleteSessionCommand) RequiresTransaction() {} + +// Events implements [Commander]. +func (cmd *DeleteSessionCommand) Events(ctx context.Context, opts *InvokeOpts) ([]eventstore.Command, error) { + if cmd.deletedRows == 0 { + return nil, nil + } + return []eventstore.Command{ + session.NewTerminateEvent(ctx, &session.NewAggregate(cmd.ID, authz.GetInstance(ctx).InstanceID()).Aggregate), + }, nil +} + +const ( + ErrSessionTokenInvalid = "ErrSessionTokenInvalid" + ErrMoreThanOneRowAffected = "ErrMoreThanOneRowAffected" + ErrIDMissing = "ErrIDMissing" + ErrMissingPermission = "ErrMissingPermission" + ErrInternal = "ErrInternal" +) + +func (cmd *DeleteSessionCommand) sessionDeletePermissionCheckCondition(ctx context.Context, sessionRepo SessionRepository, decryptor SessionTokenDecryptor) (database.Condition, error) { + if !cmd.MustCheckPermission { + return nil, nil + } + if cmd.Token != "" { + sessionID, tokenID, err := decryptor(ctx, cmd.Token) + if err != nil || sessionID != cmd.ID { + return nil, zerrors.ThrowPermissionDenied(err, ErrSessionTokenInvalid, "Errors.Session.Token.Invalid") + } + return database.Or( + sessionRepo.TokenIDCondition(tokenID), + database.PermissionCheck(SessionDeletePermission, true), + ), nil + } + return database.Or( + sessionRepo.UserIDCondition(authz.GetCtxData(ctx).UserID), + database.PermissionCheck(SessionDeletePermission, true), // TODO: implement check once permissions are implemented + ), nil +} + +// Execute implements [Commander]. +func (cmd *DeleteSessionCommand) Execute(ctx context.Context, opts *InvokeOpts) (err error) { + sessionRepo := opts.sessionRepo + instance := authz.GetInstance(ctx) + + permCheck, err := cmd.sessionDeletePermissionCheckCondition(ctx, sessionRepo, opts.sessionTokenDecryptor) + if err != nil { + return err + } + + deletedRows, deletedAt, err := sessionRepo.Delete(ctx, opts.DB(), + sessionRepo.PrimaryKeyCondition(instance.InstanceID(), cmd.ID), + permCheck, + ) + if err != nil { + if errors.Is(err, new(database.PermissionError)) { + return zerrors.ThrowPermissionDenied(err, ErrMissingPermission, + "insufficient permissions to delete session, require `session.delete` permission, ownership of the session or current session token") + } + return zerrors.ThrowInternal(err, ErrInternal, "an unexpected error occurred while deleting the session") + } + + if deletedRows > 1 { + return zerrors.ThrowInternalf(nil, ErrMoreThanOneRowAffected, "expected 1 session to be deleted, got %d", deletedRows) + } + + cmd.DeletedAt = deletedAt + cmd.deletedRows = deletedRows + return nil +} + +// String implements [Commander]. +func (*DeleteSessionCommand) String() string { + return "DeleteSessionCommand" +} + +// Validate implements [Commander]. +func (cmd *DeleteSessionCommand) Validate(ctx context.Context, opts *InvokeOpts) (err error) { + if cmd.ID = strings.TrimSpace(cmd.ID); cmd.ID == "" { + return zerrors.ThrowInvalidArgument(nil, ErrIDMissing, "Errors.IDMissing") + } + return nil +} + +var ( + _ Commander = (*DeleteSessionCommand)(nil) + _ Transactional = (*DeleteSessionCommand)(nil) +) diff --git a/backend/v3/domain/session_delete_test.go b/backend/v3/domain/session_delete_test.go new file mode 100644 index 000000000..1b6b97c40 --- /dev/null +++ b/backend/v3/domain/session_delete_test.go @@ -0,0 +1,347 @@ +package domain_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "github.com/zitadel/zitadel/backend/v3/domain" + domainmock "github.com/zitadel/zitadel/backend/v3/domain/mock" + "github.com/zitadel/zitadel/backend/v3/storage/database" + noopdb "github.com/zitadel/zitadel/backend/v3/storage/database/dialect/noop" + "github.com/zitadel/zitadel/internal/api/authz" + zdomain "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestDeleteSessionCommand_Validate(t *testing.T) { + t.Parallel() + ctx := authz.NewMockContext("inst-1", "org-1", gofakeit.UUID()) + + type args struct { + sessionID string + sessionToken string + mustCheckToken bool + } + type fields struct { + sessionRepo func(ctrl *gomock.Controller) domain.SessionRepository + permissionCheck func(ctrl *gomock.Controller) domain.PermissionChecker + } + tt := []struct { + name string + args args + fields fields + expectedError error + }{ + { + name: "when sessionID is empty should return invalid argument error", + args: args{ + sessionID: "", + }, + fields: fields{}, + expectedError: zerrors.ThrowInvalidArgument(nil, domain.ErrIDMissing, "Errors.IDMissing"), + }, + { + name: "when sessionID is provided must validate successfully", + args: args{ + sessionID: "session", + }, + fields: fields{}, + expectedError: nil, + }, + } + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + // Given + d := domain.NewDeleteSessionCommand(tc.args.sessionID, tc.args.sessionToken, tc.args.mustCheckToken) + ctrl := gomock.NewController(t) + opts := domain.DefaultOpts(nil) + domain.WithQueryExecutor(new(noopdb.Pool))(opts) + + if tc.fields.sessionRepo != nil { + domain.WithSessionRepo(tc.fields.sessionRepo(ctrl))(opts) + } + if tc.fields.permissionCheck != nil { + domain.WithPermissionChecker(tc.fields.permissionCheck(ctrl))(opts) + } + + // Test + err := d.Validate(ctx, opts) + + // Verify + assert.Equal(t, tc.expectedError, err) + }) + } +} + +func TestDeleteSessionCommand_Execute(t *testing.T) { + t.Parallel() + + userID := gofakeit.UUID() + ctx := authz.NewMockContext("inst-1", "org-1", userID) + deleteErr := errors.New("delete error") + + type args struct { + mockTx func(ctrl *gomock.Controller) database.QueryExecutor + sessionRepo func(ctrl *gomock.Controller) domain.SessionRepository + sessionTokenDecryptor func(t *testing.T) domain.SessionTokenDecryptor + } + type fields struct { + sessionID string + sessionToken string + mustCheckPermission bool + } + type res struct { + error error + deletedAt bool + events int + } + tt := []struct { + testName string + + args args + fields fields + res res + }{ + { + testName: "when delete session fails should return error", + args: args{ + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + repo.EXPECT(). + Delete(gomock.Any(), gomock.Any(), + repo.PrimaryKeyCondition("inst-1", "session-1"), + nil, + ). + Times(1). + Return(int64(0), time.Time{}, deleteErr) + return repo + }, + }, + fields: fields{ + sessionID: "session-1", + }, + res: res{ + error: deleteErr, + }, + }, + { + testName: "when no token is provided but must check permission, should use userID check", + args: args{ + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + repo.EXPECT(). + Delete(gomock.Any(), gomock.Any(), + repo.PrimaryKeyCondition("inst-1", "session-1"), + database.Or( + repo.UserIDCondition(userID), + database.PermissionCheck(zdomain.PermissionSessionDelete, true), + ), + ). + Times(1). + Return(int64(0), time.Time{}, nil) + return repo + }, + }, + fields: fields{ + sessionID: "session-1", + mustCheckPermission: true, + }, + res: res{ + error: nil, + }, + }, + { + testName: "when token is provided and must check permission, should use tokenID check", + args: args{ + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + repo.EXPECT(). + Delete(gomock.Any(), gomock.Any(), + repo.PrimaryKeyCondition("inst-1", "session-1"), + database.Or( + repo.TokenIDCondition("token-1"), + database.PermissionCheck(zdomain.PermissionSessionDelete, true), + ), + ). + Times(1). + Return(int64(0), time.Time{}, nil) + return repo + }, + sessionTokenDecryptor: mockSessionTokenDecryptor("session-token-1", "session-1", "token-1"), + }, + fields: fields{ + sessionID: "session-1", + sessionToken: "session-token-1", + mustCheckPermission: true, + }, + res: res{ + error: nil, + }, + }, + { + testName: "when delete session without permission should return error", + args: args{ + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + repo.EXPECT(). + Delete(gomock.Any(), gomock.Any(), + repo.PrimaryKeyCondition("inst-1", "session-1"), + database.Or( + repo.UserIDCondition(userID), + database.PermissionCheck(zdomain.PermissionSessionDelete, true), + ), + ). + Times(1). + Return(int64(0), time.Time{}, database.NewPermissionError(nil)) + return repo + }, + }, + fields: fields{ + sessionID: "session-1", + mustCheckPermission: true, + }, + res: res{ + error: new(database.PermissionError), + }, + }, + { + testName: "when more than one row deleted must return internal error", + args: args{ + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + repo.EXPECT(). + Delete(gomock.Any(), gomock.Any(), + repo.PrimaryKeyCondition("inst-1", "session-1"), + nil, + ). + Times(1). + Return(int64(2), time.Time{}, nil) + return repo + }, + }, + fields: fields{ + sessionID: "session-1", + }, + res: res{ + error: zerrors.ThrowInternalf(nil, domain.ErrMoreThanOneRowAffected, "expected 1 session to be deleted, got %d", 2), + }, + }, + { + testName: "when no rows deleted should execute successfully", + args: args{ + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + repo.EXPECT(). + Delete(gomock.Any(), gomock.Any(), + repo.PrimaryKeyCondition("inst-1", "session-1"), + nil, + ). + Times(1). + Return(int64(0), time.Time{}, nil) + return repo + }, + }, + fields: fields{ + sessionID: "session-1", + }, + res: res{ + error: nil, + }, + }, + { + testName: "when one row deleted should execute successfully and set DeletedAt", + args: args{ + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + repo.EXPECT(). + Delete(gomock.Any(), gomock.Any(), + repo.PrimaryKeyCondition("inst-1", "session-1"), + nil, + ). + Times(1). + Return(int64(1), time.Now(), nil) + return repo + }, + }, + fields: fields{ + sessionID: "session-1", + }, + res: res{ + deletedAt: true, + events: 1, + }, + }, + { + testName: "when session already deleted should execute successfully, set DeletedAt but no event", + args: args{ + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + repo.EXPECT(). + Delete(gomock.Any(), gomock.Any(), + repo.PrimaryKeyCondition("inst-1", "session-1"), + nil, + ). + Times(1). + Return(int64(0), time.Now(), nil) + return repo + }, + }, + fields: fields{ + sessionID: "session-1", + }, + res: res{ + deletedAt: true, + events: 0, + }, + }, + } + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + // Given + cmd := domain.NewDeleteSessionCommand(tc.fields.sessionID, tc.fields.sessionToken, tc.fields.mustCheckPermission) + ctrl := gomock.NewController(t) + opts := domain.DefaultOpts(nil) + domain.WithQueryExecutor(new(noopdb.Pool))(opts) + if tc.args.mockTx != nil { + domain.WithQueryExecutor(tc.args.mockTx(ctrl))(opts) + } + if tc.args.sessionRepo != nil { + domain.WithSessionRepo(tc.args.sessionRepo(ctrl))(opts) + } + if tc.args.sessionTokenDecryptor != nil { + domain.WithSessionTokenDecryptor(tc.args.sessionTokenDecryptor(t))(opts) + } + + // Test + err := cmd.Execute(ctx, opts) + + // Verify + assert.ErrorIs(t, err, tc.res.error) + assert.Equal(t, tc.res.deletedAt, !cmd.DeletedAt.IsZero()) + + // Test events + cmds, err := cmd.Events(ctx, opts) + assert.NoError(t, err) + assert.Len(t, cmds, tc.res.events) + }) + } +} + +func mockSessionTokenDecryptor(expectedSessionToken, returnSessionID, returnSessionToken string) func(t *testing.T) domain.SessionTokenDecryptor { + return func(t *testing.T) domain.SessionTokenDecryptor { + return func(ctx context.Context, sessionToken string) (sessionID, tokenID string, err error) { + if sessionToken != expectedSessionToken { + return "", "", zerrors.ThrowInvalidArgumentf(nil, "SESS-S3gq1", "Errors.Session.TokenInvalid") + } + return returnSessionID, returnSessionToken, nil + } + } +} diff --git a/backend/v3/domain/token_verifier.go b/backend/v3/domain/token_verifier.go new file mode 100644 index 000000000..3c11fda06 --- /dev/null +++ b/backend/v3/domain/token_verifier.go @@ -0,0 +1,7 @@ +package domain + +import "context" + +type SessionTokenVerifier func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) + +type SessionTokenDecryptor func(ctx context.Context, sessionToken string) (sessionID, tokenID string, err error) diff --git a/backend/v3/storage/database/condition.go b/backend/v3/storage/database/condition.go index 4d9ada49a..0a4dcb73f 100644 --- a/backend/v3/storage/database/condition.go +++ b/backend/v3/storage/database/condition.go @@ -313,8 +313,14 @@ type existsCondition struct { // Matches implements [Condition]. func (e *existsCondition) Matches(x any) bool { - // Unimplemented - return false + toMatch, ok := x.(*existsCondition) + if !ok { + return false + } + if e.table != toMatch.table { + return false + } + return e.condition.Matches(toMatch.condition) } // String implements [Condition]. @@ -364,3 +370,46 @@ func ForceRestrictingColumn(cond Condition, isRestriction bool) Condition { } var _ Condition = (*forcedRestrictingColumnCondition)(nil) + +type permissionCheck struct { + requiredPermission string + throwError bool +} + +func (p *permissionCheck) Matches(x any) bool { + toMatch, ok := x.(*permissionCheck) + if !ok { + return false + } + if toMatch.requiredPermission != p.requiredPermission { + return false + } + return toMatch.throwError == p.throwError +} + +func (p *permissionCheck) String() string { + return "database.permissionCheck" +} + +func (p *permissionCheck) Write(builder *StatementBuilder) { + // TODO: implement actual permission check once we have the necessary tables and functions in place. + // For now, we just write a dummy condition that can be used to verify that the permission check is included in the generated SQL. + if p.throwError { + builder.WriteString(" zitadel.throw_not_permitted() ") + return + } + builder.WriteString(" true ") +} + +func (p *permissionCheck) IsRestrictingColumn(_ Column) bool { + return false +} + +func PermissionCheck(requiredPermission string, throwError bool) *permissionCheck { + return &permissionCheck{ + requiredPermission: requiredPermission, + throwError: throwError, + } +} + +var _ Condition = (*permissionCheck)(nil) diff --git a/backend/v3/storage/database/dialect/postgres/error.go b/backend/v3/storage/database/dialect/postgres/error.go index 433db5adc..b17b30de0 100644 --- a/backend/v3/storage/database/dialect/postgres/error.go +++ b/backend/v3/storage/database/dialect/postgres/error.go @@ -10,6 +10,10 @@ import ( "github.com/zitadel/zitadel/backend/v3/storage/database" ) +const ( + ZIT01 = "ZIT01" // custom error code to express missing permissions by the end-user +) + func wrapError(err error) error { if err == nil { return nil @@ -54,6 +58,9 @@ func wrapPgError(err *pgconn.PgError) error { // 22P02: integrity violation - A value is being inserted into a enum that is not valid case "22P02": return database.NewCheckError(err.TableName, err.ConstraintName, err) + // ZIT01: missing permissions by the end-user + case ZIT01: + return database.NewPermissionError(err) default: return database.NewUnknownError(err) } diff --git a/backend/v3/storage/database/dialect/postgres/migration/017_archived_sessions_table.go b/backend/v3/storage/database/dialect/postgres/migration/017_archived_sessions_table.go new file mode 100644 index 000000000..07cd646ba --- /dev/null +++ b/backend/v3/storage/database/dialect/postgres/migration/017_archived_sessions_table.go @@ -0,0 +1,16 @@ +package migration + +import ( + _ "embed" +) + +var ( + //go:embed 017_archived_sessions_table/up.sql + up017ArchivedSessionsTable string + //go:embed 017_archived_sessions_table/down.sql + down017ArchivedSessionsTable string +) + +func init() { + registerSQLMigration(17, up017ArchivedSessionsTable, down017ArchivedSessionsTable) +} diff --git a/backend/v3/storage/database/dialect/postgres/migration/017_archived_sessions_table/down.sql b/backend/v3/storage/database/dialect/postgres/migration/017_archived_sessions_table/down.sql new file mode 100644 index 000000000..dbe257a10 --- /dev/null +++ b/backend/v3/storage/database/dialect/postgres/migration/017_archived_sessions_table/down.sql @@ -0,0 +1,4 @@ +DROP TRIGGER IF EXISTS trg_move_to_archived_sessions ON zitadel.sessions; +DROP FUNCTION IF EXISTS zitadel.move_to_archived_sessions() CASCADE; +DROP TABLE IF EXISTS zitadel.archived_sessions CASCADE; +DROP FUNCTION IF EXISTS zitadel.throw_not_permitted(); \ No newline at end of file diff --git a/backend/v3/storage/database/dialect/postgres/migration/017_archived_sessions_table/up.sql b/backend/v3/storage/database/dialect/postgres/migration/017_archived_sessions_table/up.sql new file mode 100644 index 000000000..e5e1c2ca5 --- /dev/null +++ b/backend/v3/storage/database/dialect/postgres/migration/017_archived_sessions_table/up.sql @@ -0,0 +1,37 @@ +CREATE TABLE zitadel.archived_sessions ( + instance_id TEXT NOT NULL + , id TEXT NOT NULL CHECK (id <> '') + , token_id TEXT + , user_agent_id TEXT + , expiration TIMESTAMPTZ + , user_id TEXT + , creator_id TEXT + , deleted_at TIMESTAMPTZ DEFAULT NOW() NOT NULL + + , PRIMARY KEY (instance_id, id) + , FOREIGN KEY (instance_id) REFERENCES zitadel.instances(id) + , FOREIGN KEY (instance_id, user_id) REFERENCES zitadel.users(instance_id, id) ON DELETE CASCADE + , FOREIGN KEY (instance_id, user_agent_id) REFERENCES zitadel.session_user_agents(instance_id, fingerprint_id) ON DELETE SET NULL (user_agent_id) +); + +CREATE OR REPLACE FUNCTION zitadel.move_to_archived_sessions() RETURNS trigger AS $$ +BEGIN + INSERT INTO zitadel.archived_sessions (instance_id, id, token_id, user_agent_id, expiration, user_id, creator_id, deleted_at) + VALUES (OLD.instance_id, OLD.id, OLD.token_id, OLD.user_agent_id, OLD.expiration, OLD.user_id, OLD.creator_id, now()); + RETURN OLD; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_move_to_archived_sessions +AFTER DELETE ON zitadel.sessions +FOR EACH ROW +EXECUTE FUNCTION zitadel.move_to_archived_sessions(); + +-- placeholder for permissions, will be replaced by actual permission checks in the future, +-- respectively will be used to fallback to when permissions are not yet implemented for a specific action +CREATE OR REPLACE FUNCTION zitadel.throw_not_permitted() returns boolean AS $$ +BEGIN + RAISE EXCEPTION 'Permission denied: User does not have permission' + USING ERRCODE = 'ZIT01'; +END; +$$ LANGUAGE plpgsql; \ No newline at end of file diff --git a/backend/v3/storage/database/errors.go b/backend/v3/storage/database/errors.go index 921bdd86e..2c61b1ef4 100644 --- a/backend/v3/storage/database/errors.go +++ b/backend/v3/storage/database/errors.go @@ -272,3 +272,26 @@ func (e *UnknownError) Is(target error) bool { func (e *UnknownError) Unwrap() error { return e.original } + +type PermissionError struct { + original error +} + +func NewPermissionError(original error) error { + return &PermissionError{ + original: original, + } +} + +func (e *PermissionError) Error() string { + return fmt.Sprintf("permission error: %v", e.original) +} + +func (e *PermissionError) Is(target error) bool { + _, ok := target.(*PermissionError) + return ok +} + +func (e *PermissionError) Unwrap() error { + return e.original +} diff --git a/backend/v3/storage/database/null.go b/backend/v3/storage/database/null.go new file mode 100644 index 000000000..daed1bb05 --- /dev/null +++ b/backend/v3/storage/database/null.go @@ -0,0 +1,6 @@ +package database + +import "database/sql" + +// Null is a type alias for sql.Null[T] to avoid importing the sql package into repositories. +type Null[T any] = sql.Null[T] diff --git a/backend/v3/storage/database/repository/session.go b/backend/v3/storage/database/repository/session.go index 23339112d..21f370acf 100644 --- a/backend/v3/storage/database/repository/session.go +++ b/backend/v3/storage/database/repository/session.go @@ -2,6 +2,7 @@ package repository import ( "context" + "errors" "fmt" "time" @@ -228,14 +229,40 @@ func sessionCTE(change database.Change, i, j int, builder *database.StatementBui } // Delete implements [domain.SessionRepository]. -func (s session) Delete(ctx context.Context, client database.QueryExecutor, condition database.Condition) (int64, error) { +func (s session) Delete(ctx context.Context, client database.QueryExecutor, condition database.Condition, permissionCondition database.Condition) (int64, time.Time, error) { if !condition.IsRestrictingColumn(s.InstanceIDColumn()) { - return 0, database.NewMissingConditionError(s.InstanceIDColumn()) + return 0, time.Time{}, database.NewMissingConditionError(s.InstanceIDColumn()) } var builder database.StatementBuilder - builder.WriteString("DELETE FROM zitadel.sessions") + builder.WriteString("WITH gatekeeper AS (SELECT count(*) as total_found, MAX(sessions.deleted_at) as deleted_at FROM (SELECT instance_id, user_id, token_id, now() as deleted_at FROM zitadel.sessions") writeCondition(&builder, condition) - return client.Exec(ctx, builder.String(), builder.Args()...) + // In case the deletion is intended for a specific session, + // we'll also include already deleted session to check the permission against, + // and to return the deleted_at timestamp from the previous deletion in case the session was already deleted. + if condition.IsRestrictingColumn(s.IDColumn()) { + builder.WriteString(" UNION ALL SELECT instance_id, user_id, token_id, deleted_at FROM zitadel.archived_sessions as sessions") + writeCondition(&builder, condition) + } + // join the users table to get the user's organization, which will be needed for checking the permission + builder.WriteString(") as sessions") + if permissionCondition != nil { + builder.WriteString(" LEFT JOIN zitadel.users ON users.instance_id = sessions.instance_id AND users.id = sessions.user_id HAVING (bool_and(") + permissionCondition.Write(&builder) + builder.WriteString(") AND count(*) > 0) or zitadel.throw_not_permitted()") //TODO: change throw_not_permitted to actual permission check + } + builder.WriteString("), execution AS (DELETE FROM zitadel.sessions") + writeCondition(&builder, condition) + builder.WriteString(" and exists (SELECT 1 FROM gatekeeper) RETURNING 1 AS rows_affected) SELECT (CASE WHEN gatekeeper.total_found = 1 THEN gatekeeper.deleted_at END) as deleted_at, (SELECT count(*) FROM execution) as rows_affected FROM gatekeeper") + var deletedAt database.Null[time.Time] + var deletedSessions int64 + if err := client.QueryRow(ctx, builder.String(), builder.Args()...).Scan(&deletedAt, &deletedSessions); err != nil { + // If the criteria did not match any session, ignore the error since the user would have had the permission to delete the session, + if errors.Is(err, new(database.NoRowFoundError)) { + return 0, time.Time{}, nil + } + return 0, time.Time{}, err + } + return deletedSessions, deletedAt.V, nil } // ------------------------------------------------------------- @@ -427,6 +454,11 @@ func (s session) UserIDCondition(userID string) database.Condition { return database.NewTextCondition(s.UserIDColumn(), database.TextOperationEqual, userID) } +// TokenIDCondition implements [domain.sessionConditions]. +func (s session) TokenIDCondition(tokenID string) database.Condition { + return database.NewTextCondition(s.TokenIDColumn(), database.TextOperationEqual, tokenID) +} + // CreatorIDCondition implements [domain.sessionConditions]. func (s session) CreatorIDCondition(creatorID string) database.Condition { return database.NewTextCondition(s.CreatorIDColumn(), database.TextOperationEqual, creatorID) diff --git a/backend/v3/storage/database/repository/session_test.go b/backend/v3/storage/database/repository/session_test.go index 05f5db94c..5624ca10f 100644 --- a/backend/v3/storage/database/repository/session_test.go +++ b/backend/v3/storage/database/repository/session_test.go @@ -918,6 +918,7 @@ func TestSession_Update(t *testing.T) { } func TestSession_Delete(t *testing.T) { + start := time.Now() tx, err := pool.Begin(t.Context(), nil) require.NoError(t, err) defer func() { @@ -944,59 +945,207 @@ func TestSession_Delete(t *testing.T) { err = instanceRepo.Create(t.Context(), tx, &instance) require.NoError(t, err) - type test struct { - name string - testFunc func(t *testing.T) *domain.Session + type fields struct { + testFunc func(t *testing.T) []*domain.Session + } + type args struct { + condition func([]*domain.Session) database.Condition + permissionCondition func([]*domain.Session) database.Condition + } + type res struct { noOfDeletedRows int64 + deletedAt time.Time + err error + } + + type test struct { + name string + fields fields + args args + res res } tests := []test{ { name: "delete existent session", - testFunc: func(t *testing.T) *domain.Session { - session := &domain.Session{ - InstanceID: instanceId, - ID: gofakeit.Name(), - Lifetime: time.Hour * 24, - CreatorID: gofakeit.Name(), - } - err := sessionRepo.Create(t.Context(), tx, session) - require.NoError(t, err) - return session + fields: fields{ + testFunc: func(t *testing.T) []*domain.Session { + session := &domain.Session{ + InstanceID: instanceId, + ID: gofakeit.Name(), + Lifetime: time.Hour * 24, + CreatorID: gofakeit.Name(), + } + err := sessionRepo.Create(t.Context(), tx, session) + require.NoError(t, err) + return []*domain.Session{session} + }, + }, + args: args{ + condition: func(sessions []*domain.Session) database.Condition { + return sessionRepo.PrimaryKeyCondition(instanceId, sessions[0].ID) + }, + }, + res: res{ + noOfDeletedRows: 1, + deletedAt: time.Now(), }, - noOfDeletedRows: 1, }, { name: "delete non existent session", - testFunc: func(t *testing.T) *domain.Session { - return &domain.Session{ - InstanceID: instanceId, - ID: gofakeit.Name(), - Lifetime: time.Hour * 24, - CreatorID: gofakeit.Name(), - } + fields: fields{ + testFunc: func(t *testing.T) []*domain.Session { + return []*domain.Session{} + }, + }, + args: args{ + condition: func(sessions []*domain.Session) database.Condition { + return sessionRepo.PrimaryKeyCondition(instanceId, gofakeit.UUID()) + }, + }, + res: res{ + noOfDeletedRows: 0, + }, + }, + { + name: "delete previously existent session", + fields: fields{ + testFunc: func(t *testing.T) []*domain.Session { + session := &domain.Session{ + InstanceID: instanceId, + ID: gofakeit.Name(), + Lifetime: time.Hour * 24, + CreatorID: gofakeit.Name(), + } + err := sessionRepo.Create(t.Context(), tx, session) + require.NoError(t, err) + _, _, err = sessionRepo.Delete(t.Context(), tx, sessionRepo.PrimaryKeyCondition(session.InstanceID, session.ID), nil) + require.NoError(t, err) + return []*domain.Session{session} + }, + }, + args: args{ + condition: func(sessions []*domain.Session) database.Condition { + return sessionRepo.PrimaryKeyCondition(instanceId, sessions[0].ID) + }, + }, + res: res{ + noOfDeletedRows: 0, + deletedAt: time.Now(), + }, + }, + { + name: "delete multiple sessions", + fields: fields{ + testFunc: func(t *testing.T) []*domain.Session { + session := &domain.Session{ + InstanceID: instanceId, + ID: gofakeit.Name(), + Lifetime: time.Hour * 24, + CreatorID: gofakeit.Name(), + } + err := sessionRepo.Create(t.Context(), tx, session) + require.NoError(t, err) + session2 := &domain.Session{ + InstanceID: instanceId, + ID: gofakeit.Name(), + Lifetime: time.Hour * 24, + CreatorID: gofakeit.Name(), + } + err = sessionRepo.Create(t.Context(), tx, session2) + require.NoError(t, err) + return []*domain.Session{session, session2} + }, + }, + args: args{ + condition: func(sessions []*domain.Session) database.Condition { + return sessionRepo.InstanceIDCondition(instanceId) + }, + }, + res: res{ + noOfDeletedRows: 2, + deletedAt: time.Time{}, + }, + }, + { + name: "delete without permission", + fields: fields{ + testFunc: func(t *testing.T) []*domain.Session { + session := &domain.Session{ + InstanceID: instanceId, + ID: gofakeit.Name(), + TokenID: gofakeit.UUID(), + Lifetime: time.Hour * 24, + CreatorID: gofakeit.Name(), + } + err := sessionRepo.Create(t.Context(), tx, session) + require.NoError(t, err) + return []*domain.Session{session} + }, + }, + args: args{ + condition: func(sessions []*domain.Session) database.Condition { + return sessionRepo.InstanceIDCondition(instanceId) + }, + permissionCondition: func(sessions []*domain.Session) database.Condition { + return sessionRepo.TokenIDCondition("invalid") + }, + }, + res: res{ + noOfDeletedRows: 0, + deletedAt: time.Time{}, + err: database.NewPermissionError(nil), }, - noOfDeletedRows: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - session := tt.testFunc(t) + var sessions []*domain.Session + if tt.fields.testFunc != nil { + sessions = tt.fields.testFunc(t) + } + + var condition, permissionCondition database.Condition + if tt.args.condition != nil { + condition = tt.args.condition(sessions) + } + if tt.args.permissionCondition != nil { + permissionCondition = tt.args.permissionCondition(sessions) + } + + // let's create a snapshot before deletion to make sure we could rollback in case of error + snapshot, err := tx.Begin(t.Context()) + require.NoError(t, err) // delete session - deletedRows, err := sessionRepo.Delete(t.Context(), tx, - sessionRepo.PrimaryKeyCondition(session.InstanceID, session.ID), + deletedRows, deletedAt, err := sessionRepo.Delete(t.Context(), tx, + condition, permissionCondition, ) - require.NoError(t, err) - assert.Equal(t, tt.noOfDeletedRows, deletedRows) + require.ErrorIs(t, err, tt.res.err) + assert.Equal(t, tt.res.noOfDeletedRows, deletedRows) + if tt.res.deletedAt.IsZero() { + assert.Zero(t, deletedAt) + } else { + assert.WithinRange(t, deletedAt, start, time.Now()) + } + + if err != nil { + err = snapshot.Rollback(t.Context()) + require.NoError(t, err) + } // verify session deletion - deletedSession, err := sessionRepo.Get(t.Context(), tx, - database.WithCondition( - sessionRepo.PrimaryKeyCondition(session.InstanceID, session.ID), - ), + deletedSessions, err := sessionRepo.List(t.Context(), tx, + database.WithCondition(condition), ) - require.Error(t, err, new(database.NoRowFoundError)) - assert.Nil(t, deletedSession) + require.NoError(t, err) + expectedSessions := 0 + + // in case of permission error, sessions are not deleted, + // so expectedSessions should be equal to the number of created sessions + if tt.res.err != nil { + expectedSessions = len(sessions) + } + assert.Len(t, deletedSessions, expectedSessions) }) } } diff --git a/cmd/start/start.go b/cmd/start/start.go index 46aa465e0..21b45016a 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -212,6 +212,8 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server new_domain.SetIDPEncryptionAlgorithm(keys.IDPConfig) sessionTokenVerifier := internal_authz.SessionTokenVerifier(keys.OIDC) + sessionTokenDecryptor := internal_authz.SessionTokenDecryptor(keys.OIDC) + new_domain.SetSessionTokenDecryptor(sessionTokenDecryptor) cacheConnectors, err := connector.StartConnectors(config.Caches, dbClient) if err != nil { return fmt.Errorf("unable to start caches: %w", err) diff --git a/internal/api/authz/session_token.go b/internal/api/authz/session_token.go index c6a827b0a..6b0aa112c 100644 --- a/internal/api/authz/session_token.go +++ b/internal/api/authz/session_token.go @@ -4,6 +4,7 @@ import ( "context" "encoding/base64" "fmt" + "strings" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/telemetry/tracing" @@ -30,3 +31,24 @@ func SessionTokenVerifier(algorithm crypto.EncryptionAlgorithm) func(ctx context return nil } } + +func SessionTokenDecryptor(algorithm crypto.EncryptionAlgorithm) func(ctx context.Context, sessionToken string) (sessionID, tokenID string, err error) { + return func(ctx context.Context, sessionToken string) (sessionID, tokenID string, err error) { + decodedToken, err := base64.RawURLEncoding.DecodeString(sessionToken) + if err != nil { + return "", "", zerrors.ThrowInvalidArgument(err, "AUTHZ-hi6Ph", "Errors.Session.Token.Invalid") + } + _, spanPasswordComparison := tracing.NewNamedSpan(ctx, "crypto.CompareHash") + token, err := algorithm.DecryptString(decodedToken, algorithm.EncryptionKeyID()) + spanPasswordComparison.EndWithError(err) + if err != nil { + return "", "", zerrors.ThrowPermissionDenied(err, "AUTHZ-sGr42", "Errors.Session.Token.Invalid") + } + + n, err := fmt.Sscanf(strings.ReplaceAll(token, ":", " "), strings.ReplaceAll(SessionTokenFormat, ":", " "), &sessionID, &tokenID) + if err != nil || n != 2 { + return "", "", zerrors.ThrowInvalidArgument(err, "AUTHZ-3uike", "Errors.Session.Token.Invalid") + } + return sessionID, tokenID, nil + } +} diff --git a/internal/api/grpc/session/v2/integration_test/session_test.go b/internal/api/grpc/session/v2/integration_test/session_test.go index 2a8bbf65f..0b150d2f5 100644 --- a/internal/api/grpc/session/v2/integration_test/session_test.go +++ b/internal/api/grpc/session/v2/integration_test/session_test.go @@ -21,6 +21,7 @@ import ( "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/internal/integration/sink" + "github.com/zitadel/zitadel/pkg/grpc/feature/v2" mgmt "github.com/zitadel/zitadel/pkg/grpc/management" "github.com/zitadel/zitadel/pkg/grpc/object/v2" "github.com/zitadel/zitadel/pkg/grpc/session/v2" @@ -897,95 +898,150 @@ func TestServer_SetSession_expired(t *testing.T) { } func TestServer_DeleteSession_token(t *testing.T) { - createResp, err := Client.CreateSession(LoginCTX, &session.CreateSessionRequest{}) - require.NoError(t, err) + sysAuthZ := integration.WithSystemAuthorization(CTX) - _, err = Client.DeleteSession(CTX, &session.DeleteSessionRequest{ - SessionId: createResp.GetSessionId(), - SessionToken: gu.Ptr("invalid"), - }) - require.Error(t, err) + relTableState := integration.RelationalTablesEnableMatrix(t, IAMOwnerCTX, sysAuthZ) + for _, stateCase := range relTableState { + t.Run(stateCase.Name, func(t *testing.T) { + loginCTX := stateCase.Inst.WithAuthorizationToken(t.Context(), integration.UserTypeLogin) + createResp, err := stateCase.Inst.Client.SessionV2.CreateSession(loginCTX, &session.CreateSessionRequest{}) + require.NoError(t, err) - _, err = Client.DeleteSession(CTX, &session.DeleteSessionRequest{ - SessionId: createResp.GetSessionId(), - SessionToken: gu.Ptr(createResp.GetSessionToken()), - }) - require.NoError(t, err) + //TODO: remove after create session also uses the relational tables directly + if stateCase.Enabled { + time.Sleep(5 * time.Second) + } + + _, err = stateCase.Inst.Client.SessionV2.DeleteSession(stateCase.InstOwner, &session.DeleteSessionRequest{ + SessionId: createResp.GetSessionId(), + SessionToken: gu.Ptr("invalid"), + }) + require.Error(t, err) + + _, err = stateCase.Inst.Client.SessionV2.DeleteSession(stateCase.InstOwner, &session.DeleteSessionRequest{ + SessionId: createResp.GetSessionId(), + SessionToken: gu.Ptr(createResp.GetSessionToken()), + }) + require.NoError(t, err) + }) + } } func TestServer_DeleteSession_own_session(t *testing.T) { - // create two users for the test and a session each to get tokens for authorization - user1 := Instance.CreateHumanUser(CTX) - Instance.SetUserPassword(CTX, user1.GetUserId(), integration.UserPassword, false) - _, token1, _, _ := Instance.CreatePasswordSession(t, LoginCTX, user1.GetUserId(), integration.UserPassword) + sysAuthZ := integration.WithSystemAuthorization(CTX) - user2 := Instance.CreateHumanUser(CTX) - Instance.SetUserPassword(CTX, user2.GetUserId(), integration.UserPassword, false) - _, token2, _, _ := Instance.CreatePasswordSession(t, LoginCTX, user2.GetUserId(), integration.UserPassword) + relTableState := integration.RelationalTablesEnableMatrix(t, IAMOwnerCTX, sysAuthZ) + for _, stateCase := range relTableState { + t.Run(stateCase.Name, func(t *testing.T) { + loginCTX := stateCase.Inst.WithAuthorizationToken(t.Context(), integration.UserTypeLogin) + // create two users for the test and a session each to get tokens for authorization + user1 := stateCase.Inst.CreateUserTypeHuman(stateCase.InstOwner, integration.Email()) + stateCase.Inst.SetUserPassword(stateCase.InstOwner, user1.GetId(), integration.UserPassword, false) + _, token1, _, _ := stateCase.Inst.CreatePasswordSession(t, loginCTX, user1.GetId(), integration.UserPassword) - // create a new session for the first user - createResp, err := Client.CreateSession(LoginCTX, &session.CreateSessionRequest{ - Checks: &session.Checks{ - User: &session.CheckUser{ - Search: &session.CheckUser_UserId{ - UserId: user1.GetUserId(), + user2 := stateCase.Inst.CreateUserTypeHuman(stateCase.InstOwner, integration.Email()) + stateCase.Inst.SetUserPassword(stateCase.InstOwner, user2.GetId(), integration.UserPassword, false) + _, token2, _, _ := stateCase.Inst.CreatePasswordSession(t, loginCTX, user2.GetId(), integration.UserPassword) + + // create a new session for the first user + createResp, err := stateCase.Inst.Client.SessionV2.CreateSession(loginCTX, &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: user1.GetId(), + }, + }, }, - }, - }, - }) - require.NoError(t, err) + }) + require.NoError(t, err) - // delete the new (user1) session must not be possible with user (has no permission) - _, err = Client.DeleteSession(integration.WithAuthorizationToken(context.Background(), token2), &session.DeleteSessionRequest{ - SessionId: createResp.GetSessionId(), - }) - require.Error(t, err) + //TODO: remove after create session also uses the relational tables directly + if stateCase.Enabled { + time.Sleep(5 * time.Second) + } - // delete the new (user1) session by themselves - _, err = Client.DeleteSession(integration.WithAuthorizationToken(context.Background(), token1), &session.DeleteSessionRequest{ - SessionId: createResp.GetSessionId(), - }) - require.NoError(t, err) + // delete the new (user1) session must not be possible with user (has no permission) + _, err = stateCase.Inst.Client.SessionV2.DeleteSession(integration.WithAuthorizationToken(t.Context(), token2), &session.DeleteSessionRequest{ + SessionId: createResp.GetSessionId(), + }) + require.Error(t, err) + + // delete the new (user1) session by themselves + _, err = stateCase.Inst.Client.SessionV2.DeleteSession(integration.WithAuthorizationToken(t.Context(), token1), &session.DeleteSessionRequest{ + SessionId: createResp.GetSessionId(), + }) + require.NoError(t, err) + }) + } } func TestServer_DeleteSession_with_permission(t *testing.T) { - createResp, err := Client.CreateSession(LoginCTX, &session.CreateSessionRequest{ - Checks: &session.Checks{ - User: &session.CheckUser{ - Search: &session.CheckUser_UserId{ - UserId: User.GetUserId(), - }, - }, - }, - }) - require.NoError(t, err) + sysAuthZ := integration.WithSystemAuthorization(CTX) - // delete the new session by ORG_OWNER - _, err = Client.DeleteSession(Instance.WithAuthorization(context.Background(), integration.UserTypeOrgOwner), &session.DeleteSessionRequest{ - SessionId: createResp.GetSessionId(), - }) - require.NoError(t, err) + relTableState := integration.RelationalTablesEnableMatrix(t, IAMOwnerCTX, sysAuthZ) + + for _, stateCase := range relTableState { + t.Run(stateCase.Name, func(t *testing.T) { + loginCTX := stateCase.Inst.WithAuthorizationToken(t.Context(), integration.UserTypeLogin) + user1 := stateCase.Inst.CreateUserTypeHuman(stateCase.InstOwner, integration.Email()) + createResp, err := stateCase.Inst.Client.SessionV2.CreateSession(loginCTX, &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: user1.GetId(), + }, + }, + }, + }) + require.NoError(t, err) + + if stateCase.Enabled { + t.Skip("TODO: remove after permission checks are implemented in the relational tables") + } + // delete the new session by ORG_OWNER + _, err = stateCase.Inst.Client.SessionV2.DeleteSession( + stateCase.Inst.WithAuthorizationToken(t.Context(), integration.UserTypeOrgOwner), + &session.DeleteSessionRequest{ + SessionId: createResp.GetSessionId(), + }, + ) + require.NoError(t, err) + }) + } } func TestServer_DeleteSession_expired(t *testing.T) { - createResp, err := Client.CreateSession(LoginCTX, &session.CreateSessionRequest{ - Lifetime: durationpb.New(5 * time.Second), + t.Cleanup(func() { + _, err := Instance.Client.FeatureV2.ResetInstanceFeatures(IAMOwnerCTX, &feature.ResetInstanceFeaturesRequest{}) + require.NoError(t, err) }) - require.NoError(t, err) + sysAuthZ := integration.WithSystemAuthorization(CTX) - // wait until the token expires - time.Sleep(10 * time.Second) - _, err = Client.DeleteSession(Instance.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner), &session.DeleteSessionRequest{ - SessionId: createResp.GetSessionId(), - SessionToken: gu.Ptr(createResp.GetSessionToken()), - }) - require.NoError(t, err) + relTableState := integration.RelationalTablesEnableMatrix(t, IAMOwnerCTX, sysAuthZ) - // get session should return an error - sessionResp, err := Client.GetSession(Instance.WithAuthorizationToken(context.Background(), integration.UserTypeOrgOwner), - &session.GetSessionRequest{SessionId: createResp.GetSessionId()}) - require.Error(t, err) - require.Nil(t, sessionResp) + for _, stateCase := range relTableState { + t.Run(stateCase.Name, func(t *testing.T) { + loginCTX := stateCase.Inst.WithAuthorizationToken(t.Context(), integration.UserTypeLogin) + createResp, err := stateCase.Inst.Client.SessionV2.CreateSession(loginCTX, &session.CreateSessionRequest{ + Lifetime: durationpb.New(5 * time.Second), + }) + require.NoError(t, err) + + // wait until the token expires + time.Sleep(10 * time.Second) + _, err = stateCase.Inst.Client.SessionV2.DeleteSession(stateCase.Inst.WithAuthorizationToken(t.Context(), integration.UserTypeOrgOwner), &session.DeleteSessionRequest{ + SessionId: createResp.GetSessionId(), + SessionToken: gu.Ptr(createResp.GetSessionToken()), + }) + require.NoError(t, err) + + // get session should return an error + sessionResp, err := stateCase.Inst.Client.SessionV2.GetSession(stateCase.Inst.WithAuthorizationToken(t.Context(), integration.UserTypeOrgOwner), + &session.GetSessionRequest{SessionId: createResp.GetSessionId()}) + require.Error(t, err) + require.Nil(t, sessionResp) + }) + } } func Test_ZITADEL_API_missing_authentication(t *testing.T) { diff --git a/internal/api/grpc/session/v2/session.go b/internal/api/grpc/session/v2/session.go index 742693108..abd0478aa 100644 --- a/internal/api/grpc/session/v2/session.go +++ b/internal/api/grpc/session/v2/session.go @@ -10,6 +10,8 @@ import ( "golang.org/x/text/language" "google.golang.org/protobuf/types/known/structpb" + sessionv2 "github.com/zitadel/zitadel/backend/v3/api/session/v2" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/object/v2" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/domain" @@ -63,6 +65,10 @@ func (s *Server) SetSession(ctx context.Context, req *connect.Request[session.Se } func (s *Server) DeleteSession(ctx context.Context, req *connect.Request[session.DeleteSessionRequest]) (*connect.Response[session.DeleteSessionResponse], error) { + if authz.GetFeatures(ctx).EnableRelationalTables { + return sessionv2.DeleteSession(ctx, req) + } + details, err := s.command.TerminateSession(ctx, req.Msg.GetSessionId(), req.Msg.GetSessionToken()) if err != nil { return nil, err diff --git a/internal/api/oidc/auth_request.go b/internal/api/oidc/auth_request.go index ced95a427..6ea81b260 100644 --- a/internal/api/oidc/auth_request.go +++ b/internal/api/oidc/auth_request.go @@ -16,6 +16,8 @@ import ( "github.com/zitadel/oidc/v3/pkg/oidc" "github.com/zitadel/oidc/v3/pkg/op" + zdomain "github.com/zitadel/zitadel/backend/v3/domain" + "github.com/zitadel/zitadel/backend/v3/storage/database/repository" "github.com/zitadel/zitadel/internal/api/authz" http_utils "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/api/http/middleware" @@ -350,6 +352,16 @@ func (o *OPStorage) TerminateSessionFromRequest(ctx context.Context, endSessionR // V2: // Terminate the v2 session of the id_token_hint + if authz.GetFeatures(ctx).EnableRelationalTables { + if err := zdomain.Invoke( + ctx, + zdomain.NewDeleteSessionCommand(endSessionRequest.IDTokenHintClaims.SessionID, "", false), + zdomain.WithSessionRepo(repository.SessionRepository()), + ); err != nil { + return "", err + } + return v2PostLogoutRedirectURI(endSessionRequest.RedirectURI), nil + } _, err = o.command.TerminateSessionWithoutTokenCheck(ctx, endSessionRequest.IDTokenHintClaims.SessionID) if err != nil { return "", err diff --git a/internal/query/projection/session_relational.go b/internal/query/projection/session_relational.go index b20acbaee..7394fbd5a 100644 --- a/internal/query/projection/session_relational.go +++ b/internal/query/projection/session_relational.go @@ -428,7 +428,7 @@ func (p *relationalTablesProjection) reduceSessionTerminated(event eventstore.Ev sessionRepo := repository.SessionRepository() condition := sessionRepo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.Aggregate().ID) - _, err := sessionRepo.Delete(ctx, v3Tx, condition) + _, _, err := sessionRepo.Delete(ctx, v3Tx, condition, nil) return err }), nil } diff --git a/internal/query/projection/user_relational.go b/internal/query/projection/user_relational.go index c37995f30..14b25ba18 100644 --- a/internal/query/projection/user_relational.go +++ b/internal/query/projection/user_relational.go @@ -160,7 +160,7 @@ func (p *relationalTablesProjection) reduceUserLocked(event eventstore.Event) (* sessionRepo.InstanceIDCondition(event.Aggregate().InstanceID), sessionRepo.UserIDCondition(event.Aggregate().ID), ) - _, err := sessionRepo.Delete(ctx, v3Tx, condition) + _, _, err := sessionRepo.Delete(ctx, v3Tx, condition, nil) return err }), ), nil @@ -223,7 +223,7 @@ func (p *relationalTablesProjection) reduceUserDeactivated(event eventstore.Even sessionRepo.InstanceIDCondition(event.Aggregate().InstanceID), sessionRepo.UserIDCondition(event.Aggregate().ID), ) - _, err := sessionRepo.Delete(ctx, v3Tx, condition) + _, _, err := sessionRepo.Delete(ctx, v3Tx, condition, nil) return err }), ), nil