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
This commit is contained in:
Gayathri Vijayan
2026-02-20 10:21:01 +00:00
committed by GitHub
parent 545e2665f3
commit 1494f91f80
4 changed files with 142 additions and 0 deletions
@@ -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) {
+29
View File
@@ -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
}
@@ -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
}
+78
View File
@@ -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) {