Files
zitadel/backend/v3/domain/session_list.go
T
Marco A.andGitHub 14acc60edd feat: ListSessions to relational (#11987)
# Which Problems Are Solved

This PR introduces the possibility of using the relational DB when
calling the ListSession gRPC endpoint.

# How the Problems Are Solved

  - Implement the Querier interface for ListSession:
- Create a `domain` model for the Session object to be able to map the
gRPC session into it.
- Match the permission filters on ES side to be sure the same sessions
are returned
- Implement converter methods to go from `gRPC` to `domain` models (both
ways)
- Fix the session sql relational migration: rework the trigger to update
the `expiration` field to perform the update only when the input
`lifetime` has a non-zero value.
  - Link the Querier implementation with the gRPC endpoint
- Run the same integration tests against the relational DB, making sure
the outcome is the same. ⚠️ Due to missing permission checks, some
divergence is expected. Hence, specific code to circumvent the
differences has been put in place

# Additional Context

- Closes #11039
2026-04-16 10:58:17 +02:00

186 lines
5.6 KiB
Go

package domain
import (
"context"
"time"
"github.com/zitadel/zitadel/backend/v3/storage/database"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/zerrors"
)
type SessionFilter interface {
sessionFilter()
}
// SessionIDsFilter filters sessions by one or more IDs.
type SessionIDsFilter struct{ IDs []string }
func (SessionIDsFilter) sessionFilter() {}
// SessionUserIDFilter filters sessions by the associated user's ID.
type SessionUserIDFilter struct{ UserID string }
func (SessionUserIDFilter) sessionFilter() {}
// SessionCreationDateFilter filters sessions by creation date.
type SessionCreationDateFilter struct {
Op database.NumberOperation
Date time.Time
}
func (SessionCreationDateFilter) sessionFilter() {}
// SessionCreatorFilter filters sessions by the creator's ID.
type SessionCreatorFilter struct{ ID string }
func (SessionCreatorFilter) sessionFilter() {}
// SessionUserAgentFilter filters sessions by user-agent fingerprint.
type SessionUserAgentFilter struct{ FingerprintID string }
func (SessionUserAgentFilter) sessionFilter() {}
// SessionExpirationDateFilter filters sessions by expiration date.
type SessionExpirationDateFilter struct {
Op database.NumberOperation
Date time.Time
}
func (SessionExpirationDateFilter) sessionFilter() {}
// SessionSortColumn determines the column used to sort the result set.
type SessionSortColumn int
const (
SessionSortColumnUnspecified SessionSortColumn = iota
SessionSortColumnCreationDate
)
// ListSessionsRequest holds all parameters for a ListSessions query.
type ListSessionsRequest struct {
Filters []SessionFilter
SortColumn SessionSortColumn
Ascending bool
Limit uint32
Offset uint32
}
// ListSessionsQuery implements [Querier] for listing sessions.
type ListSessionsQuery struct {
Request *ListSessionsRequest
sessions []*Session
}
// Result implements [Querier].
func (l *ListSessionsQuery) Result() []*Session { return l.sessions }
var _ Querier[[]*Session] = (*ListSessionsQuery)(nil)
// NewListSessionsQuery returns a new [ListSessionsQuery] for the given request.
func NewListSessionsQuery(req *ListSessionsRequest) *ListSessionsQuery {
return &ListSessionsQuery{Request: req}
}
// String implements [Querier].
func (*ListSessionsQuery) String() string { return "ListSessionsQuery" }
// Validate implements [Querier].
func (l *ListSessionsQuery) Validate(_ context.Context, _ *InvokeOpts) error {
return nil
}
// Execute implements [Querier].
func (l *ListSessionsQuery) Execute(ctx context.Context, opts *InvokeOpts) error {
sessionRepo := opts.sessionRepo.LoadUserData()
conds, err := l.Conditions(ctx, sessionRepo)
if err != nil {
return err
}
l.sessions, err = sessionRepo.List(ctx, opts.DB(),
database.WithCondition(conds),
l.Sorting(sessionRepo),
database.WithLimit(l.Request.Limit),
database.WithOffset(l.Request.Offset),
)
if err != nil {
return zerrors.ThrowInternal(err, "DOM-Yx8q2r", "Errors.Session.List")
}
return nil
}
func (l *ListSessionsQuery) permissionCondition(ctx context.Context, sessionRepo SessionRepository) database.Condition {
ctxData := authz.GetCtxData(ctx)
return database.Or(
sessionRepo.UserIDCondition(ctxData.UserID),
sessionRepo.UserAgentIDCondition(ctxData.AgentID),
sessionRepo.CreatorIDCondition(ctxData.UserID),
database.PermissionCheck(SessionReadPermission, false),
)
}
func (l *ListSessionsQuery) Conditions(ctx context.Context, sessionRepo SessionRepository) (database.Condition, error) {
conds := make([]database.Condition, 2, 2+len(l.Request.Filters))
conds[0] = sessionRepo.InstanceIDCondition(authz.GetInstance(ctx).InstanceID())
conds[1] = l.permissionCondition(ctx, sessionRepo)
for _, f := range l.Request.Filters {
cond, err := l.sessionFilterToCondition(sessionRepo, f)
if err != nil {
return nil, err
}
conds = append(conds, cond)
}
return database.And(conds...), nil
}
func (l *ListSessionsQuery) sessionFilterToCondition(repo SessionRepository, filter SessionFilter) (database.Condition, error) {
switch typedFilter := filter.(type) {
case SessionIDsFilter:
idConds := make([]database.Condition, len(typedFilter.IDs))
for i, id := range typedFilter.IDs {
idConds[i] = repo.IDCondition(id)
}
return database.Or(idConds...), nil
case SessionUserIDFilter:
return repo.UserIDCondition(typedFilter.UserID), nil
case SessionCreationDateFilter:
return repo.CreatedAtCondition(typedFilter.Op, typedFilter.Date), nil
case SessionCreatorFilter:
return repo.CreatorIDCondition(typedFilter.ID), nil
case SessionUserAgentFilter:
return repo.UserAgentIDCondition(typedFilter.FingerprintID), nil
case SessionExpirationDateFilter:
// Unlike ES side, we are also including NOT EQUAL. This is an impossible case though since gRPC has no value for it
expCond := repo.ExpirationCondition(typedFilter.Op, typedFilter.Date)
if typedFilter.Op == database.NumberOperationGreaterThan || typedFilter.Op == database.NumberOperationGreaterThanOrEqual {
// Include also nil expirations (i.e. non-expiring sessions)
return database.Or(expCond, database.IsNull(repo.ExpirationColumn())), nil
}
return expCond, nil
default:
return nil, zerrors.ThrowInvalidArgument(NewUnexpectedQueryTypeError(typedFilter), "DOM-Cz5s3t", "List.Query.Invalid")
}
}
// Sorting returns the QueryOption for ordering results.
func (l *ListSessionsQuery) Sorting(sessionRepo SessionRepository) database.QueryOption {
if l.Request.SortColumn != SessionSortColumnCreationDate {
return func(*database.QueryOpts) {}
}
direction := database.OrderDirectionDesc
if l.Request.Ascending {
direction = database.OrderDirectionAsc
}
return database.WithOrderBy(direction, sessionRepo.CreatedAtColumn())
}