From 1494f91f80a2905d83d85e15eed3e1789ca6ac10 Mon Sep 17 00:00:00 2001 From: Gayathri Vijayan <66356931+grvijayan@users.noreply.github.com> Date: Fri, 20 Feb 2026 11:21:01 +0100 Subject: [PATCH] fix: session termination after user deletion/deactivation (#11644) # Which Problems Are Solved After a user is deactivated, their sessions are still valid. When the user is redirected to login page, they're stuck in a loop on a blank page on the `users/me` endpoint as the user's session is still valid. # How the Problems Are Solved By terminating a deactivated user's session. This is done by: - Adding a new reducer in session projection to delete sessions for `UserDeactivatedEvent`, `UserLockedEvent` and `UserRemovedEvent` events - Adding a new reducer in the relation session projection to delete sessions based on `UserDeactivatedEvent`, `UserLockedEvent` events # Additional Changes N/A # Additional Context - Closes https://github.com/zitadel/zitadel/issues/10307 - todo: add integration test cases for relational session reducers based on user deactivated, locked, deleted events --- .../database/events_testing/session_test.go | 2 + internal/query/projection/session.go | 29 +++++++ .../query/projection/session_relational.go | 33 ++++++++ internal/query/projection/session_test.go | 78 +++++++++++++++++++ 4 files changed, 142 insertions(+) diff --git a/backend/v3/storage/database/events_testing/session_test.go b/backend/v3/storage/database/events_testing/session_test.go index 88de27a4c..064ff52f8 100644 --- a/backend/v3/storage/database/events_testing/session_test.go +++ b/backend/v3/storage/database/events_testing/session_test.go @@ -473,6 +473,8 @@ func TestServer_SessionReduces(t *testing.T) { assert.Nil(collect, dbSession) }, retryDuration, tick, "session not found within %v: %v", retryDuration, err) }) + + // TODO (@grvijayan): add tests for session reducers based on user deactivated, locked, deleted events } func assertSessionsEqual(t *assert.CollectT, expected, actual *domain.Session) { diff --git a/internal/query/projection/session.go b/internal/query/projection/session.go index bb604c566..bf6f2cbda 100644 --- a/internal/query/projection/session.go +++ b/internal/query/projection/session.go @@ -169,6 +169,18 @@ func (p *sessionProjection) Reducers() []handler.AggregateReducer { Event: user.HumanPasswordChangedType, Reduce: p.reducePasswordChanged, }, + { + Event: user.UserDeactivatedType, + Reduce: p.reduceUserStateNotActive, + }, + { + Event: user.UserLockedType, + Reduce: p.reduceUserStateNotActive, + }, + { + Event: user.UserRemovedType, + Reduce: p.reduceUserStateNotActive, + }, }, }, } @@ -465,3 +477,20 @@ func (p *sessionProjection) reducePasswordChanged(event eventstore.Event) (*hand }, ), nil } + +func (p *sessionProjection) reduceUserStateNotActive(event eventstore.Event) (_ *handler.Statement, err error) { + switch t := event.(type) { + case *user.UserDeactivatedEvent, *user.UserRemovedEvent, *user.UserLockedEvent: + // ok + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-7zALpR", "reduce.wrong.event.type %v", t) + } + + return handler.NewDeleteStatement( + event, + []handler.Condition{ + handler.NewCond(SessionColumnUserID, event.Aggregate().ID), + handler.NewCond(SessionColumnInstanceID, event.Aggregate().InstanceID), + }, + ), nil +} diff --git a/internal/query/projection/session_relational.go b/internal/query/projection/session_relational.go index f22891fff..400163430 100644 --- a/internal/query/projection/session_relational.go +++ b/internal/query/projection/session_relational.go @@ -117,6 +117,14 @@ func (p *sessionRelationalProjection) Reducers() []handler.AggregateReducer { Event: user.HumanPasswordChangedType, Reduce: p.reducePasswordChanged, }, + { + Event: user.UserDeactivatedType, + Reduce: p.reduceUserStateNotActive, + }, + { + Event: user.UserLockedType, + Reduce: p.reduceUserStateNotActive, + }, }, }, } @@ -573,3 +581,28 @@ func (p *sessionRelationalProjection) reducePasswordChanged(event eventstore.Eve return err }), nil } + +func (p *sessionRelationalProjection) reduceUserStateNotActive(event eventstore.Event) (*handler.Statement, error) { + switch t := event.(type) { + case *user.UserDeactivatedEvent, *user.UserLockedEvent: + // ok + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-XBglbF", "reduce.wrong.event.type %v", t) + } + + return handler.NewStatement(event, func(ctx context.Context, ex handler.Executer, projectionName string) error { + tx, ok := ex.(*sql.Tx) + if !ok { + return zerrors.ThrowInvalidArgumentf(nil, "HANDL-fcxV68", "reduce.wrong.db.pool %T", ex) + } + v3Tx := v3_sql.SQLTx(tx) + + sessionRepo := repository.SessionRepository() + condition := database.And( + sessionRepo.InstanceIDCondition(event.Aggregate().InstanceID), + sessionRepo.UserIDCondition(event.Aggregate().ID), + ) + _, err := sessionRepo.Delete(ctx, v3Tx, condition) + return err + }), nil +} diff --git a/internal/query/projection/session_test.go b/internal/query/projection/session_test.go index ecd9ca5f1..0575ea67b 100644 --- a/internal/query/projection/session_test.go +++ b/internal/query/projection/session_test.go @@ -414,6 +414,84 @@ func TestSessionProjection_reduces(t *testing.T) { }, }, }, + { + name: "reduce UserDeactivated", + args: args{ + event: getEvent(testEvent( + user.UserDeactivatedType, + user.AggregateType, + []byte(`{}`), + ), user.UserDeactivatedEventMapper), + }, + reduce: (&sessionProjection{}).reduceUserStateNotActive, + want: wantReduce{ + aggregateType: eventstore.AggregateType("user"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.sessions8 WHERE (user_id = $1) AND (instance_id = $2)", + expectedArgs: []any{ + "agg-id", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "reduce UserRemoved", + args: args{ + event: getEvent(testEvent( + user.UserRemovedType, + user.AggregateType, + []byte(`{}`), + ), user.UserRemovedEventMapper), + }, + reduce: (&sessionProjection{}).reduceUserStateNotActive, + want: wantReduce{ + aggregateType: eventstore.AggregateType("user"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.sessions8 WHERE (user_id = $1) AND (instance_id = $2)", + expectedArgs: []any{ + "agg-id", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "reduce UserLocked", + args: args{ + event: getEvent(testEvent( + user.UserLockedType, + user.AggregateType, + []byte(`{}`), + ), user.UserLockedEventMapper), + }, + reduce: (&sessionProjection{}).reduceUserStateNotActive, + want: wantReduce{ + aggregateType: eventstore.AggregateType("user"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.sessions8 WHERE (user_id = $1) AND (instance_id = $2)", + expectedArgs: []any{ + "agg-id", + "instance-id", + }, + }, + }, + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) {