feat: add delete session on relation tables (#11334)

# Which Problems Are Solved

As part of moving the session API to the relational database, this PR
adds the functionality to delete sessions through the session API v2 and
the OIDC end_session endpoint.

# How the Problems Are Solved

- added `SessionDeleteCommand` in domain package
- added possibility to pass the session token verifier to the domain
package
- use feature flag to switch API and OIDC usage from CQRS to relation
table
- added a `permissionCheck` condition to let permissions be checked
directly in the repository / sql
- part of it is still a placeholder until actual permission tables are
done
- added a `session_deleted` table to store (recently) deleted sessions
to handle necessary checks / cases like delete my own session, which
needs to be idempotent

# Additional Changes

Fixed `Matches` function of `existsCondition` (as missing an
implementation)

# Additional Context

- closes #11037

---------

Co-authored-by: Stefan Benz <[email protected]>
This commit is contained in:
Livio Spring
2026-03-19 08:35:53 +01:00
committed by GitHub
co-authored by Stefan Benz
parent ae32508225
commit ca4bba3992
26 changed files with 1221 additions and 222 deletions
+34
View File
@@ -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
}
+5 -2
View File
@@ -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
}
+14 -3
View File
@@ -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,
}
}
+141 -103
View File
@@ -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}
}
+3 -2
View File
@@ -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)
}
+1
View File
@@ -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.
+10 -1
View File
@@ -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.
+127
View File
@@ -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)
)
+347
View File
@@ -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
}
}
}
+7
View File
@@ -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)
+51 -2
View File
@@ -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)
@@ -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)
}
@@ -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)
}
@@ -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();
@@ -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;
+23
View File
@@ -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
}
+6
View File
@@ -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]
@@ -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)
@@ -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)
})
}
}
+2
View File
@@ -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)
+22
View File
@@ -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
}
}
@@ -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) {
+6
View File
@@ -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
+12
View File
@@ -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
@@ -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
}
+2 -2
View File
@@ -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