feat(api): new session service (#5801)

* backup new protoc plugin

* backup

* session

* backup

* initial implementation

* change to specific events

* implement tests

* cleanup

* refactor: use new protoc plugin for api v2

* change package

* simplify code

* cleanup

* cleanup

* fix merge

* start queries

* fix tests

* improve returned values

* add token to projection

* tests

* test db map

* update query

* permission checks

* fix tests and linting

* rework token creation

* i18n

* refactor token check and fix tests

* session to PB test

* request to query tests

* cleanup proto

* test user check

* add comment

* simplify database map type

* Update docs/docs/guides/integrate/access-zitadel-system-api.md

Co-authored-by: Tim Möhlmann <tim+github@zitadel.com>

* fix test

* cleanup

* docs

---------

Co-authored-by: Tim Möhlmann <tim+github@zitadel.com>
This commit is contained in:
Livio Spring 2023-05-05 17:34:53 +02:00 committed by GitHub
parent 74377c2c37
commit c2cb84cd24
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 3911 additions and 106 deletions

View File

@ -77,6 +77,7 @@ func (mig *FirstInstance) Execute(ctx context.Context) error {
nil, nil,
nil, nil,
nil, nil,
nil,
) )
if err != nil { if err != nil {

View File

@ -52,6 +52,7 @@ func (mig *externalConfigChange) Execute(ctx context.Context) error {
nil, nil,
nil, nil,
nil, nil,
nil,
) )
if err != nil { if err != nil {

View File

@ -50,6 +50,7 @@ import (
"github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/crypto"
cryptoDB "github.com/zitadel/zitadel/internal/crypto/database" cryptoDB "github.com/zitadel/zitadel/internal/crypto/database"
"github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/id" "github.com/zitadel/zitadel/internal/id"
"github.com/zitadel/zitadel/internal/logstore" "github.com/zitadel/zitadel/internal/logstore"
@ -129,7 +130,21 @@ func startZitadel(config *Config, masterKey string, server chan<- *Server) error
return fmt.Errorf("cannot start eventstore for queries: %w", err) return fmt.Errorf("cannot start eventstore for queries: %w", err)
} }
queries, err := query.StartQueries(ctx, eventstoreClient, dbClient, config.Projections, config.SystemDefaults, keys.IDPConfig, keys.OTP, keys.OIDC, keys.SAML, config.InternalAuthZ.RolePermissionMappings) sessionTokenVerifier := internal_authz.SessionTokenVerifier(keys.OIDC)
queries, err := query.StartQueries(
ctx,
eventstoreClient,
dbClient,
config.Projections,
config.SystemDefaults,
keys.IDPConfig,
keys.OTP,
keys.OIDC,
keys.SAML,
config.InternalAuthZ.RolePermissionMappings,
sessionTokenVerifier,
)
if err != nil { if err != nil {
return fmt.Errorf("cannot start queries: %w", err) return fmt.Errorf("cannot start queries: %w", err)
} }
@ -138,6 +153,9 @@ func startZitadel(config *Config, masterKey string, server chan<- *Server) error
if err != nil { if err != nil {
return fmt.Errorf("error starting authz repo: %w", err) return fmt.Errorf("error starting authz repo: %w", err)
} }
permissionCheck := func(ctx context.Context, permission, orgID, resourceID string) (err error) {
return internal_authz.CheckPermission(ctx, authZRepo, config.InternalAuthZ.RolePermissionMappings, permission, orgID, resourceID)
}
storage, err := config.AssetStorage.NewStorage(dbClient.DB) storage, err := config.AssetStorage.NewStorage(dbClient.DB)
if err != nil { if err != nil {
@ -165,7 +183,8 @@ func startZitadel(config *Config, masterKey string, server chan<- *Server) error
keys.OIDC, keys.OIDC,
keys.SAML, keys.SAML,
&http.Client{}, &http.Client{},
authZRepo, permissionCheck,
sessionTokenVerifier,
) )
if err != nil { if err != nil {
return fmt.Errorf("cannot start commands: %w", err) return fmt.Errorf("cannot start commands: %w", err)
@ -195,7 +214,22 @@ func startZitadel(config *Config, masterKey string, server chan<- *Server) error
if err != nil { if err != nil {
return err return err
} }
err = startAPIs(ctx, clock, router, commands, queries, eventstoreClient, dbClient, config, storage, authZRepo, keys, queries, usageReporter) err = startAPIs(
ctx,
clock,
router,
commands,
queries,
eventstoreClient,
dbClient,
config,
storage,
authZRepo,
keys,
queries,
usageReporter,
permissionCheck,
)
if err != nil { if err != nil {
return err return err
} }
@ -239,6 +273,7 @@ func startAPIs(
keys *encryptionKeys, keys *encryptionKeys,
quotaQuerier logstore.QuotaQuerier, quotaQuerier logstore.QuotaQuerier,
usageReporter logstore.UsageReporter, usageReporter logstore.UsageReporter,
permissionCheck domain.PermissionCheck,
) error { ) error {
repo := struct { repo := struct {
authz_repo.Repository authz_repo.Repository
@ -294,7 +329,7 @@ func startAPIs(
if err := apis.RegisterService(ctx, user.CreateServer(commands, queries, keys.User)); err != nil { if err := apis.RegisterService(ctx, user.CreateServer(commands, queries, keys.User)); err != nil {
return err return err
} }
if err := apis.RegisterService(ctx, session.CreateServer(commands, queries)); err != nil { if err := apis.RegisterService(ctx, session.CreateServer(commands, queries, permissionCheck)); err != nil {
return err return err
} }
instanceInterceptor := middleware.InstanceInterceptor(queries, config.HTTP1HostHeader, login.IgnoreInstanceEndpoints...) instanceInterceptor := middleware.InstanceInterceptor(queries, config.HTTP1HostHeader, login.IgnoreInstanceEndpoints...)

View File

@ -86,7 +86,7 @@ If your system is exposed without TLS or on a dedicated port, be sure to provide
If you want to manually create a JWT for a test, you can also use our [ZITADEL Tools](https://github.com/zitadel/zitadel-tools). Download the latest release and run: If you want to manually create a JWT for a test, you can also use our [ZITADEL Tools](https://github.com/zitadel/zitadel-tools). Download the latest release and run:
```bash ```bash
./key2jwt -audience=https://custom-domain.com -key=system-user-1.pem -issuer=system-user-1 zitadel-tools key2jwt --audience=https://custom-domain.com --key=system-user-1.pem --issuer=system-user-1
``` ```
## Call the System API ## Call the System API

View File

@ -266,6 +266,13 @@ module.exports = {
sidebarOptions: { sidebarOptions: {
groupPathsBy: "tag", groupPathsBy: "tag",
}, },
},
session: {
specPath: ".artifacts/openapi/zitadel/session/v2alpha/session_service.swagger.json",
outputDir: "docs/apis/session_service",
sidebarOptions: {
groupPathsBy: "tag",
},
} }
} }
}, },

View File

@ -412,6 +412,20 @@ module.exports = {
}, },
items: require("./docs/apis/user_service/sidebar.js"), items: require("./docs/apis/user_service/sidebar.js"),
}, },
{
type: "category",
label: "Session Lifecycle (Alpha)",
link: {
type: "generated-index",
title: "Session Service API (Alpha)",
slug: "/apis/session_service",
description:
"This API is intended to manage sessions in a ZITADEL instance.\n"+
"\n"+
"This project is in alpha state. It can AND will continue breaking until the services provide the same functionality as the current login.",
},
items: require("./docs/apis/session_service/sidebar.js"),
},
{ {
type: "category", type: "category",
label: "Assets", label: "Assets",

2
go.mod
View File

@ -192,7 +192,7 @@ require (
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.14.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.14.0 // indirect
go.opentelemetry.io/proto/otlp v0.19.0 // indirect go.opentelemetry.io/proto/otlp v0.19.0 // indirect
golang.org/x/mod v0.10.0 // indirect golang.org/x/mod v0.10.0 // indirect
golang.org/x/sys v0.7.0 // indirect golang.org/x/sys v0.7.0
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/appengine v1.6.7 // indirect google.golang.org/appengine v1.6.7 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect

View File

@ -14,11 +14,16 @@ const (
authenticated = "authenticated" authenticated = "authenticated"
) )
func CheckUserAuthorization(ctx context.Context, req interface{}, token, orgIDHeader string, verifier *TokenVerifier, authConfig Config, requiredAuthOption Option, method string) (ctxSetter func(context.Context) context.Context, err error) { // CheckUserAuthorization verifies that:
// - the token is active,
// - the organisation (**either** provided by ID or verified domain) exists
// - the user is permitted to call the requested endpoint (permission option in proto)
// it will pass the [CtxData] and permission of the user into the ctx [context.Context]
func CheckUserAuthorization(ctx context.Context, req interface{}, token, orgID, orgDomain string, verifier *TokenVerifier, authConfig Config, requiredAuthOption Option, method string) (ctxSetter func(context.Context) context.Context, err error) {
ctx, span := tracing.NewServerInterceptorSpan(ctx) ctx, span := tracing.NewServerInterceptorSpan(ctx)
defer func() { span.EndWithError(err) }() defer func() { span.EndWithError(err) }()
ctxData, err := VerifyTokenAndCreateCtxData(ctx, token, orgIDHeader, verifier, method) ctxData, err := VerifyTokenAndCreateCtxData(ctx, token, orgID, orgDomain, verifier, method)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -60,7 +60,7 @@ const (
MemberTypeIam MemberTypeIam
) )
func VerifyTokenAndCreateCtxData(ctx context.Context, token, orgID string, t *TokenVerifier, method string) (_ CtxData, err error) { func VerifyTokenAndCreateCtxData(ctx context.Context, token, orgID, orgDomain string, t *TokenVerifier, method string) (_ CtxData, err error) {
ctx, span := tracing.NewSpan(ctx) ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }() defer func() { span.EndWithError(err) }()
@ -82,14 +82,15 @@ func VerifyTokenAndCreateCtxData(ctx context.Context, token, orgID string, t *To
if err := checkOrigin(ctx, origins); err != nil { if err := checkOrigin(ctx, origins); err != nil {
return CtxData{}, err return CtxData{}, err
} }
if orgID == "" { if orgID == "" && orgDomain == "" {
orgID = resourceOwner orgID = resourceOwner
} }
err = t.ExistsOrg(ctx, orgID) verifiedOrgID, err := t.ExistsOrg(ctx, orgID, orgDomain)
if err != nil { if err != nil {
err = retry(func() error { err = retry(func() error {
return t.ExistsOrg(ctx, orgID) verifiedOrgID, err = t.ExistsOrg(ctx, orgID, orgDomain)
return err
}) })
if err != nil { if err != nil {
return CtxData{}, errors.ThrowPermissionDenied(nil, "AUTH-Bs7Ds", "Organisation doesn't exist") return CtxData{}, errors.ThrowPermissionDenied(nil, "AUTH-Bs7Ds", "Organisation doesn't exist")
@ -98,7 +99,7 @@ func VerifyTokenAndCreateCtxData(ctx context.Context, token, orgID string, t *To
return CtxData{ return CtxData{
UserID: userID, UserID: userID,
OrgID: orgID, OrgID: verifiedOrgID,
ProjectID: projectID, ProjectID: projectID,
AgentID: agentID, AgentID: agentID,
PreferredLanguage: prefLang, PreferredLanguage: prefLang,

View File

@ -7,12 +7,8 @@ import (
"github.com/zitadel/zitadel/internal/telemetry/tracing" "github.com/zitadel/zitadel/internal/telemetry/tracing"
) )
func CheckPermission(ctx context.Context, resolver MembershipsResolver, roleMappings []RoleMapping, permission, orgID, resourceID string, allowSelf bool) (err error) { func CheckPermission(ctx context.Context, resolver MembershipsResolver, roleMappings []RoleMapping, permission, orgID, resourceID string) (err error) {
ctxData := GetCtxData(ctx) requestedPermissions, _, err := getUserPermissions(ctx, resolver, permission, roleMappings, GetCtxData(ctx), orgID)
if allowSelf && ctxData.UserID == resourceID {
return nil
}
requestedPermissions, _, err := getUserPermissions(ctx, resolver, permission, roleMappings, ctxData, orgID)
if err != nil { if err != nil {
return err return err
} }

View File

@ -26,8 +26,8 @@ func (v *testVerifier) ProjectIDAndOriginsByClientID(ctx context.Context, client
return "", nil, nil return "", nil, nil
} }
func (v *testVerifier) ExistsOrg(ctx context.Context, orgID string) error { func (v *testVerifier) ExistsOrg(ctx context.Context, orgID, domain string) (string, error) {
return nil return orgID, nil
} }
func (v *testVerifier) VerifierClientID(ctx context.Context, appName string) (string, string, error) { func (v *testVerifier) VerifierClientID(ctx context.Context, appName string) (string, string, error) {

View File

@ -3,6 +3,8 @@ package authz
import ( import (
"context" "context"
"crypto/rsa" "crypto/rsa"
"encoding/base64"
"fmt"
"os" "os"
"strings" "strings"
"sync" "sync"
@ -17,7 +19,8 @@ import (
) )
const ( const (
BearerPrefix = "Bearer " BearerPrefix = "Bearer "
SessionTokenFormat = "sess_%s:%s"
) )
type TokenVerifier struct { type TokenVerifier struct {
@ -36,7 +39,7 @@ type authZRepo interface {
VerifierClientID(ctx context.Context, name string) (clientID, projectID string, err error) VerifierClientID(ctx context.Context, name string) (clientID, projectID string, err error)
SearchMyMemberships(ctx context.Context, orgID string) ([]*Membership, error) SearchMyMemberships(ctx context.Context, orgID string) ([]*Membership, error)
ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (projectID string, origins []string, err error) ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (projectID string, origins []string, err error)
ExistsOrg(ctx context.Context, orgID string) error ExistsOrg(ctx context.Context, id, domain string) (string, error)
} }
func Start(authZRepo authZRepo, issuer string, keys map[string]*SystemAPIUser) (v *TokenVerifier) { func Start(authZRepo authZRepo, issuer string, keys map[string]*SystemAPIUser) (v *TokenVerifier) {
@ -144,10 +147,10 @@ func (v *TokenVerifier) ProjectIDAndOriginsByClientID(ctx context.Context, clien
return v.authZRepo.ProjectIDAndOriginsByClientID(ctx, clientID) return v.authZRepo.ProjectIDAndOriginsByClientID(ctx, clientID)
} }
func (v *TokenVerifier) ExistsOrg(ctx context.Context, orgID string) (err error) { func (v *TokenVerifier) ExistsOrg(ctx context.Context, id, domain string) (orgID string, err error) {
ctx, span := tracing.NewSpan(ctx) ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }() defer func() { span.EndWithError(err) }()
return v.authZRepo.ExistsOrg(ctx, orgID) return v.authZRepo.ExistsOrg(ctx, id, domain)
} }
func (v *TokenVerifier) CheckAuthMethod(method string) (Option, bool) { func (v *TokenVerifier) CheckAuthMethod(method string) (Option, bool) {
@ -165,3 +168,20 @@ func verifyAccessToken(ctx context.Context, token string, t *TokenVerifier, meth
} }
return t.VerifyAccessToken(ctx, parts[1], method) return t.VerifyAccessToken(ctx, parts[1], method)
} }
func SessionTokenVerifier(algorithm crypto.EncryptionAlgorithm) func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) {
return func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) {
decodedToken, err := base64.RawURLEncoding.DecodeString(sessionToken)
if err != nil {
return err
}
_, spanPasswordComparison := tracing.NewNamedSpan(ctx, "crypto.CompareHash")
var token string
token, err = algorithm.DecryptString(decodedToken, algorithm.EncryptionKeyID())
spanPasswordComparison.EndWithError(err)
if err != nil || token != fmt.Sprintf(SessionTokenFormat, sessionID, tokenID) {
return caos_errs.ThrowPermissionDenied(err, "COMMAND-sGr42", "Errors.Session.Token.Invalid")
}
return nil
}
}

View File

@ -4,6 +4,7 @@ import (
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha" object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha"
) )
@ -17,3 +18,21 @@ func DomainToDetailsPb(objectDetail *domain.ObjectDetails) *object.Details {
} }
return details return details
} }
func ToListDetails(response query.SearchResponse) *object.ListDetails {
details := &object.ListDetails{
TotalResult: response.Count,
ProcessedSequence: response.Sequence,
}
if !response.Timestamp.IsZero() {
details.Timestamp = timestamppb.New(response.Timestamp)
}
return details
}
func ListQueryToQuery(query *object.ListQuery) (offset, limit uint64, asc bool) {
if query == nil {
return 0, 0, false
}
return query.Offset, uint64(query.Limit), query.Asc
}

View File

@ -34,12 +34,14 @@ func authorize(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo,
return nil, status.Error(codes.Unauthenticated, "auth header missing") return nil, status.Error(codes.Unauthenticated, "auth header missing")
} }
var orgDomain string
orgID := grpc_util.GetHeader(authCtx, http.ZitadelOrgID) orgID := grpc_util.GetHeader(authCtx, http.ZitadelOrgID)
if o, ok := req.(OrganisationFromRequest); ok { if o, ok := req.(OrganisationFromRequest); ok {
orgID = o.OrganisationFromRequest().GetOrgId() orgID = o.OrganisationFromRequest().GetOrgId()
orgDomain = o.OrganisationFromRequest().GetOrgDomain()
} }
ctxSetter, err := authz.CheckUserAuthorization(authCtx, req, authToken, orgID, verifier, authConfig, authOpt, info.FullMethod) ctxSetter, err := authz.CheckUserAuthorization(authCtx, req, authToken, orgID, orgDomain, verifier, authConfig, authOpt, info.FullMethod)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -31,8 +31,8 @@ func (v *verifierMock) SearchMyMemberships(ctx context.Context, orgID string) ([
func (v *verifierMock) ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (string, []string, error) { func (v *verifierMock) ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (string, []string, error) {
return "", nil, nil return "", nil, nil
} }
func (v *verifierMock) ExistsOrg(ctx context.Context, orgID string) error { func (v *verifierMock) ExistsOrg(ctx context.Context, orgID, domain string) (string, error) {
return nil return orgID, nil
} }
func (v *verifierMock) VerifierClientID(ctx context.Context, appName string) (string, string, error) { func (v *verifierMock) VerifierClientID(ctx context.Context, appName string) (string, string, error) {
return "", "", nil return "", "", nil

View File

@ -6,16 +6,18 @@ import (
"github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/grpc/server" "github.com/zitadel/zitadel/internal/api/grpc/server"
"github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/pkg/grpc/session/v2alpha" session "github.com/zitadel/zitadel/pkg/grpc/session/v2alpha"
) )
var _ session.SessionServiceServer = (*Server)(nil) var _ session.SessionServiceServer = (*Server)(nil)
type Server struct { type Server struct {
session.UnimplementedSessionServiceServer session.UnimplementedSessionServiceServer
command *command.Commands command *command.Commands
query *query.Queries query *query.Queries
checkPermission domain.PermissionCheck
} }
type Config struct{} type Config struct{}
@ -23,10 +25,12 @@ type Config struct{}
func CreateServer( func CreateServer(
command *command.Commands, command *command.Commands,
query *query.Queries, query *query.Queries,
checkPermission domain.PermissionCheck,
) *Server { ) *Server {
return &Server{ return &Server{
command: command, command: command,
query: query, query: query,
checkPermission: checkPermission,
} }
} }

View File

@ -3,16 +3,260 @@ package session
import ( import (
"context" "context"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/pkg/grpc/session/v2alpha" "github.com/zitadel/zitadel/internal/api/grpc/object/v2"
"github.com/zitadel/zitadel/pkg/grpc/user/v2alpha" "github.com/zitadel/zitadel/internal/command"
caos_errs "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/query"
session "github.com/zitadel/zitadel/pkg/grpc/session/v2alpha"
) )
func (s *Server) GetSession(ctx context.Context, req *session.GetSessionRequest) (*session.GetSessionResponse, error) { func (s *Server) GetSession(ctx context.Context, req *session.GetSessionRequest) (*session.GetSessionResponse, error) {
res, err := s.query.SessionByID(ctx, req.GetSessionId(), req.GetSessionToken())
if err != nil {
return nil, err
}
return &session.GetSessionResponse{ return &session.GetSessionResponse{
Session: &session.Session{ Session: sessionToPb(res),
Id: req.Id,
User: &user.User{Id: authz.GetCtxData(ctx).UserID},
},
}, nil }, nil
} }
func (s *Server) ListSessions(ctx context.Context, req *session.ListSessionsRequest) (*session.ListSessionsResponse, error) {
queries, err := listSessionsRequestToQuery(ctx, req)
if err != nil {
return nil, err
}
sessions, err := s.query.SearchSessions(ctx, queries)
if err != nil {
return nil, err
}
return &session.ListSessionsResponse{
Details: object.ToListDetails(sessions.SearchResponse),
Sessions: sessionsToPb(sessions.Sessions),
}, nil
}
func (s *Server) CreateSession(ctx context.Context, req *session.CreateSessionRequest) (*session.CreateSessionResponse, error) {
checks, metadata, err := s.createSessionRequestToCommand(ctx, req)
if err != nil {
return nil, err
}
set, err := s.command.CreateSession(ctx, checks, metadata)
if err != nil {
return nil, err
}
return &session.CreateSessionResponse{
Details: object.DomainToDetailsPb(set.ObjectDetails),
SessionId: set.ID,
SessionToken: set.NewToken,
}, nil
}
func (s *Server) SetSession(ctx context.Context, req *session.SetSessionRequest) (*session.SetSessionResponse, error) {
checks, err := s.setSessionRequestToCommand(ctx, req)
if err != nil {
return nil, err
}
set, err := s.command.UpdateSession(ctx, req.GetSessionId(), req.GetSessionToken(), checks, req.GetMetadata())
if err != nil {
return nil, err
}
// if there's no new token, just return the current
if set.NewToken == "" {
set.NewToken = req.GetSessionToken()
}
return &session.SetSessionResponse{
Details: object.DomainToDetailsPb(set.ObjectDetails),
SessionToken: set.NewToken,
}, nil
}
func (s *Server) DeleteSession(ctx context.Context, req *session.DeleteSessionRequest) (*session.DeleteSessionResponse, error) {
details, err := s.command.TerminateSession(ctx, req.GetSessionId(), req.GetSessionToken())
if err != nil {
return nil, err
}
return &session.DeleteSessionResponse{
Details: object.DomainToDetailsPb(details),
}, nil
}
func sessionsToPb(sessions []*query.Session) []*session.Session {
s := make([]*session.Session, len(sessions))
for i, session := range sessions {
s[i] = sessionToPb(session)
}
return s
}
func sessionToPb(s *query.Session) *session.Session {
return &session.Session{
Id: s.ID,
CreationDate: timestamppb.New(s.CreationDate),
ChangeDate: timestamppb.New(s.ChangeDate),
Sequence: s.Sequence,
Factors: factorsToPb(s),
Metadata: s.Metadata,
}
}
func factorsToPb(s *query.Session) *session.Factors {
user := userFactorToPb(s.UserFactor)
pw := passwordFactorToPb(s.PasswordFactor)
if user == nil && pw == nil {
return nil
}
return &session.Factors{
User: user,
Password: pw,
}
}
func passwordFactorToPb(factor query.SessionPasswordFactor) *session.PasswordFactor {
if factor.PasswordCheckedAt.IsZero() {
return nil
}
return &session.PasswordFactor{
VerifiedAt: timestamppb.New(factor.PasswordCheckedAt),
}
}
func userFactorToPb(factor query.SessionUserFactor) *session.UserFactor {
if factor.UserID == "" || factor.UserCheckedAt.IsZero() {
return nil
}
return &session.UserFactor{
VerifiedAt: timestamppb.New(factor.UserCheckedAt),
Id: factor.UserID,
LoginName: factor.LoginName,
DisplayName: factor.DisplayName,
}
}
func listSessionsRequestToQuery(ctx context.Context, req *session.ListSessionsRequest) (*query.SessionsSearchQueries, error) {
offset, limit, asc := object.ListQueryToQuery(req.Query)
queries, err := sessionQueriesToQuery(ctx, req.GetQueries())
if err != nil {
return nil, err
}
return &query.SessionsSearchQueries{
SearchRequest: query.SearchRequest{
Offset: offset,
Limit: limit,
Asc: asc,
},
Queries: queries,
}, nil
}
func sessionQueriesToQuery(ctx context.Context, queries []*session.SearchQuery) (_ []query.SearchQuery, err error) {
q := make([]query.SearchQuery, len(queries)+1)
for i, query := range queries {
q[i], err = sessionQueryToQuery(query)
if err != nil {
return nil, err
}
}
creatorQuery, err := query.NewSessionCreatorSearchQuery(authz.GetCtxData(ctx).UserID)
if err != nil {
return nil, err
}
q[len(queries)] = creatorQuery
return q, nil
}
func sessionQueryToQuery(query *session.SearchQuery) (query.SearchQuery, error) {
switch q := query.Query.(type) {
case *session.SearchQuery_IdsQuery:
return idsQueryToQuery(q.IdsQuery)
default:
return nil, caos_errs.ThrowInvalidArgument(nil, "GRPC-Sfefs", "List.Query.Invalid")
}
}
func idsQueryToQuery(q *session.IDsQuery) (query.SearchQuery, error) {
return query.NewSessionIDsSearchQuery(q.Ids)
}
func (s *Server) createSessionRequestToCommand(ctx context.Context, req *session.CreateSessionRequest) ([]command.SessionCheck, map[string][]byte, error) {
checks, err := s.checksToCommand(ctx, req.Checks)
if err != nil {
return nil, nil, err
}
return checks, req.GetMetadata(), nil
}
func (s *Server) setSessionRequestToCommand(ctx context.Context, req *session.SetSessionRequest) ([]command.SessionCheck, error) {
checks, err := s.checksToCommand(ctx, req.Checks)
if err != nil {
return nil, err
}
return checks, nil
}
func (s *Server) checksToCommand(ctx context.Context, checks *session.Checks) ([]command.SessionCheck, error) {
checkUser, err := userCheck(checks.GetUser())
if err != nil {
return nil, err
}
sessionChecks := make([]command.SessionCheck, 0, 2)
if checkUser != nil {
user, err := checkUser.search(ctx, s.query)
if err != nil {
return nil, err
}
sessionChecks = append(sessionChecks, command.CheckUser(user.ID))
}
if password := checks.GetPassword(); password != nil {
sessionChecks = append(sessionChecks, command.CheckPassword(password.GetPassword()))
}
return sessionChecks, nil
}
func userCheck(user *session.CheckUser) (userSearch, error) {
if user == nil {
return nil, nil
}
switch s := user.GetSearch().(type) {
case *session.CheckUser_UserId:
return userByID(s.UserId), nil
case *session.CheckUser_LoginName:
return userByLoginName(s.LoginName)
default:
return nil, caos_errs.ThrowUnimplementedf(nil, "SESSION-d3b4g0", "user search %T not implemented", s)
}
}
type userSearch interface {
search(ctx context.Context, q *query.Queries) (*query.User, error)
}
func userByID(userID string) userSearch {
return userSearchByID{userID}
}
func userByLoginName(loginName string) (userSearch, error) {
loginNameQuery, err := query.NewUserLoginNamesSearchQuery(loginName)
if err != nil {
return nil, err
}
return userSearchByLoginName{loginNameQuery}, nil
}
type userSearchByID struct {
id string
}
func (u userSearchByID) search(ctx context.Context, q *query.Queries) (*query.User, error) {
return q.GetUserByID(ctx, true, u.id, false)
}
type userSearchByLoginName struct {
loginNameQuery query.SearchQuery
}
func (u userSearchByLoginName) search(ctx context.Context, q *query.Queries) (*query.User, error) {
return q.GetUser(ctx, true, false, u.loginNameQuery)
}

View File

@ -0,0 +1,379 @@
package session
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/query"
object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha"
session "github.com/zitadel/zitadel/pkg/grpc/session/v2alpha"
)
func Test_sessionsToPb(t *testing.T) {
now := time.Now()
past := now.Add(-time.Hour)
sessions := []*query.Session{
{ // no factor
ID: "999",
CreationDate: now,
ChangeDate: now,
Sequence: 123,
State: domain.SessionStateActive,
ResourceOwner: "me",
Creator: "he",
Metadata: map[string][]byte{"hello": []byte("world")},
},
{ // user factor
ID: "999",
CreationDate: now,
ChangeDate: now,
Sequence: 123,
State: domain.SessionStateActive,
ResourceOwner: "me",
Creator: "he",
UserFactor: query.SessionUserFactor{
UserID: "345",
UserCheckedAt: past,
LoginName: "donald",
DisplayName: "donald duck",
},
Metadata: map[string][]byte{"hello": []byte("world")},
},
{ // no factor
ID: "999",
CreationDate: now,
ChangeDate: now,
Sequence: 123,
State: domain.SessionStateActive,
ResourceOwner: "me",
Creator: "he",
PasswordFactor: query.SessionPasswordFactor{
PasswordCheckedAt: past,
},
Metadata: map[string][]byte{"hello": []byte("world")},
},
}
want := []*session.Session{
{ // no factor
Id: "999",
CreationDate: timestamppb.New(now),
ChangeDate: timestamppb.New(now),
Sequence: 123,
Factors: nil,
Metadata: map[string][]byte{"hello": []byte("world")},
},
{ // user factor
Id: "999",
CreationDate: timestamppb.New(now),
ChangeDate: timestamppb.New(now),
Sequence: 123,
Factors: &session.Factors{
User: &session.UserFactor{
VerifiedAt: timestamppb.New(past),
Id: "345",
LoginName: "donald",
DisplayName: "donald duck",
},
},
Metadata: map[string][]byte{"hello": []byte("world")},
},
{ // password factor
Id: "999",
CreationDate: timestamppb.New(now),
ChangeDate: timestamppb.New(now),
Sequence: 123,
Factors: &session.Factors{
Password: &session.PasswordFactor{
VerifiedAt: timestamppb.New(past),
},
},
Metadata: map[string][]byte{"hello": []byte("world")},
},
}
out := sessionsToPb(sessions)
require.Len(t, out, len(want))
for i, got := range out {
if !proto.Equal(got, want[i]) {
t.Errorf("session %d got:\n%v\nwant:\n%v", i, got, want)
}
}
}
func mustNewTextQuery(t testing.TB, column query.Column, value string, compare query.TextComparison) query.SearchQuery {
q, err := query.NewTextQuery(column, value, compare)
require.NoError(t, err)
return q
}
func mustNewListQuery(t testing.TB, column query.Column, list []any, compare query.ListComparison) query.SearchQuery {
q, err := query.NewListQuery(query.SessionColumnID, list, compare)
require.NoError(t, err)
return q
}
func Test_listSessionsRequestToQuery(t *testing.T) {
type args struct {
ctx context.Context
req *session.ListSessionsRequest
}
tests := []struct {
name string
args args
want *query.SessionsSearchQueries
wantErr error
}{
{
name: "default request",
args: args{
ctx: authz.NewMockContext("123", "456", "789"),
req: &session.ListSessionsRequest{},
},
want: &query.SessionsSearchQueries{
SearchRequest: query.SearchRequest{
Offset: 0,
Limit: 0,
Asc: false,
},
Queries: []query.SearchQuery{
mustNewTextQuery(t, query.SessionColumnCreator, "789", query.TextEquals),
},
},
},
{
name: "with list query and sessions",
args: args{
ctx: authz.NewMockContext("123", "456", "789"),
req: &session.ListSessionsRequest{
Query: &object.ListQuery{
Offset: 10,
Limit: 20,
Asc: true,
},
Queries: []*session.SearchQuery{
{Query: &session.SearchQuery_IdsQuery{
IdsQuery: &session.IDsQuery{
Ids: []string{"1", "2", "3"},
},
}},
{Query: &session.SearchQuery_IdsQuery{
IdsQuery: &session.IDsQuery{
Ids: []string{"4", "5", "6"},
},
}},
},
},
},
want: &query.SessionsSearchQueries{
SearchRequest: query.SearchRequest{
Offset: 10,
Limit: 20,
Asc: true,
},
Queries: []query.SearchQuery{
mustNewListQuery(t, query.SessionColumnID, []interface{}{"1", "2", "3"}, query.ListIn),
mustNewListQuery(t, query.SessionColumnID, []interface{}{"4", "5", "6"}, query.ListIn),
mustNewTextQuery(t, query.SessionColumnCreator, "789", query.TextEquals),
},
},
},
{
name: "invalid argument error",
args: args{
ctx: authz.NewMockContext("123", "456", "789"),
req: &session.ListSessionsRequest{
Query: &object.ListQuery{
Offset: 10,
Limit: 20,
Asc: true,
},
Queries: []*session.SearchQuery{
{Query: &session.SearchQuery_IdsQuery{
IdsQuery: &session.IDsQuery{
Ids: []string{"1", "2", "3"},
},
}},
{Query: nil},
},
},
},
wantErr: caos_errs.ThrowInvalidArgument(nil, "GRPC-Sfefs", "List.Query.Invalid"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := listSessionsRequestToQuery(tt.args.ctx, tt.args.req)
require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.want, got)
})
}
}
func Test_sessionQueriesToQuery(t *testing.T) {
type args struct {
ctx context.Context
queries []*session.SearchQuery
}
tests := []struct {
name string
args args
want []query.SearchQuery
wantErr error
}{
{
name: "creator only",
args: args{
ctx: authz.NewMockContext("123", "456", "789"),
},
want: []query.SearchQuery{
mustNewTextQuery(t, query.SessionColumnCreator, "789", query.TextEquals),
},
},
{
name: "invalid argument",
args: args{
ctx: authz.NewMockContext("123", "456", "789"),
queries: []*session.SearchQuery{
{Query: nil},
},
},
wantErr: caos_errs.ThrowInvalidArgument(nil, "GRPC-Sfefs", "List.Query.Invalid"),
},
{
name: "creator and sessions",
args: args{
ctx: authz.NewMockContext("123", "456", "789"),
queries: []*session.SearchQuery{
{Query: &session.SearchQuery_IdsQuery{
IdsQuery: &session.IDsQuery{
Ids: []string{"1", "2", "3"},
},
}},
{Query: &session.SearchQuery_IdsQuery{
IdsQuery: &session.IDsQuery{
Ids: []string{"4", "5", "6"},
},
}},
},
},
want: []query.SearchQuery{
mustNewListQuery(t, query.SessionColumnID, []interface{}{"1", "2", "3"}, query.ListIn),
mustNewListQuery(t, query.SessionColumnID, []interface{}{"4", "5", "6"}, query.ListIn),
mustNewTextQuery(t, query.SessionColumnCreator, "789", query.TextEquals),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := sessionQueriesToQuery(tt.args.ctx, tt.args.queries)
require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.want, got)
})
}
}
func Test_sessionQueryToQuery(t *testing.T) {
type args struct {
query *session.SearchQuery
}
tests := []struct {
name string
args args
want query.SearchQuery
wantErr error
}{
{
name: "invalid argument",
args: args{&session.SearchQuery{
Query: nil,
}},
wantErr: caos_errs.ThrowInvalidArgument(nil, "GRPC-Sfefs", "List.Query.Invalid"),
},
{
name: "query",
args: args{&session.SearchQuery{
Query: &session.SearchQuery_IdsQuery{
IdsQuery: &session.IDsQuery{
Ids: []string{"1", "2", "3"},
},
},
}},
want: mustNewListQuery(t, query.SessionColumnID, []interface{}{"1", "2", "3"}, query.ListIn),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := sessionQueryToQuery(tt.args.query)
require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.want, got)
})
}
}
func mustUserLoginNamesSearchQuery(t testing.TB, value string) query.SearchQuery {
loginNameQuery, err := query.NewUserLoginNamesSearchQuery("bar")
require.NoError(t, err)
return loginNameQuery
}
func Test_userCheck(t *testing.T) {
type args struct {
user *session.CheckUser
}
tests := []struct {
name string
args args
want userSearch
wantErr error
}{
{
name: "nil user",
args: args{nil},
want: nil,
},
{
name: "by user id",
args: args{&session.CheckUser{
Search: &session.CheckUser_UserId{
UserId: "foo",
},
}},
want: userSearchByID{"foo"},
},
{
name: "by user id",
args: args{&session.CheckUser{
Search: &session.CheckUser_LoginName{
LoginName: "bar",
},
}},
want: userSearchByLoginName{mustUserLoginNamesSearchQuery(t, "bar")},
},
{
name: "unimplemented error",
args: args{&session.CheckUser{
Search: nil,
}},
wantErr: caos_errs.ThrowUnimplementedf(nil, "SESSION-d3b4g0", "user search %T not implemented", nil),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := userCheck(tt.args.user)
require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.want, got)
})
}
}

View File

@ -11,7 +11,7 @@ import (
"github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/errors" "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/pkg/grpc/user/v2alpha" user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
) )
func (s *Server) AddHumanUser(ctx context.Context, req *user.AddHumanUserRequest) (_ *user.AddHumanUserResponse, err error) { func (s *Server) AddHumanUser(ctx context.Context, req *user.AddHumanUserRequest) (_ *user.AddHumanUserResponse, err error) {
@ -19,10 +19,7 @@ func (s *Server) AddHumanUser(ctx context.Context, req *user.AddHumanUserRequest
if err != nil { if err != nil {
return nil, err return nil, err
} }
orgID := req.GetOrganisation().GetOrgId() orgID := authz.GetCtxData(ctx).OrgID
if orgID == "" {
orgID = authz.GetCtxData(ctx).OrgID
}
err = s.command.AddHuman(ctx, orgID, human, false) err = s.command.AddHuman(ctx, orgID, human, false)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -62,7 +62,7 @@ func authorize(r *http.Request, verifier *authz.TokenVerifier, authConfig authz.
return nil, errors.New("auth header missing") return nil, errors.New("auth header missing")
} }
ctxSetter, err := authz.CheckUserAuthorization(authCtx, &httpReq{}, authToken, http_util.GetOrgID(r), verifier, authConfig, authOpt, r.RequestURI) ctxSetter, err := authz.CheckUserAuthorization(authCtx, &httpReq{}, authToken, http_util.GetOrgID(r), "", verifier, authConfig, authOpt, r.RequestURI)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -20,6 +20,7 @@ import (
"github.com/zitadel/zitadel/internal/repository/org" "github.com/zitadel/zitadel/internal/repository/org"
proj_repo "github.com/zitadel/zitadel/internal/repository/project" proj_repo "github.com/zitadel/zitadel/internal/repository/project"
"github.com/zitadel/zitadel/internal/repository/quota" "github.com/zitadel/zitadel/internal/repository/quota"
"github.com/zitadel/zitadel/internal/repository/session"
usr_repo "github.com/zitadel/zitadel/internal/repository/user" usr_repo "github.com/zitadel/zitadel/internal/repository/user"
usr_grant_repo "github.com/zitadel/zitadel/internal/repository/usergrant" usr_grant_repo "github.com/zitadel/zitadel/internal/repository/usergrant"
"github.com/zitadel/zitadel/internal/static" "github.com/zitadel/zitadel/internal/static"
@ -29,7 +30,7 @@ import (
type Commands struct { type Commands struct {
httpClient *http.Client httpClient *http.Client
checkPermission permissionCheck checkPermission domain.PermissionCheck
newEmailCode func(ctx context.Context, filter preparation.FilterToQueryReducer, codeAlg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error) newEmailCode func(ctx context.Context, filter preparation.FilterToQueryReducer, codeAlg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error)
eventstore *eventstore.Eventstore eventstore *eventstore.Eventstore
@ -50,6 +51,8 @@ type Commands struct {
domainVerificationAlg crypto.EncryptionAlgorithm domainVerificationAlg crypto.EncryptionAlgorithm
domainVerificationGenerator crypto.Generator domainVerificationGenerator crypto.Generator
domainVerificationValidator func(domain, token, verifier string, checkType api_http.CheckType) error domainVerificationValidator func(domain, token, verifier string, checkType api_http.CheckType) error
sessionTokenCreator func(sessionID string) (id string, token string, err error)
sessionTokenVerifier func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error)
multifactors domain.MultifactorConfigs multifactors domain.MultifactorConfigs
webauthnConfig *webauthn_helper.Config webauthnConfig *webauthn_helper.Config
@ -71,24 +74,21 @@ func StartCommands(
externalDomain string, externalDomain string,
externalSecure bool, externalSecure bool,
externalPort uint16, externalPort uint16,
idpConfigEncryption, idpConfigEncryption, otpEncryption, smtpEncryption, smsEncryption, userEncryption, domainVerificationEncryption, oidcEncryption, samlEncryption crypto.EncryptionAlgorithm,
otpEncryption,
smtpEncryption,
smsEncryption,
userEncryption,
domainVerificationEncryption,
oidcEncryption,
samlEncryption crypto.EncryptionAlgorithm,
httpClient *http.Client, httpClient *http.Client,
membershipsResolver authz.MembershipsResolver, permissionCheck domain.PermissionCheck,
sessionTokenVerifier func(ctx context.Context, sessionToken string, sessionID string, tokenID string) (err error),
) (repo *Commands, err error) { ) (repo *Commands, err error) {
if externalDomain == "" { if externalDomain == "" {
return nil, errors.ThrowInvalidArgument(nil, "COMMAND-Df21s", "no external domain specified") return nil, errors.ThrowInvalidArgument(nil, "COMMAND-Df21s", "no external domain specified")
} }
idGenerator := id.SonyFlakeGenerator()
// reuse the oidcEncryption to be able to handle both tokens in the interceptor later on
sessionAlg := oidcEncryption
repo = &Commands{ repo = &Commands{
eventstore: es, eventstore: es,
static: staticStore, static: staticStore,
idGenerator: id.SonyFlakeGenerator(), idGenerator: idGenerator,
zitadelRoles: zitadelRoles, zitadelRoles: zitadelRoles,
externalDomain: externalDomain, externalDomain: externalDomain,
externalSecure: externalSecure, externalSecure: externalSecure,
@ -107,10 +107,10 @@ func StartCommands(
certificateAlgorithm: samlEncryption, certificateAlgorithm: samlEncryption,
webauthnConfig: webAuthN, webauthnConfig: webAuthN,
httpClient: httpClient, httpClient: httpClient,
checkPermission: func(ctx context.Context, permission, orgID, resourceID string, allowSelf bool) (err error) { checkPermission: permissionCheck,
return authz.CheckPermission(ctx, membershipsResolver, zitadelRoles, permission, orgID, resourceID, allowSelf) newEmailCode: newEmailCode,
}, sessionTokenCreator: sessionTokenCreator(idGenerator, sessionAlg),
newEmailCode: newEmailCode, sessionTokenVerifier: sessionTokenVerifier,
} }
instance_repo.RegisterEventMappers(repo.eventstore) instance_repo.RegisterEventMappers(repo.eventstore)
@ -121,6 +121,7 @@ func StartCommands(
keypair.RegisterEventMappers(repo.eventstore) keypair.RegisterEventMappers(repo.eventstore)
action.RegisterEventMappers(repo.eventstore) action.RegisterEventMappers(repo.eventstore)
quota.RegisterEventMappers(repo.eventstore) quota.RegisterEventMappers(repo.eventstore)
session.RegisterEventMappers(repo.eventstore)
repo.userPasswordAlg = crypto.NewBCrypt(defaults.SecretGenerators.PasswordSaltCost) repo.userPasswordAlg = crypto.NewBCrypt(defaults.SecretGenerators.PasswordSaltCost)
repo.machineKeySize = int(defaults.SecretGenerators.MachineKeySize) repo.machineKeySize = int(defaults.SecretGenerators.MachineKeySize)

View File

@ -10,6 +10,7 @@ import (
"golang.org/x/text/language" "golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/errors" "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/repository" "github.com/zitadel/zitadel/internal/eventstore/repository"
@ -19,6 +20,7 @@ import (
key_repo "github.com/zitadel/zitadel/internal/repository/keypair" key_repo "github.com/zitadel/zitadel/internal/repository/keypair"
"github.com/zitadel/zitadel/internal/repository/org" "github.com/zitadel/zitadel/internal/repository/org"
proj_repo "github.com/zitadel/zitadel/internal/repository/project" proj_repo "github.com/zitadel/zitadel/internal/repository/project"
"github.com/zitadel/zitadel/internal/repository/session"
usr_repo "github.com/zitadel/zitadel/internal/repository/user" usr_repo "github.com/zitadel/zitadel/internal/repository/user"
"github.com/zitadel/zitadel/internal/repository/usergrant" "github.com/zitadel/zitadel/internal/repository/usergrant"
) )
@ -38,6 +40,7 @@ func eventstoreExpect(t *testing.T, expects ...expect) *eventstore.Eventstore {
usergrant.RegisterEventMappers(es) usergrant.RegisterEventMappers(es)
key_repo.RegisterEventMappers(es) key_repo.RegisterEventMappers(es)
action_repo.RegisterEventMappers(es) action_repo.RegisterEventMappers(es)
session.RegisterEventMappers(es)
return es return es
} }
@ -125,6 +128,11 @@ func expectFilter(events ...*repository.Event) expect {
m.ExpectFilterEvents(events...) m.ExpectFilterEvents(events...)
} }
} }
func expectFilterError(err error) expect {
return func(m *mock.MockRepository) {
m.ExpectFilterEventsError(err)
}
}
func expectFilterOrgDomainNotFound() expect { func expectFilterOrgDomainNotFound() expect {
return func(m *mock.MockRepository) { return func(m *mock.MockRepository) {
@ -250,14 +258,14 @@ func (m *mockInstance) SecurityPolicyAllowedOrigins() []string {
return nil return nil
} }
func newMockPermissionCheckAllowed() permissionCheck { func newMockPermissionCheckAllowed() domain.PermissionCheck {
return func(ctx context.Context, permission, orgID, resourceID string, allowSelf bool) (err error) { return func(ctx context.Context, permission, orgID, resourceID string) (err error) {
return nil return nil
} }
} }
func newMockPermissionCheckNotAllowed() permissionCheck { func newMockPermissionCheckNotAllowed() domain.PermissionCheck {
return func(ctx context.Context, permission, orgID, resourceID string, allowSelf bool) (err error) { return func(ctx context.Context, permission, orgID, resourceID string) (err error) {
return errors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied") return errors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")
} }
} }

View File

@ -1,11 +0,0 @@
package command
import (
"context"
)
type permissionCheck func(ctx context.Context, permission, orgID, resourceID string, allowSelf bool) (err error)
const (
permissionUserWrite = "user.write"
)

225
internal/command/session.go Normal file
View File

@ -0,0 +1,225 @@
package command
import (
"context"
"encoding/base64"
"fmt"
"time"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/id"
"github.com/zitadel/zitadel/internal/repository/session"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
)
type SessionCheck func(ctx context.Context, cmd *SessionChecks) error
type SessionChecks struct {
checks []SessionCheck
sessionWriteModel *SessionWriteModel
passwordWriteModel *HumanPasswordWriteModel
eventstore *eventstore.Eventstore
userPasswordAlg crypto.HashAlgorithm
createToken func(sessionID string) (id string, token string, err error)
now func() time.Time
}
func (c *Commands) NewSessionChecks(checks []SessionCheck, session *SessionWriteModel) *SessionChecks {
return &SessionChecks{
checks: checks,
sessionWriteModel: session,
eventstore: c.eventstore,
userPasswordAlg: c.userPasswordAlg,
createToken: c.sessionTokenCreator,
now: time.Now,
}
}
// CheckUser defines a user check to be executed for a session update
func CheckUser(id string) SessionCheck {
return func(ctx context.Context, cmd *SessionChecks) error {
if cmd.sessionWriteModel.UserID != "" && id != "" && cmd.sessionWriteModel.UserID != id {
return caos_errs.ThrowInvalidArgument(nil, "", "user change not possible")
}
return cmd.sessionWriteModel.UserChecked(ctx, id, cmd.now())
}
}
// CheckPassword defines a password check to be executed for a session update
func CheckPassword(password string) SessionCheck {
return func(ctx context.Context, cmd *SessionChecks) error {
if cmd.sessionWriteModel.UserID == "" {
return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Sfw3f", "Errors.User.UserIDMissing")
}
cmd.passwordWriteModel = NewHumanPasswordWriteModel(cmd.sessionWriteModel.UserID, "")
err := cmd.eventstore.FilterToQueryReducer(ctx, cmd.passwordWriteModel)
if err != nil {
return err
}
if cmd.passwordWriteModel.UserState == domain.UserStateUnspecified || cmd.passwordWriteModel.UserState == domain.UserStateDeleted {
return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Df4b3", "Errors.User.NotFound")
}
if cmd.passwordWriteModel.Secret == nil {
return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-WEf3t", "Errors.User.Password.NotSet")
}
ctx, spanPasswordComparison := tracing.NewNamedSpan(ctx, "crypto.CompareHash")
err = crypto.CompareHash(cmd.passwordWriteModel.Secret, []byte(password), cmd.userPasswordAlg)
spanPasswordComparison.EndWithError(err)
if err != nil {
//TODO: maybe we want to reset the session in the future https://github.com/zitadel/zitadel/issues/5807
return caos_errs.ThrowInvalidArgument(err, "COMMAND-SAF3g", "Errors.User.Password.Invalid")
}
cmd.sessionWriteModel.PasswordChecked(ctx, cmd.now())
return nil
}
}
// Check will execute the checks specified and return an error on the first occurrence
func (s *SessionChecks) Check(ctx context.Context) error {
for _, check := range s.checks {
if err := check(ctx, s); err != nil {
return err
}
}
return nil
}
func (s *SessionChecks) commands(ctx context.Context) (string, []eventstore.Command, error) {
if len(s.sessionWriteModel.commands) == 0 {
return "", nil, nil
}
tokenID, token, err := s.createToken(s.sessionWriteModel.AggregateID)
if err != nil {
return "", nil, err
}
s.sessionWriteModel.SetToken(ctx, tokenID)
return token, s.sessionWriteModel.commands, nil
}
func (c *Commands) CreateSession(ctx context.Context, checks []SessionCheck, metadata map[string][]byte) (set *SessionChanged, err error) {
sessionID, err := c.idGenerator.Next()
if err != nil {
return nil, err
}
sessionWriteModel := NewSessionWriteModel(sessionID, authz.GetCtxData(ctx).OrgID)
err = c.eventstore.FilterToQueryReducer(ctx, sessionWriteModel)
if err != nil {
return nil, err
}
cmd := c.NewSessionChecks(checks, sessionWriteModel)
cmd.sessionWriteModel.Start(ctx)
return c.updateSession(ctx, cmd, metadata)
}
func (c *Commands) UpdateSession(ctx context.Context, sessionID, sessionToken string, checks []SessionCheck, metadata map[string][]byte) (set *SessionChanged, err error) {
sessionWriteModel := NewSessionWriteModel(sessionID, authz.GetCtxData(ctx).OrgID)
err = c.eventstore.FilterToQueryReducer(ctx, sessionWriteModel)
if err != nil {
return nil, err
}
if err := c.sessionPermission(ctx, sessionWriteModel, sessionToken, domain.PermissionSessionWrite); err != nil {
return nil, err
}
cmd := c.NewSessionChecks(checks, sessionWriteModel)
return c.updateSession(ctx, cmd, metadata)
}
func (c *Commands) TerminateSession(ctx context.Context, sessionID, sessionToken string) (*domain.ObjectDetails, error) {
sessionWriteModel := NewSessionWriteModel(sessionID, "")
if err := c.eventstore.FilterToQueryReducer(ctx, sessionWriteModel); err != nil {
return nil, err
}
if err := c.sessionPermission(ctx, sessionWriteModel, sessionToken, domain.PermissionSessionDelete); err != nil {
return nil, err
}
if sessionWriteModel.State != domain.SessionStateActive {
return writeModelToObjectDetails(&sessionWriteModel.WriteModel), nil
}
terminate := session.NewTerminateEvent(ctx, &session.NewAggregate(sessionWriteModel.AggregateID, sessionWriteModel.ResourceOwner).Aggregate)
pushedEvents, err := c.eventstore.Push(ctx, terminate)
if err != nil {
return nil, err
}
err = AppendAndReduce(sessionWriteModel, pushedEvents...)
if err != nil {
return nil, err
}
return writeModelToObjectDetails(&sessionWriteModel.WriteModel), nil
}
// updateSession execute the [SessionChecks] where new events will be created and as well as for metadata (changes)
func (c *Commands) updateSession(ctx context.Context, checks *SessionChecks, metadata map[string][]byte) (set *SessionChanged, err error) {
if checks.sessionWriteModel.State == domain.SessionStateTerminated {
return nil, caos_errs.ThrowPreconditionFailed(nil, "COMAND-SAjeh", "Errors.Session.Terminated")
}
if err := checks.Check(ctx); err != nil {
// TODO: how to handle failed checks (e.g. pw wrong) https://github.com/zitadel/zitadel/issues/5807
return nil, err
}
checks.sessionWriteModel.ChangeMetadata(ctx, metadata)
sessionToken, cmds, err := checks.commands(ctx)
if err != nil {
return nil, err
}
if len(cmds) == 0 {
return sessionWriteModelToSessionChanged(checks.sessionWriteModel), nil
}
pushedEvents, err := c.eventstore.Push(ctx, cmds...)
if err != nil {
return nil, err
}
err = AppendAndReduce(checks.sessionWriteModel, pushedEvents...)
if err != nil {
return nil, err
}
changed := sessionWriteModelToSessionChanged(checks.sessionWriteModel)
changed.NewToken = sessionToken
return changed, nil
}
// sessionPermission will check that the provided sessionToken is correct or
// if empty, check that the caller is granted the necessary permission
func (c *Commands) sessionPermission(ctx context.Context, sessionWriteModel *SessionWriteModel, sessionToken, permission string) (err error) {
if sessionToken == "" {
return c.checkPermission(ctx, permission, authz.GetCtxData(ctx).OrgID, sessionWriteModel.AggregateID)
}
return c.sessionTokenVerifier(ctx, sessionToken, sessionWriteModel.AggregateID, sessionWriteModel.TokenID)
}
func sessionTokenCreator(idGenerator id.Generator, sessionAlg crypto.EncryptionAlgorithm) func(sessionID string) (id string, token string, err error) {
return func(sessionID string) (id string, token string, err error) {
id, err = idGenerator.Next()
if err != nil {
return "", "", err
}
encrypted, err := sessionAlg.Encrypt([]byte(fmt.Sprintf(authz.SessionTokenFormat, sessionID, id)))
if err != nil {
return "", "", err
}
return id, base64.RawURLEncoding.EncodeToString(encrypted), nil
}
}
type SessionChanged struct {
*domain.ObjectDetails
ID string
NewToken string
}
func sessionWriteModelToSessionChanged(wm *SessionWriteModel) *SessionChanged {
return &SessionChanged{
ObjectDetails: &domain.ObjectDetails{
Sequence: wm.ProcessedSequence,
EventDate: wm.ChangeDate,
ResourceOwner: wm.ResourceOwner,
},
ID: wm.AggregateID,
}
}

View File

@ -0,0 +1,139 @@
package command
import (
"bytes"
"context"
"time"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/session"
)
type SessionWriteModel struct {
eventstore.WriteModel
TokenID string
UserID string
UserCheckedAt time.Time
PasswordCheckedAt time.Time
Metadata map[string][]byte
State domain.SessionState
commands []eventstore.Command
aggregate *eventstore.Aggregate
}
func NewSessionWriteModel(sessionID string, resourceOwner string) *SessionWriteModel {
return &SessionWriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: sessionID,
ResourceOwner: resourceOwner,
},
Metadata: make(map[string][]byte),
aggregate: &session.NewAggregate(sessionID, resourceOwner).Aggregate,
}
}
func (wm *SessionWriteModel) Reduce() error {
for _, event := range wm.Events {
switch e := event.(type) {
case *session.AddedEvent:
wm.reduceAdded(e)
case *session.UserCheckedEvent:
wm.reduceUserChecked(e)
case *session.PasswordCheckedEvent:
wm.reducePasswordChecked(e)
case *session.TokenSetEvent:
wm.reduceTokenSet(e)
case *session.TerminateEvent:
wm.reduceTerminate()
}
}
return wm.WriteModel.Reduce()
}
func (wm *SessionWriteModel) Query() *eventstore.SearchQueryBuilder {
query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
AddQuery().
AggregateTypes(session.AggregateType).
AggregateIDs(wm.AggregateID).
EventTypes(
session.AddedType,
session.UserCheckedType,
session.PasswordCheckedType,
session.TokenSetType,
session.MetadataSetType,
session.TerminateType,
).
Builder()
if wm.ResourceOwner != "" {
query.ResourceOwner(wm.ResourceOwner)
}
return query
}
func (wm *SessionWriteModel) reduceAdded(e *session.AddedEvent) {
wm.State = domain.SessionStateActive
}
func (wm *SessionWriteModel) reduceUserChecked(e *session.UserCheckedEvent) {
wm.UserID = e.UserID
wm.UserCheckedAt = e.CheckedAt
}
func (wm *SessionWriteModel) reducePasswordChecked(e *session.PasswordCheckedEvent) {
wm.PasswordCheckedAt = e.CheckedAt
}
func (wm *SessionWriteModel) reduceTokenSet(e *session.TokenSetEvent) {
wm.TokenID = e.TokenID
}
func (wm *SessionWriteModel) reduceTerminate() {
wm.State = domain.SessionStateTerminated
}
func (wm *SessionWriteModel) Start(ctx context.Context) {
wm.commands = append(wm.commands, session.NewAddedEvent(ctx, wm.aggregate))
}
func (wm *SessionWriteModel) UserChecked(ctx context.Context, userID string, checkedAt time.Time) error {
wm.commands = append(wm.commands, session.NewUserCheckedEvent(ctx, wm.aggregate, userID, checkedAt))
// set the userID so other checks can use it
wm.UserID = userID
return nil
}
func (wm *SessionWriteModel) PasswordChecked(ctx context.Context, checkedAt time.Time) {
wm.commands = append(wm.commands, session.NewPasswordCheckedEvent(ctx, wm.aggregate, checkedAt))
}
func (wm *SessionWriteModel) SetToken(ctx context.Context, tokenID string) {
wm.commands = append(wm.commands, session.NewTokenSetEvent(ctx, wm.aggregate, tokenID))
}
func (wm *SessionWriteModel) ChangeMetadata(ctx context.Context, metadata map[string][]byte) {
var changed bool
for key, value := range metadata {
currentValue, exists := wm.Metadata[key]
if len(value) != 0 {
// if a value is provided, and it's not equal, change it
if !bytes.Equal(currentValue, value) {
wm.Metadata[key] = value
changed = true
}
} else {
// if there's no / an empty value, we only need to remove it on existing entries
if exists {
delete(wm.Metadata, key)
changed = true
}
}
}
if changed {
wm.commands = append(wm.commands, session.NewMetadataSetEvent(ctx, wm.aggregate, wm.Metadata))
}
}

View File

@ -0,0 +1,547 @@
package command
import (
"context"
"testing"
"time"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/id"
"github.com/zitadel/zitadel/internal/id/mock"
"github.com/zitadel/zitadel/internal/repository/session"
"github.com/zitadel/zitadel/internal/repository/user"
)
func TestCommands_CreateSession(t *testing.T) {
type fields struct {
eventstore *eventstore.Eventstore
idGenerator id.Generator
tokenCreator func(sessionID string) (string, string, error)
}
type args struct {
ctx context.Context
checks []SessionCheck
metadata map[string][]byte
}
type res struct {
want *SessionChanged
err error
}
tests := []struct {
name string
fields fields
args args
res res
}{
{
"id generator fails",
fields{
idGenerator: mock.NewIDGeneratorExpectError(t, caos_errs.ThrowInternal(nil, "id", "generator failed")),
},
args{
ctx: context.Background(),
},
res{
err: caos_errs.ThrowInternal(nil, "id", "generator failed"),
},
},
{
"eventstore failed",
fields{
idGenerator: mock.NewIDGeneratorExpectIDs(t, "sessionID"),
eventstore: eventstoreExpect(t,
expectFilterError(caos_errs.ThrowInternal(nil, "id", "filter failed")),
),
},
args{
ctx: context.Background(),
},
res{
err: caos_errs.ThrowInternal(nil, "id", "filter failed"),
},
},
{
"empty session",
fields{
idGenerator: mock.NewIDGeneratorExpectIDs(t, "sessionID"),
eventstore: eventstoreExpect(t,
expectFilter(),
expectPush(
eventPusherToEvents(
session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate),
session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate,
"tokenID",
),
),
),
),
tokenCreator: func(sessionID string) (string, string, error) {
return "tokenID",
"token",
nil
},
},
args{
ctx: authz.NewMockContext("", "org1", ""),
},
res{
want: &SessionChanged{
ObjectDetails: &domain.ObjectDetails{ResourceOwner: "org1"},
ID: "sessionID",
NewToken: "token",
},
},
},
// the rest is tested in the Test_updateSession
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
eventstore: tt.fields.eventstore,
idGenerator: tt.fields.idGenerator,
sessionTokenCreator: tt.fields.tokenCreator,
}
got, err := c.CreateSession(tt.args.ctx, tt.args.checks, tt.args.metadata)
require.ErrorIs(t, err, tt.res.err)
assert.Equal(t, tt.res.want, got)
})
}
}
func TestCommands_UpdateSession(t *testing.T) {
type fields struct {
eventstore *eventstore.Eventstore
tokenVerifier func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error)
}
type args struct {
ctx context.Context
sessionID string
sessionToken string
checks []SessionCheck
metadata map[string][]byte
}
type res struct {
want *SessionChanged
err error
}
tests := []struct {
name string
fields fields
args args
res res
}{
{
"eventstore failed",
fields{
eventstore: eventstoreExpect(t,
expectFilterError(caos_errs.ThrowInternal(nil, "id", "filter failed")),
),
},
args{
ctx: context.Background(),
},
res{
err: caos_errs.ThrowInternal(nil, "id", "filter failed"),
},
},
{
"invalid session token",
fields{
eventstore: eventstoreExpect(t,
expectFilter(
eventFromEventPusher(
session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)),
eventFromEventPusher(
session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate,
"tokenID")),
),
),
tokenVerifier: func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) {
return caos_errs.ThrowPermissionDenied(nil, "COMMAND-sGr42", "Errors.Session.Token.Invalid")
},
},
args{
ctx: context.Background(),
sessionID: "sessionID",
sessionToken: "invalid",
},
res{
err: caos_errs.ThrowPermissionDenied(nil, "COMMAND-sGr42", "Errors.Session.Token.Invalid"),
},
},
{
"no change",
fields{
eventstore: eventstoreExpect(t,
expectFilter(
eventFromEventPusher(
session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)),
eventFromEventPusher(
session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate,
"tokenID")),
),
),
tokenVerifier: func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) {
return nil
},
},
args{
ctx: context.Background(),
sessionID: "sessionID",
sessionToken: "token",
},
res{
want: &SessionChanged{
ObjectDetails: &domain.ObjectDetails{
ResourceOwner: "org1",
},
ID: "sessionID",
NewToken: "",
},
},
},
// the rest is tested in the Test_updateSession
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
eventstore: tt.fields.eventstore,
sessionTokenVerifier: tt.fields.tokenVerifier,
}
got, err := c.UpdateSession(tt.args.ctx, tt.args.sessionID, tt.args.sessionToken, tt.args.checks, tt.args.metadata)
require.ErrorIs(t, err, tt.res.err)
assert.Equal(t, tt.res.want, got)
})
}
}
func TestCommands_updateSession(t *testing.T) {
testNow := time.Now()
type fields struct {
eventstore *eventstore.Eventstore
}
type args struct {
ctx context.Context
checks *SessionChecks
metadata map[string][]byte
}
type res struct {
want *SessionChanged
err error
}
tests := []struct {
name string
fields fields
args args
res res
}{
{
"terminated",
fields{
eventstore: eventstoreExpect(t),
},
args{
ctx: context.Background(),
checks: &SessionChecks{
sessionWriteModel: &SessionWriteModel{State: domain.SessionStateTerminated},
},
},
res{
err: caos_errs.ThrowPreconditionFailed(nil, "COMAND-SAjeh", "Errors.Session.Terminated"),
},
},
{
"check failed",
fields{
eventstore: eventstoreExpect(t),
},
args{
ctx: context.Background(),
checks: &SessionChecks{
sessionWriteModel: NewSessionWriteModel("sessionID", "org1"),
checks: []SessionCheck{
func(ctx context.Context, cmd *SessionChecks) error {
return caos_errs.ThrowInternal(nil, "id", "check failed")
},
},
},
},
res{
err: caos_errs.ThrowInternal(nil, "id", "check failed"),
},
},
{
"no change",
fields{
eventstore: eventstoreExpect(t),
},
args{
ctx: context.Background(),
checks: &SessionChecks{
sessionWriteModel: NewSessionWriteModel("sessionID", "org1"),
checks: []SessionCheck{},
},
},
res{
want: &SessionChanged{
ObjectDetails: &domain.ObjectDetails{
ResourceOwner: "org1",
},
ID: "sessionID",
NewToken: "",
},
},
},
{
"set user, password, metadata and token",
fields{
eventstore: eventstoreExpect(t,
expectPush(
eventPusherToEvents(
session.NewUserCheckedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate,
"userID", testNow),
session.NewPasswordCheckedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate,
testNow),
session.NewMetadataSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate,
map[string][]byte{"key": []byte("value")}),
session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate,
"tokenID"),
),
),
),
},
args{
ctx: context.Background(),
checks: &SessionChecks{
sessionWriteModel: NewSessionWriteModel("sessionID", "org1"),
checks: []SessionCheck{
CheckUser("userID"),
CheckPassword("password"),
},
eventstore: eventstoreExpect(t,
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
"username", "", "", "", "", language.English, domain.GenderUnspecified, "", false),
),
eventFromEventPusher(
user.NewHumanPasswordChangedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
&crypto.CryptoValue{
CryptoType: crypto.TypeHash,
Algorithm: "hash",
KeyID: "",
Crypted: []byte("password"),
}, false, ""),
),
),
),
createToken: func(sessionID string) (string, string, error) {
return "tokenID",
"token",
nil
},
userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)),
now: func() time.Time {
return testNow
},
},
metadata: map[string][]byte{
"key": []byte("value"),
},
},
res{
want: &SessionChanged{
ObjectDetails: &domain.ObjectDetails{
ResourceOwner: "org1",
},
ID: "sessionID",
NewToken: "token",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
eventstore: tt.fields.eventstore,
}
got, err := c.updateSession(tt.args.ctx, tt.args.checks, tt.args.metadata)
require.ErrorIs(t, err, tt.res.err)
assert.Equal(t, tt.res.want, got)
})
}
}
func TestCommands_TerminateSession(t *testing.T) {
type fields struct {
eventstore *eventstore.Eventstore
tokenVerifier func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error)
}
type args struct {
ctx context.Context
sessionID string
sessionToken string
}
type res struct {
want *domain.ObjectDetails
err error
}
tests := []struct {
name string
fields fields
args args
res res
}{
{
"eventstore failed",
fields{
eventstore: eventstoreExpect(t,
expectFilterError(caos_errs.ThrowInternal(nil, "id", "filter failed")),
),
},
args{
ctx: context.Background(),
},
res{
err: caos_errs.ThrowInternal(nil, "id", "filter failed"),
},
},
{
"invalid session token",
fields{
eventstore: eventstoreExpect(t,
expectFilter(
eventFromEventPusher(
session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)),
eventFromEventPusher(
session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate,
"tokenID")),
),
),
tokenVerifier: func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) {
return caos_errs.ThrowPermissionDenied(nil, "COMMAND-sGr42", "Errors.Session.Token.Invalid")
},
},
args{
ctx: context.Background(),
sessionID: "sessionID",
sessionToken: "invalid",
},
res{
err: caos_errs.ThrowPermissionDenied(nil, "COMMAND-sGr42", "Errors.Session.Token.Invalid"),
},
},
{
"not active",
fields{
eventstore: eventstoreExpect(t,
expectFilter(
eventFromEventPusher(
session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)),
eventFromEventPusher(
session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate,
"tokenID")),
eventFromEventPusher(
session.NewTerminateEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)),
),
),
tokenVerifier: func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) {
return nil
},
},
args{
ctx: context.Background(),
sessionID: "sessionID",
sessionToken: "token",
},
res{
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
},
},
{
"push failed",
fields{
eventstore: eventstoreExpect(t,
expectFilter(
eventFromEventPusher(
session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)),
eventFromEventPusher(
session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate,
"tokenID"),
),
),
expectPushFailed(
caos_errs.ThrowInternal(nil, "id", "pushed failed"),
eventPusherToEvents(
session.NewTerminateEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)),
),
),
tokenVerifier: func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) {
return nil
},
},
args{
ctx: context.Background(),
sessionID: "sessionID",
sessionToken: "token",
},
res{
err: caos_errs.ThrowInternal(nil, "id", "pushed failed"),
},
},
{
"terminate",
fields{
eventstore: eventstoreExpect(t,
expectFilter(
eventFromEventPusher(
session.NewAddedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)),
eventFromEventPusher(
session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate,
"tokenID"),
),
),
expectPush(
eventPusherToEvents(
session.NewTerminateEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate)),
),
),
tokenVerifier: func(ctx context.Context, sessionToken, sessionID, tokenID string) (err error) {
return nil
},
},
args{
ctx: context.Background(),
sessionID: "sessionID",
sessionToken: "token",
},
res{
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
eventstore: tt.fields.eventstore,
sessionTokenVerifier: tt.fields.tokenVerifier,
}
got, err := c.TerminateSession(tt.args.ctx, tt.args.sessionID, tt.args.sessionToken)
require.ErrorIs(t, err, tt.res.err)
assert.Equal(t, tt.res.want, got)
})
}
}

View File

@ -6,6 +6,7 @@ import (
"github.com/zitadel/logging" "github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors" caos_errs "github.com/zitadel/zitadel/internal/errors"
@ -42,7 +43,7 @@ func (c *Commands) ChangeUserEmailVerified(ctx context.Context, userID, resource
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err = c.checkPermission(ctx, permissionUserWrite, cmd.aggregate.ResourceOwner, userID, false); err != nil { if err = c.checkPermission(ctx, domain.PermissionUserWrite, cmd.aggregate.ResourceOwner, userID); err != nil {
return nil, err return nil, err
} }
if err = cmd.Change(ctx, domain.EmailAddress(email)); err != nil { if err = cmd.Change(ctx, domain.EmailAddress(email)); err != nil {
@ -70,8 +71,10 @@ func (c *Commands) changeUserEmailWithGenerator(ctx context.Context, userID, res
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err = c.checkPermission(ctx, permissionUserWrite, cmd.aggregate.ResourceOwner, userID, true); err != nil { if authz.GetCtxData(ctx).UserID != userID {
return nil, err if err = c.checkPermission(ctx, domain.PermissionUserWrite, cmd.aggregate.ResourceOwner, userID); err != nil {
return nil, err
}
} }
if err = cmd.Change(ctx, domain.EmailAddress(email)); err != nil { if err = cmd.Change(ctx, domain.EmailAddress(email)); err != nil {
return nil, err return nil, err

View File

@ -24,7 +24,7 @@ import (
func TestCommands_ChangeUserEmail(t *testing.T) { func TestCommands_ChangeUserEmail(t *testing.T) {
type fields struct { type fields struct {
eventstore *eventstore.Eventstore eventstore *eventstore.Eventstore
checkPermission permissionCheck checkPermission domain.PermissionCheck
} }
type args struct { type args struct {
userID string userID string
@ -174,7 +174,7 @@ func TestCommands_ChangeUserEmail(t *testing.T) {
func TestCommands_ChangeUserEmailURLTemplate(t *testing.T) { func TestCommands_ChangeUserEmailURLTemplate(t *testing.T) {
type fields struct { type fields struct {
eventstore *eventstore.Eventstore eventstore *eventstore.Eventstore
checkPermission permissionCheck checkPermission domain.PermissionCheck
} }
type args struct { type args struct {
userID string userID string
@ -300,7 +300,7 @@ func TestCommands_ChangeUserEmailURLTemplate(t *testing.T) {
func TestCommands_ChangeUserEmailReturnCode(t *testing.T) { func TestCommands_ChangeUserEmailReturnCode(t *testing.T) {
type fields struct { type fields struct {
eventstore *eventstore.Eventstore eventstore *eventstore.Eventstore
checkPermission permissionCheck checkPermission domain.PermissionCheck
} }
type args struct { type args struct {
userID string userID string
@ -410,7 +410,7 @@ func TestCommands_ChangeUserEmailReturnCode(t *testing.T) {
func TestCommands_ChangeUserEmailVerified(t *testing.T) { func TestCommands_ChangeUserEmailVerified(t *testing.T) {
type fields struct { type fields struct {
eventstore *eventstore.Eventstore eventstore *eventstore.Eventstore
checkPermission permissionCheck checkPermission domain.PermissionCheck
} }
type args struct { type args struct {
userID string userID string
@ -569,7 +569,7 @@ func TestCommands_ChangeUserEmailVerified(t *testing.T) {
func TestCommands_changeUserEmailWithGenerator(t *testing.T) { func TestCommands_changeUserEmailWithGenerator(t *testing.T) {
type fields struct { type fields struct {
eventstore *eventstore.Eventstore eventstore *eventstore.Eventstore
checkPermission permissionCheck checkPermission domain.PermissionCheck
} }
type args struct { type args struct {
userID string userID string

View File

@ -2,13 +2,14 @@ package database
import ( import (
"database/sql/driver" "database/sql/driver"
"encoding/json"
"github.com/jackc/pgtype" "github.com/jackc/pgtype"
) )
type StringArray []string type StringArray []string
// Scan implements the `database/sql.Scanner` interface. // Scan implements the [database/sql.Scanner] interface.
func (s *StringArray) Scan(src any) error { func (s *StringArray) Scan(src any) error {
array := new(pgtype.TextArray) array := new(pgtype.TextArray)
if err := array.Scan(src); err != nil { if err := array.Scan(src); err != nil {
@ -20,7 +21,7 @@ func (s *StringArray) Scan(src any) error {
return nil return nil
} }
// Value implements the `database/sql/driver.Valuer` interface. // Value implements the [database/sql/driver.Valuer] interface.
func (s StringArray) Value() (driver.Value, error) { func (s StringArray) Value() (driver.Value, error) {
if len(s) == 0 { if len(s) == 0 {
return nil, nil return nil, nil
@ -40,7 +41,7 @@ type enumField interface {
type EnumArray[F enumField] []F type EnumArray[F enumField] []F
// Scan implements the `database/sql.Scanner` interface. // Scan implements the [database/sql.Scanner] interface.
func (s *EnumArray[F]) Scan(src any) error { func (s *EnumArray[F]) Scan(src any) error {
array := new(pgtype.Int2Array) array := new(pgtype.Int2Array)
if err := array.Scan(src); err != nil { if err := array.Scan(src); err != nil {
@ -57,7 +58,7 @@ func (s *EnumArray[F]) Scan(src any) error {
return nil return nil
} }
// Value implements the `database/sql/driver.Valuer` interface. // Value implements the [database/sql/driver.Valuer] interface.
func (s EnumArray[F]) Value() (driver.Value, error) { func (s EnumArray[F]) Value() (driver.Value, error) {
if len(s) == 0 { if len(s) == 0 {
return nil, nil return nil, nil
@ -70,3 +71,25 @@ func (s EnumArray[F]) Value() (driver.Value, error) {
return array.Value() return array.Value()
} }
type Map[V any] map[string]V
// Scan implements the [database/sql.Scanner] interface.
func (m *Map[V]) Scan(src any) error {
bytea := new(pgtype.Bytea)
if err := bytea.Scan(src); err != nil {
return err
}
if len(bytea.Bytes) == 0 {
return nil
}
return json.Unmarshal(bytea.Bytes, &m)
}
// Value implements the [database/sql/driver.Valuer] interface.
func (m Map[V]) Value() (driver.Value, error) {
if len(m) == 0 {
return nil, nil
}
return json.Marshal(m)
}

View File

@ -0,0 +1,119 @@
package database
import (
"database/sql/driver"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMap_Scan(t *testing.T) {
type args struct {
src any
}
type res[V any] struct {
want Map[V]
err bool
}
type testCase[V any] struct {
name string
m Map[V]
args args
res[V]
}
tests := []testCase[string]{
{
"null",
Map[string]{},
args{src: "invalid"},
res[string]{
want: Map[string]{},
err: true,
},
},
{
"null",
Map[string]{},
args{src: nil},
res[string]{
want: Map[string]{},
},
},
{
"empty",
Map[string]{},
args{src: []byte(`{}`)},
res[string]{
want: Map[string]{},
},
},
{
"set",
Map[string]{},
args{src: []byte(`{"key": "value"}`)},
res[string]{
want: Map[string]{
"key": "value",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := tt.m.Scan(tt.args.src); (err != nil) != tt.res.err {
t.Errorf("Scan() error = %v, wantErr %v", err, tt.res.err)
}
assert.Equal(t, tt.res.want, tt.m)
})
}
}
func TestMap_Value(t *testing.T) {
type res struct {
want driver.Value
err bool
}
type testCase[V any] struct {
name string
m Map[V]
res res
}
tests := []testCase[string]{
{
"nil",
nil,
res{
want: nil,
},
},
{
"empty",
Map[string]{},
res{
want: nil,
},
},
{
"set",
Map[string]{
"key": "value",
},
res{
want: driver.Value([]byte(`{"key":"value"}`)),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.m.Value()
if tt.res.err {
assert.Error(t, err)
}
if !tt.res.err {
require.NoError(t, err)
assert.Equalf(t, tt.res.want, got, "Value()")
}
})
}
}

View File

@ -1,5 +1,7 @@
package domain package domain
import "context"
type Permissions struct { type Permissions struct {
Permissions []string Permissions []string
} }
@ -21,3 +23,12 @@ func (p *Permissions) appendPermission(ctxID, permission string) {
} }
p.Permissions = append(p.Permissions, permission) p.Permissions = append(p.Permissions, permission)
} }
type PermissionCheck func(ctx context.Context, permission, orgID, resourceID string) (err error)
const (
PermissionUserWrite = "user.write"
PermissionSessionRead = "session.read"
PermissionSessionWrite = "session.write"
PermissionSessionDelete = "session.delete"
)

View File

@ -0,0 +1,9 @@
package domain
type SessionState int32
const (
SessionStateUnspecified SessionState = iota
SessionStateActive
SessionStateTerminated
)

View File

@ -25,3 +25,9 @@ func ExpectID(t *testing.T, id string) *MockGenerator {
m.EXPECT().Next().Return(id, nil) m.EXPECT().Next().Return(id, nil)
return m return m
} }
func NewIDGeneratorExpectError(t *testing.T, err error) *MockGenerator {
m := NewMockGenerator(gomock.NewController(t))
m.EXPECT().Next().Return("", err)
return m
}

View File

@ -180,12 +180,20 @@ func (q *Queries) IsOrgUnique(ctx context.Context, name, domain string) (isUniqu
return scan(row) return scan(row)
} }
func (q *Queries) ExistsOrg(ctx context.Context, id string) (err error) { func (q *Queries) ExistsOrg(ctx context.Context, id, domain string) (verifiedID string, err error) {
ctx, span := tracing.NewSpan(ctx) ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }() defer func() { span.EndWithError(err) }()
_, err = q.OrgByID(ctx, true, id) var org *Org
return err if id != "" {
org, err = q.OrgByID(ctx, true, id)
} else {
org, err = q.OrgByVerifiedDomain(ctx, domain)
}
if err != nil {
return "", err
}
return org.ID, nil
} }
func (q *Queries) SearchOrgs(ctx context.Context, queries *OrgSearchQueries) (orgs *Orgs, err error) { func (q *Queries) SearchOrgs(ctx context.Context, queries *OrgSearchQueries) (orgs *Orgs, err error) {

View File

@ -65,6 +65,7 @@ var (
NotificationsProjection interface{} NotificationsProjection interface{}
NotificationsQuotaProjection interface{} NotificationsQuotaProjection interface{}
DeviceAuthProjection *deviceAuthProjection DeviceAuthProjection *deviceAuthProjection
SessionProjection *sessionProjection
) )
type projection interface { type projection interface {
@ -141,6 +142,7 @@ func Create(ctx context.Context, sqlClient *database.DB, es *eventstore.Eventsto
SecurityPolicyProjection = newSecurityPolicyProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["security_policies"])) SecurityPolicyProjection = newSecurityPolicyProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["security_policies"]))
NotificationPolicyProjection = newNotificationPolicyProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["notification_policies"])) NotificationPolicyProjection = newNotificationPolicyProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["notification_policies"]))
DeviceAuthProjection = newDeviceAuthProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["device_auth"])) DeviceAuthProjection = newDeviceAuthProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["device_auth"]))
SessionProjection = newSessionProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["sessions"]))
newProjectionsList() newProjectionsList()
return nil return nil
} }
@ -237,5 +239,6 @@ func newProjectionsList() {
SecurityPolicyProjection, SecurityPolicyProjection,
NotificationPolicyProjection, NotificationPolicyProjection,
DeviceAuthProjection, DeviceAuthProjection,
SessionProjection,
} }
} }

View File

@ -0,0 +1,221 @@
package projection
import (
"context"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/handler"
"github.com/zitadel/zitadel/internal/eventstore/handler/crdb"
"github.com/zitadel/zitadel/internal/repository/instance"
"github.com/zitadel/zitadel/internal/repository/session"
)
const (
SessionsProjectionTable = "projections.sessions"
SessionColumnID = "id"
SessionColumnCreationDate = "creation_date"
SessionColumnChangeDate = "change_date"
SessionColumnSequence = "sequence"
SessionColumnState = "state"
SessionColumnResourceOwner = "resource_owner"
SessionColumnInstanceID = "instance_id"
SessionColumnCreator = "creator"
SessionColumnUserID = "user_id"
SessionColumnUserCheckedAt = "user_checked_at"
SessionColumnPasswordCheckedAt = "password_checked_at"
SessionColumnMetadata = "metadata"
SessionColumnTokenID = "token_id"
)
type sessionProjection struct {
crdb.StatementHandler
}
func newSessionProjection(ctx context.Context, config crdb.StatementHandlerConfig) *sessionProjection {
p := new(sessionProjection)
config.ProjectionName = SessionsProjectionTable
config.Reducers = p.reducers()
config.InitCheck = crdb.NewMultiTableCheck(
crdb.NewTable([]*crdb.Column{
crdb.NewColumn(SessionColumnID, crdb.ColumnTypeText),
crdb.NewColumn(SessionColumnCreationDate, crdb.ColumnTypeTimestamp),
crdb.NewColumn(SessionColumnChangeDate, crdb.ColumnTypeTimestamp),
crdb.NewColumn(SessionColumnSequence, crdb.ColumnTypeInt64),
crdb.NewColumn(SessionColumnState, crdb.ColumnTypeEnum),
crdb.NewColumn(SessionColumnResourceOwner, crdb.ColumnTypeText),
crdb.NewColumn(SessionColumnInstanceID, crdb.ColumnTypeText),
crdb.NewColumn(SessionColumnCreator, crdb.ColumnTypeText),
crdb.NewColumn(SessionColumnUserID, crdb.ColumnTypeText, crdb.Nullable()),
crdb.NewColumn(SessionColumnUserCheckedAt, crdb.ColumnTypeTimestamp, crdb.Nullable()),
crdb.NewColumn(SessionColumnPasswordCheckedAt, crdb.ColumnTypeTimestamp, crdb.Nullable()),
crdb.NewColumn(SessionColumnMetadata, crdb.ColumnTypeJSONB, crdb.Nullable()),
crdb.NewColumn(SessionColumnTokenID, crdb.ColumnTypeText, crdb.Nullable()),
},
crdb.NewPrimaryKey(SessionColumnInstanceID, SessionColumnID),
),
)
p.StatementHandler = crdb.NewStatementHandler(ctx, config)
return p
}
func (p *sessionProjection) reducers() []handler.AggregateReducer {
return []handler.AggregateReducer{
{
Aggregate: session.AggregateType,
EventRedusers: []handler.EventReducer{
{
Event: session.AddedType,
Reduce: p.reduceSessionAdded,
},
{
Event: session.UserCheckedType,
Reduce: p.reduceUserChecked,
},
{
Event: session.PasswordCheckedType,
Reduce: p.reducePasswordChecked,
},
{
Event: session.TokenSetType,
Reduce: p.reduceTokenSet,
},
{
Event: session.MetadataSetType,
Reduce: p.reduceMetadataSet,
},
{
Event: session.TerminateType,
Reduce: p.reduceSessionTerminated,
},
},
},
{
Aggregate: instance.AggregateType,
EventRedusers: []handler.EventReducer{
{
Event: instance.InstanceRemovedEventType,
Reduce: reduceInstanceRemovedHelper(SMSColumnInstanceID),
},
},
},
}
}
func (p *sessionProjection) reduceSessionAdded(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*session.AddedEvent)
if !ok {
return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-Sfrgf", "reduce.wrong.event.type %s", session.AddedType)
}
return crdb.NewCreateStatement(
e,
[]handler.Column{
handler.NewCol(SessionColumnID, e.Aggregate().ID),
handler.NewCol(SessionColumnInstanceID, e.Aggregate().InstanceID),
handler.NewCol(SessionColumnCreationDate, e.CreationDate()),
handler.NewCol(SessionColumnChangeDate, e.CreationDate()),
handler.NewCol(SessionColumnResourceOwner, e.Aggregate().ResourceOwner),
handler.NewCol(SessionColumnState, domain.SessionStateActive),
handler.NewCol(SessionColumnSequence, e.Sequence()),
handler.NewCol(SessionColumnCreator, e.User),
},
), nil
}
func (p *sessionProjection) reduceUserChecked(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*session.UserCheckedEvent)
if !ok {
return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-saDg5", "reduce.wrong.event.type %s", session.UserCheckedType)
}
return crdb.NewUpdateStatement(
e,
[]handler.Column{
handler.NewCol(SessionColumnChangeDate, e.CreationDate()),
handler.NewCol(SessionColumnSequence, e.Sequence()),
handler.NewCol(SessionColumnUserID, e.UserID),
handler.NewCol(SessionColumnUserCheckedAt, e.CheckedAt),
},
[]handler.Condition{
handler.NewCond(SessionColumnID, e.Aggregate().ID),
handler.NewCond(SessionColumnInstanceID, e.Aggregate().InstanceID),
},
), nil
}
func (p *sessionProjection) reducePasswordChecked(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*session.PasswordCheckedEvent)
if !ok {
return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-SDgrb", "reduce.wrong.event.type %s", session.PasswordCheckedType)
}
return crdb.NewUpdateStatement(
e,
[]handler.Column{
handler.NewCol(SessionColumnChangeDate, e.CreationDate()),
handler.NewCol(SessionColumnSequence, e.Sequence()),
handler.NewCol(SessionColumnPasswordCheckedAt, e.CheckedAt),
},
[]handler.Condition{
handler.NewCond(SessionColumnID, e.Aggregate().ID),
handler.NewCond(SessionColumnInstanceID, e.Aggregate().InstanceID),
},
), nil
}
func (p *sessionProjection) reduceTokenSet(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*session.TokenSetEvent)
if !ok {
return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-SAfd3", "reduce.wrong.event.type %s", session.TokenSetType)
}
return crdb.NewUpdateStatement(
e,
[]handler.Column{
handler.NewCol(SessionColumnChangeDate, e.CreationDate()),
handler.NewCol(SessionColumnSequence, e.Sequence()),
handler.NewCol(SessionColumnTokenID, e.TokenID),
},
[]handler.Condition{
handler.NewCond(SessionColumnID, e.Aggregate().ID),
handler.NewCond(SessionColumnInstanceID, e.Aggregate().InstanceID),
},
), nil
}
func (p *sessionProjection) reduceMetadataSet(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*session.MetadataSetEvent)
if !ok {
return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-SAfd3", "reduce.wrong.event.type %s", session.MetadataSetType)
}
return crdb.NewUpdateStatement(
e,
[]handler.Column{
handler.NewCol(SessionColumnChangeDate, e.CreationDate()),
handler.NewCol(SessionColumnSequence, e.Sequence()),
handler.NewCol(SessionColumnMetadata, e.Metadata),
},
[]handler.Condition{
handler.NewCond(SessionColumnID, e.Aggregate().ID),
handler.NewCond(SessionColumnInstanceID, e.Aggregate().InstanceID),
},
), nil
}
func (p *sessionProjection) reduceSessionTerminated(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*session.TerminateEvent)
if !ok {
return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-SAftn", "reduce.wrong.event.type %s", session.TerminateType)
}
return crdb.NewDeleteStatement(
e,
[]handler.Condition{
handler.NewCond(SessionColumnID, e.Aggregate().ID),
handler.NewCond(SessionColumnInstanceID, e.Aggregate().InstanceID),
},
), nil
}

View File

@ -0,0 +1,260 @@
package projection
import (
"testing"
"time"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/handler"
"github.com/zitadel/zitadel/internal/eventstore/repository"
"github.com/zitadel/zitadel/internal/repository/instance"
"github.com/zitadel/zitadel/internal/repository/session"
)
func TestSessionProjection_reduces(t *testing.T) {
type args struct {
event func(t *testing.T) eventstore.Event
}
tests := []struct {
name string
args args
reduce func(event eventstore.Event) (*handler.Statement, error)
want wantReduce
}{
{
name: "instance reduceSessionAdded",
args: args{
event: getEvent(testEvent(
session.AddedType,
session.AggregateType,
[]byte(`{}`),
), session.AddedEventMapper),
},
reduce: (&sessionProjection{}).reduceSessionAdded,
want: wantReduce{
aggregateType: eventstore.AggregateType("session"),
sequence: 15,
previousSequence: 10,
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "INSERT INTO projections.sessions (id, instance_id, creation_date, change_date, resource_owner, state, sequence, creator) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
expectedArgs: []interface{}{
"agg-id",
"instance-id",
anyArg{},
anyArg{},
"ro-id",
domain.SessionStateActive,
uint64(15),
"editor-user",
},
},
},
},
},
},
{
name: "instance reduceUserChecked",
args: args{
event: getEvent(testEvent(
session.AddedType,
session.AggregateType,
[]byte(`{
"userId": "user-id",
"checkedAt": "2023-05-04T00:00:00Z"
}`),
), session.UserCheckedEventMapper),
},
reduce: (&sessionProjection{}).reduceUserChecked,
want: wantReduce{
aggregateType: eventstore.AggregateType("session"),
sequence: 15,
previousSequence: 10,
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "UPDATE projections.sessions SET (change_date, sequence, user_id, user_checked_at) = ($1, $2, $3, $4) WHERE (id = $5) AND (instance_id = $6)",
expectedArgs: []interface{}{
anyArg{},
anyArg{},
"user-id",
time.Date(2023, time.May, 4, 0, 0, 0, 0, time.UTC),
"agg-id",
"instance-id",
},
},
},
},
},
},
{
name: "instance reducePasswordChecked",
args: args{
event: getEvent(testEvent(
session.AddedType,
session.AggregateType,
[]byte(`{
"checkedAt": "2023-05-04T00:00:00Z"
}`),
), session.PasswordCheckedEventMapper),
},
reduce: (&sessionProjection{}).reducePasswordChecked,
want: wantReduce{
aggregateType: eventstore.AggregateType("session"),
sequence: 15,
previousSequence: 10,
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "UPDATE projections.sessions SET (change_date, sequence, password_checked_at) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)",
expectedArgs: []interface{}{
anyArg{},
anyArg{},
time.Date(2023, time.May, 4, 0, 0, 0, 0, time.UTC),
"agg-id",
"instance-id",
},
},
},
},
},
},
{
name: "instance reduceTokenSet",
args: args{
event: getEvent(testEvent(
session.TokenSetType,
session.AggregateType,
[]byte(`{
"tokenID": "tokenID"
}`),
), session.TokenSetEventMapper),
},
reduce: (&sessionProjection{}).reduceTokenSet,
want: wantReduce{
aggregateType: eventstore.AggregateType("session"),
sequence: 15,
previousSequence: 10,
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "UPDATE projections.sessions SET (change_date, sequence, token_id) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)",
expectedArgs: []interface{}{
anyArg{},
anyArg{},
"tokenID",
"agg-id",
"instance-id",
},
},
},
},
},
},
{
name: "instance reduceMetadataSet",
args: args{
event: getEvent(testEvent(
session.MetadataSetType,
session.AggregateType,
[]byte(`{
"metadata": {
"key": "dmFsdWU="
}
}`),
), session.MetadataSetEventMapper),
},
reduce: (&sessionProjection{}).reduceMetadataSet,
want: wantReduce{
aggregateType: eventstore.AggregateType("session"),
sequence: 15,
previousSequence: 10,
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "UPDATE projections.sessions SET (change_date, sequence, metadata) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)",
expectedArgs: []interface{}{
anyArg{},
anyArg{},
map[string][]byte{
"key": []byte("value"),
},
"agg-id",
"instance-id",
},
},
},
},
},
},
{
name: "instance reduceSessionTerminated",
args: args{
event: getEvent(testEvent(
session.TerminateType,
session.AggregateType,
[]byte(`{}`),
), session.TerminateEventMapper),
},
reduce: (&sessionProjection{}).reduceSessionTerminated,
want: wantReduce{
aggregateType: eventstore.AggregateType("session"),
sequence: 15,
previousSequence: 10,
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "DELETE FROM projections.sessions WHERE (id = $1) AND (instance_id = $2)",
expectedArgs: []interface{}{
"agg-id",
"instance-id",
},
},
},
},
},
},
{
name: "instance reduceInstanceRemoved",
args: args{
event: getEvent(testEvent(
repository.EventType(instance.InstanceRemovedEventType),
instance.AggregateType,
nil,
), instance.InstanceRemovedEventMapper),
},
reduce: reduceInstanceRemovedHelper(SessionColumnInstanceID),
want: wantReduce{
aggregateType: eventstore.AggregateType("instance"),
sequence: 15,
previousSequence: 10,
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "DELETE FROM projections.sessions WHERE (instance_id = $1)",
expectedArgs: []interface{}{
"agg-id",
},
},
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
event := baseEvent(t)
got, err := tt.reduce(event)
if !errors.IsErrorInvalidArgument(err) {
t.Errorf("no wrong event mapping: %v, got: %v", err, got)
}
event = tt.args.event(t)
got, err = tt.reduce(event)
assertReduce(t, got, err, SessionsProjectionTable, tt.want)
})
}
}

View File

@ -22,6 +22,7 @@ import (
"github.com/zitadel/zitadel/internal/repository/keypair" "github.com/zitadel/zitadel/internal/repository/keypair"
"github.com/zitadel/zitadel/internal/repository/org" "github.com/zitadel/zitadel/internal/repository/org"
"github.com/zitadel/zitadel/internal/repository/project" "github.com/zitadel/zitadel/internal/repository/project"
"github.com/zitadel/zitadel/internal/repository/session"
usr_repo "github.com/zitadel/zitadel/internal/repository/user" usr_repo "github.com/zitadel/zitadel/internal/repository/user"
"github.com/zitadel/zitadel/internal/repository/usergrant" "github.com/zitadel/zitadel/internal/repository/usergrant"
) )
@ -30,7 +31,8 @@ type Queries struct {
eventstore *eventstore.Eventstore eventstore *eventstore.Eventstore
client *database.DB client *database.DB
idpConfigEncryption crypto.EncryptionAlgorithm idpConfigEncryption crypto.EncryptionAlgorithm
sessionTokenVerifier func(ctx context.Context, sessionToken string, sessionID string, tokenID string) (err error)
DefaultLanguage language.Tag DefaultLanguage language.Tag
LoginDir http.FileSystem LoginDir http.FileSystem
@ -43,7 +45,16 @@ type Queries struct {
multifactors domain.MultifactorConfigs multifactors domain.MultifactorConfigs
} }
func StartQueries(ctx context.Context, es *eventstore.Eventstore, sqlClient *database.DB, projections projection.Config, defaults sd.SystemDefaults, idpConfigEncryption, otpEncryption, keyEncryptionAlgorithm crypto.EncryptionAlgorithm, certEncryptionAlgorithm crypto.EncryptionAlgorithm, zitadelRoles []authz.RoleMapping) (repo *Queries, err error) { func StartQueries(
ctx context.Context,
es *eventstore.Eventstore,
sqlClient *database.DB,
projections projection.Config,
defaults sd.SystemDefaults,
idpConfigEncryption, otpEncryption, keyEncryptionAlgorithm, certEncryptionAlgorithm crypto.EncryptionAlgorithm,
zitadelRoles []authz.RoleMapping,
sessionTokenVerifier func(ctx context.Context, sessionToken string, sessionID string, tokenID string) (err error),
) (repo *Queries, err error) {
statikLoginFS, err := fs.NewWithNamespace("login") statikLoginFS, err := fs.NewWithNamespace("login")
if err != nil { if err != nil {
return nil, fmt.Errorf("unable to start login statik dir") return nil, fmt.Errorf("unable to start login statik dir")
@ -63,6 +74,7 @@ func StartQueries(ctx context.Context, es *eventstore.Eventstore, sqlClient *dat
LoginTranslationFileContents: make(map[string][]byte), LoginTranslationFileContents: make(map[string][]byte),
NotificationTranslationFileContents: make(map[string][]byte), NotificationTranslationFileContents: make(map[string][]byte),
zitadelRoles: zitadelRoles, zitadelRoles: zitadelRoles,
sessionTokenVerifier: sessionTokenVerifier,
} }
iam_repo.RegisterEventMappers(repo.eventstore) iam_repo.RegisterEventMappers(repo.eventstore)
usr_repo.RegisterEventMappers(repo.eventstore) usr_repo.RegisterEventMappers(repo.eventstore)
@ -71,6 +83,7 @@ func StartQueries(ctx context.Context, es *eventstore.Eventstore, sqlClient *dat
action.RegisterEventMappers(repo.eventstore) action.RegisterEventMappers(repo.eventstore)
keypair.RegisterEventMappers(repo.eventstore) keypair.RegisterEventMappers(repo.eventstore)
usergrant.RegisterEventMappers(repo.eventstore) usergrant.RegisterEventMappers(repo.eventstore)
session.RegisterEventMappers(repo.eventstore)
repo.idpConfigEncryption = idpConfigEncryption repo.idpConfigEncryption = idpConfigEncryption
repo.multifactors = domain.MultifactorConfigs{ repo.multifactors = domain.MultifactorConfigs{

320
internal/query/session.go Normal file
View File

@ -0,0 +1,320 @@
package query
import (
"context"
"database/sql"
errs "errors"
"time"
sq "github.com/Masterminds/squirrel"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/call"
"github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/query/projection"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
)
type Sessions struct {
SearchResponse
Sessions []*Session
}
type Session struct {
ID string
CreationDate time.Time
ChangeDate time.Time
Sequence uint64
State domain.SessionState
ResourceOwner string
Creator string
UserFactor SessionUserFactor
PasswordFactor SessionPasswordFactor
Metadata map[string][]byte
}
type SessionUserFactor struct {
UserID string
UserCheckedAt time.Time
LoginName string
DisplayName string
}
type SessionPasswordFactor struct {
PasswordCheckedAt time.Time
}
type SessionsSearchQueries struct {
SearchRequest
Queries []SearchQuery
}
func (q *SessionsSearchQueries) toQuery(query sq.SelectBuilder) sq.SelectBuilder {
query = q.SearchRequest.toQuery(query)
for _, q := range q.Queries {
query = q.toQuery(query)
}
return query
}
var (
sessionsTable = table{
name: projection.SessionsProjectionTable,
instanceIDCol: projection.SessionColumnInstanceID,
}
SessionColumnID = Column{
name: projection.SessionColumnID,
table: sessionsTable,
}
SessionColumnCreationDate = Column{
name: projection.SessionColumnCreationDate,
table: sessionsTable,
}
SessionColumnChangeDate = Column{
name: projection.SessionColumnChangeDate,
table: sessionsTable,
}
SessionColumnSequence = Column{
name: projection.SessionColumnSequence,
table: sessionsTable,
}
SessionColumnState = Column{
name: projection.SessionColumnState,
table: sessionsTable,
}
SessionColumnResourceOwner = Column{
name: projection.SessionColumnResourceOwner,
table: sessionsTable,
}
SessionColumnInstanceID = Column{
name: projection.SessionColumnInstanceID,
table: sessionsTable,
}
SessionColumnCreator = Column{
name: projection.SessionColumnCreator,
table: sessionsTable,
}
SessionColumnUserID = Column{
name: projection.SessionColumnUserID,
table: sessionsTable,
}
SessionColumnUserCheckedAt = Column{
name: projection.SessionColumnUserCheckedAt,
table: sessionsTable,
}
SessionColumnPasswordCheckedAt = Column{
name: projection.SessionColumnPasswordCheckedAt,
table: sessionsTable,
}
SessionColumnMetadata = Column{
name: projection.SessionColumnMetadata,
table: sessionsTable,
}
SessionColumnToken = Column{
name: projection.SessionColumnTokenID,
table: sessionsTable,
}
)
func (q *Queries) SessionByID(ctx context.Context, id, sessionToken string) (_ *Session, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
query, scan := prepareSessionQuery(ctx, q.client)
stmt, args, err := query.Where(
sq.Eq{
SessionColumnID.identifier(): id,
SessionColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(),
},
).ToSql()
if err != nil {
return nil, errors.ThrowInternal(err, "QUERY-dn9JW", "Errors.Query.SQLStatement")
}
row := q.client.QueryRowContext(ctx, stmt, args...)
session, tokenID, err := scan(row)
if err != nil {
return nil, err
}
if sessionToken == "" {
return session, nil
}
if err := q.sessionTokenVerifier(ctx, sessionToken, session.ID, tokenID); err != nil {
return nil, errors.ThrowPermissionDenied(nil, "QUERY-dsfr3", "Errors.PermissionDenied")
}
return session, nil
}
func (q *Queries) SearchSessions(ctx context.Context, queries *SessionsSearchQueries) (_ *Sessions, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
query, scan := prepareSessionsQuery(ctx, q.client)
stmt, args, err := queries.toQuery(query).
Where(sq.Eq{
SessionColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(),
}).ToSql()
if err != nil {
return nil, errors.ThrowInvalidArgument(err, "QUERY-sn9Jf", "Errors.Query.InvalidRequest")
}
rows, err := q.client.QueryContext(ctx, stmt, args...)
if err != nil || rows.Err() != nil {
return nil, errors.ThrowInternal(err, "QUERY-Sfg42", "Errors.Internal")
}
sessions, err := scan(rows)
if err != nil {
return nil, err
}
sessions.LatestSequence, err = q.latestSequence(ctx, sessionsTable)
return sessions, err
}
func NewSessionIDsSearchQuery(ids []string) (SearchQuery, error) {
list := make([]interface{}, len(ids))
for i, value := range ids {
list[i] = value
}
return NewListQuery(SessionColumnID, list, ListIn)
}
func NewSessionCreatorSearchQuery(creator string) (SearchQuery, error) {
return NewTextQuery(SessionColumnCreator, creator, TextEquals)
}
func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (*Session, string, error)) {
return sq.Select(
SessionColumnID.identifier(),
SessionColumnCreationDate.identifier(),
SessionColumnChangeDate.identifier(),
SessionColumnSequence.identifier(),
SessionColumnState.identifier(),
SessionColumnResourceOwner.identifier(),
SessionColumnCreator.identifier(),
SessionColumnUserID.identifier(),
SessionColumnUserCheckedAt.identifier(),
LoginNameNameCol.identifier(),
HumanDisplayNameCol.identifier(),
SessionColumnPasswordCheckedAt.identifier(),
SessionColumnMetadata.identifier(),
SessionColumnToken.identifier(),
).From(sessionsTable.identifier()).
LeftJoin(join(LoginNameUserIDCol, SessionColumnUserID)).
LeftJoin(join(HumanUserIDCol, SessionColumnUserID) + db.Timetravel(call.Took(ctx))).
PlaceholderFormat(sq.Dollar), func(row *sql.Row) (*Session, string, error) {
session := new(Session)
var (
userID sql.NullString
userCheckedAt sql.NullTime
loginName sql.NullString
displayName sql.NullString
passwordCheckedAt sql.NullTime
metadata database.Map[[]byte]
token sql.NullString
)
err := row.Scan(
&session.ID,
&session.CreationDate,
&session.ChangeDate,
&session.Sequence,
&session.State,
&session.ResourceOwner,
&session.Creator,
&userID,
&userCheckedAt,
&loginName,
&displayName,
&passwordCheckedAt,
&metadata,
&token,
)
if err != nil {
if errs.Is(err, sql.ErrNoRows) {
return nil, "", errors.ThrowNotFound(err, "QUERY-SFeaa", "Errors.Session.NotExisting")
}
return nil, "", errors.ThrowInternal(err, "QUERY-SAder", "Errors.Internal")
}
session.UserFactor.UserID = userID.String
session.UserFactor.UserCheckedAt = userCheckedAt.Time
session.UserFactor.LoginName = loginName.String
session.UserFactor.DisplayName = displayName.String
session.PasswordFactor.PasswordCheckedAt = passwordCheckedAt.Time
session.Metadata = metadata
return session, token.String, nil
}
}
func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Rows) (*Sessions, error)) {
return sq.Select(
SessionColumnID.identifier(),
SessionColumnCreationDate.identifier(),
SessionColumnChangeDate.identifier(),
SessionColumnSequence.identifier(),
SessionColumnState.identifier(),
SessionColumnResourceOwner.identifier(),
SessionColumnCreator.identifier(),
SessionColumnUserID.identifier(),
SessionColumnUserCheckedAt.identifier(),
LoginNameNameCol.identifier(),
HumanDisplayNameCol.identifier(),
SessionColumnPasswordCheckedAt.identifier(),
SessionColumnMetadata.identifier(),
countColumn.identifier(),
).From(sessionsTable.identifier()).
LeftJoin(join(LoginNameUserIDCol, SessionColumnUserID)).
LeftJoin(join(HumanUserIDCol, SessionColumnUserID) + db.Timetravel(call.Took(ctx))).
PlaceholderFormat(sq.Dollar), func(rows *sql.Rows) (*Sessions, error) {
sessions := &Sessions{Sessions: []*Session{}}
for rows.Next() {
session := new(Session)
var (
userID sql.NullString
userCheckedAt sql.NullTime
loginName sql.NullString
displayName sql.NullString
passwordCheckedAt sql.NullTime
metadata database.Map[[]byte]
)
err := rows.Scan(
&session.ID,
&session.CreationDate,
&session.ChangeDate,
&session.Sequence,
&session.State,
&session.ResourceOwner,
&session.Creator,
&userID,
&userCheckedAt,
&loginName,
&displayName,
&passwordCheckedAt,
&metadata,
&sessions.Count,
)
if err != nil {
return nil, errors.ThrowInternal(err, "QUERY-SAfeg", "Errors.Internal")
}
session.UserFactor.UserID = userID.String
session.UserFactor.UserCheckedAt = userCheckedAt.Time
session.UserFactor.LoginName = loginName.String
session.UserFactor.DisplayName = displayName.String
session.PasswordFactor.PasswordCheckedAt = passwordCheckedAt.Time
session.Metadata = metadata
sessions.Sessions = append(sessions.Sessions, session)
}
return sessions, nil
}
}

View File

@ -0,0 +1,396 @@
package query
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"fmt"
"regexp"
"testing"
sq "github.com/Masterminds/squirrel"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/internal/domain"
errs "github.com/zitadel/zitadel/internal/errors"
)
var (
expectedSessionQuery = regexp.QuoteMeta(`SELECT projections.sessions.id,` +
` projections.sessions.creation_date,` +
` projections.sessions.change_date,` +
` projections.sessions.sequence,` +
` projections.sessions.state,` +
` projections.sessions.resource_owner,` +
` projections.sessions.creator,` +
` projections.sessions.user_id,` +
` projections.sessions.user_checked_at,` +
` projections.login_names2.login_name,` +
` projections.users8_humans.display_name,` +
` projections.sessions.password_checked_at,` +
` projections.sessions.metadata,` +
` projections.sessions.token_id` +
` FROM projections.sessions` +
` LEFT JOIN projections.login_names2 ON projections.sessions.user_id = projections.login_names2.user_id AND projections.sessions.instance_id = projections.login_names2.instance_id` +
` LEFT JOIN projections.users8_humans ON projections.sessions.user_id = projections.users8_humans.user_id AND projections.sessions.instance_id = projections.users8_humans.instance_id` +
` AS OF SYSTEM TIME '-1 ms'`)
expectedSessionsQuery = regexp.QuoteMeta(`SELECT projections.sessions.id,` +
` projections.sessions.creation_date,` +
` projections.sessions.change_date,` +
` projections.sessions.sequence,` +
` projections.sessions.state,` +
` projections.sessions.resource_owner,` +
` projections.sessions.creator,` +
` projections.sessions.user_id,` +
` projections.sessions.user_checked_at,` +
` projections.login_names2.login_name,` +
` projections.users8_humans.display_name,` +
` projections.sessions.password_checked_at,` +
` projections.sessions.metadata,` +
` COUNT(*) OVER ()` +
` FROM projections.sessions` +
` LEFT JOIN projections.login_names2 ON projections.sessions.user_id = projections.login_names2.user_id AND projections.sessions.instance_id = projections.login_names2.instance_id` +
` LEFT JOIN projections.users8_humans ON projections.sessions.user_id = projections.users8_humans.user_id AND projections.sessions.instance_id = projections.users8_humans.instance_id` +
` AS OF SYSTEM TIME '-1 ms'`)
sessionCols = []string{
"id",
"creation_date",
"change_date",
"sequence",
"state",
"resource_owner",
"creator",
"user_id",
"user_checked_at",
"login_name",
"display_name",
"password_checked_at",
"metadata",
"token",
}
sessionsCols = []string{
"id",
"creation_date",
"change_date",
"sequence",
"state",
"resource_owner",
"creator",
"user_id",
"user_checked_at",
"login_name",
"display_name",
"password_checked_at",
"metadata",
"count",
}
)
func Test_SessionsPrepare(t *testing.T) {
type want struct {
sqlExpectations sqlExpectation
err checkErr
}
tests := []struct {
name string
prepare interface{}
want want
object interface{}
}{
{
name: "prepareSessionsQuery no result",
prepare: prepareSessionsQuery,
want: want{
sqlExpectations: mockQueries(
expectedSessionsQuery,
nil,
nil,
),
},
object: &Sessions{Sessions: []*Session{}},
},
{
name: "prepareSessionQuery",
prepare: prepareSessionsQuery,
want: want{
sqlExpectations: mockQueries(
expectedSessionsQuery,
sessionsCols,
[][]driver.Value{
{
"session-id",
testNow,
testNow,
uint64(20211109),
domain.SessionStateActive,
"ro",
"creator",
"user-id",
testNow,
"login-name",
"display-name",
testNow,
[]byte(`{"key": "dmFsdWU="}`),
},
},
),
},
object: &Sessions{
SearchResponse: SearchResponse{
Count: 1,
},
Sessions: []*Session{
{
ID: "session-id",
CreationDate: testNow,
ChangeDate: testNow,
Sequence: 20211109,
State: domain.SessionStateActive,
ResourceOwner: "ro",
Creator: "creator",
UserFactor: SessionUserFactor{
UserID: "user-id",
UserCheckedAt: testNow,
LoginName: "login-name",
DisplayName: "display-name",
},
PasswordFactor: SessionPasswordFactor{
PasswordCheckedAt: testNow,
},
Metadata: map[string][]byte{
"key": []byte("value"),
},
},
},
},
},
{
name: "prepareSessionsQuery multiple result",
prepare: prepareSessionsQuery,
want: want{
sqlExpectations: mockQueries(
expectedSessionsQuery,
sessionsCols,
[][]driver.Value{
{
"session-id",
testNow,
testNow,
uint64(20211109),
domain.SessionStateActive,
"ro",
"creator",
"user-id",
testNow,
"login-name",
"display-name",
testNow,
[]byte(`{"key": "dmFsdWU="}`),
},
{
"session-id2",
testNow,
testNow,
uint64(20211109),
domain.SessionStateActive,
"ro",
"creator2",
"user-id2",
testNow,
"login-name2",
"display-name2",
testNow,
[]byte(`{"key": "dmFsdWU="}`),
},
},
),
},
object: &Sessions{
SearchResponse: SearchResponse{
Count: 2,
},
Sessions: []*Session{
{
ID: "session-id",
CreationDate: testNow,
ChangeDate: testNow,
Sequence: 20211109,
State: domain.SessionStateActive,
ResourceOwner: "ro",
Creator: "creator",
UserFactor: SessionUserFactor{
UserID: "user-id",
UserCheckedAt: testNow,
LoginName: "login-name",
DisplayName: "display-name",
},
PasswordFactor: SessionPasswordFactor{
PasswordCheckedAt: testNow,
},
Metadata: map[string][]byte{
"key": []byte("value"),
},
},
{
ID: "session-id2",
CreationDate: testNow,
ChangeDate: testNow,
Sequence: 20211109,
State: domain.SessionStateActive,
ResourceOwner: "ro",
Creator: "creator2",
UserFactor: SessionUserFactor{
UserID: "user-id2",
UserCheckedAt: testNow,
LoginName: "login-name2",
DisplayName: "display-name2",
},
PasswordFactor: SessionPasswordFactor{
PasswordCheckedAt: testNow,
},
Metadata: map[string][]byte{
"key": []byte("value"),
},
},
},
},
},
{
name: "prepareSessionsQuery sql err",
prepare: prepareSessionsQuery,
want: want{
sqlExpectations: mockQueryErr(
expectedSessionsQuery,
sql.ErrConnDone,
),
err: func(err error) (error, bool) {
if !errors.Is(err, sql.ErrConnDone) {
return fmt.Errorf("err should be sql.ErrConnDone got: %w", err), false
}
return nil, true
},
},
object: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assertPrepare(t, tt.prepare, tt.object, tt.want.sqlExpectations, tt.want.err, defaultPrepareArgs...)
})
}
}
func Test_SessionPrepare(t *testing.T) {
type want struct {
sqlExpectations sqlExpectation
err checkErr
}
tests := []struct {
name string
prepare interface{}
want want
object interface{}
}{
{
name: "prepareSessionQuery no result",
prepare: prepareSessionQueryTesting(t, ""),
want: want{
sqlExpectations: mockQueries(
expectedSessionQuery,
nil,
nil,
),
err: func(err error) (error, bool) {
if !errs.IsNotFound(err) {
return fmt.Errorf("err should be zitadel.NotFoundError got: %w", err), false
}
return nil, true
},
},
object: (*Session)(nil),
},
{
name: "prepareSessionQuery found",
prepare: prepareSessionQueryTesting(t, "tokenID"),
want: want{
sqlExpectations: mockQuery(
expectedSessionQuery,
sessionCols,
[]driver.Value{
"session-id",
testNow,
testNow,
uint64(20211109),
domain.SessionStateActive,
"ro",
"creator",
"user-id",
testNow,
"login-name",
"display-name",
testNow,
[]byte(`{"key": "dmFsdWU="}`),
"tokenID",
},
),
},
object: &Session{
ID: "session-id",
CreationDate: testNow,
ChangeDate: testNow,
Sequence: 20211109,
State: domain.SessionStateActive,
ResourceOwner: "ro",
Creator: "creator",
UserFactor: SessionUserFactor{
UserID: "user-id",
UserCheckedAt: testNow,
LoginName: "login-name",
DisplayName: "display-name",
},
PasswordFactor: SessionPasswordFactor{
PasswordCheckedAt: testNow,
},
Metadata: map[string][]byte{
"key": []byte("value"),
},
},
},
{
name: "prepareSessionQuery sql err",
prepare: prepareSessionQueryTesting(t, ""),
want: want{
sqlExpectations: mockQueryErr(
expectedSessionQuery,
sql.ErrConnDone,
),
err: func(err error) (error, bool) {
if !errors.Is(err, sql.ErrConnDone) {
return fmt.Errorf("err should be sql.ErrConnDone got: %w", err), false
}
return nil, true
},
},
object: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assertPrepare(t, tt.prepare, tt.object, tt.want.sqlExpectations, tt.want.err, defaultPrepareArgs...)
})
}
}
func prepareSessionQueryTesting(t *testing.T, token string) func(context.Context, prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (*Session, error)) {
return func(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Row) (*Session, error)) {
builder, scan := prepareSessionQuery(ctx, db)
return builder, func(row *sql.Row) (*Session, error) {
session, tokenID, err := scan(row)
require.Equal(t, tokenID, token)
return session, err
}
}
}

View File

@ -0,0 +1,25 @@
package session
import (
"github.com/zitadel/zitadel/internal/eventstore"
)
const (
AggregateType = "session"
AggregateVersion = "v1"
)
type Aggregate struct {
eventstore.Aggregate
}
func NewAggregate(id, resourceOwner string) *Aggregate {
return &Aggregate{
Aggregate: eventstore.Aggregate{
Type: AggregateType,
Version: AggregateVersion,
ID: id,
ResourceOwner: resourceOwner,
},
}
}

View File

@ -0,0 +1,12 @@
package session
import "github.com/zitadel/zitadel/internal/eventstore"
func RegisterEventMappers(es *eventstore.Eventstore) {
es.RegisterFilterEventMapper(AggregateType, AddedType, AddedEventMapper).
RegisterFilterEventMapper(AggregateType, UserCheckedType, UserCheckedEventMapper).
RegisterFilterEventMapper(AggregateType, PasswordCheckedType, PasswordCheckedEventMapper).
RegisterFilterEventMapper(AggregateType, TokenSetType, TokenSetEventMapper).
RegisterFilterEventMapper(AggregateType, MetadataSetType, MetadataSetEventMapper).
RegisterFilterEventMapper(AggregateType, TerminateType, TerminateEventMapper)
}

View File

@ -0,0 +1,255 @@
package session
import (
"context"
"encoding/json"
"time"
"github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/repository"
)
const (
sessionEventPrefix = "session."
AddedType = sessionEventPrefix + "added"
UserCheckedType = sessionEventPrefix + "user.checked"
PasswordCheckedType = sessionEventPrefix + "password.checked"
TokenSetType = sessionEventPrefix + "token.set"
MetadataSetType = sessionEventPrefix + "metadata.set"
TerminateType = sessionEventPrefix + "terminated"
)
type AddedEvent struct {
eventstore.BaseEvent `json:"-"`
}
func (e *AddedEvent) Data() interface{} {
return e
}
func (e *AddedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
return nil
}
func NewAddedEvent(ctx context.Context,
aggregate *eventstore.Aggregate,
) *AddedEvent {
return &AddedEvent{
BaseEvent: *eventstore.NewBaseEventForPush(
ctx,
aggregate,
AddedType,
),
}
}
func AddedEventMapper(event *repository.Event) (eventstore.Event, error) {
added := &AddedEvent{
BaseEvent: *eventstore.BaseEventFromRepo(event),
}
err := json.Unmarshal(event.Data, added)
if err != nil {
return nil, errors.ThrowInternal(err, "SESSION-DG4gn", "unable to unmarshal session added")
}
return added, nil
}
type UserCheckedEvent struct {
eventstore.BaseEvent `json:"-"`
UserID string `json:"userID"`
CheckedAt time.Time `json:"checkedAt"`
}
func (e *UserCheckedEvent) Data() interface{} {
return e
}
func (e *UserCheckedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
return nil
}
func NewUserCheckedEvent(
ctx context.Context,
aggregate *eventstore.Aggregate,
userID string,
checkedAt time.Time,
) *UserCheckedEvent {
return &UserCheckedEvent{
BaseEvent: *eventstore.NewBaseEventForPush(
ctx,
aggregate,
UserCheckedType,
),
UserID: userID,
CheckedAt: checkedAt,
}
}
func UserCheckedEventMapper(event *repository.Event) (eventstore.Event, error) {
added := &UserCheckedEvent{
BaseEvent: *eventstore.BaseEventFromRepo(event),
}
err := json.Unmarshal(event.Data, added)
if err != nil {
return nil, errors.ThrowInternal(err, "SESSION-DSGn5", "unable to unmarshal user checked")
}
return added, nil
}
type PasswordCheckedEvent struct {
eventstore.BaseEvent `json:"-"`
CheckedAt time.Time `json:"checkedAt"`
}
func (e *PasswordCheckedEvent) Data() interface{} {
return e
}
func (e *PasswordCheckedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
return nil
}
func NewPasswordCheckedEvent(
ctx context.Context,
aggregate *eventstore.Aggregate,
checkedAt time.Time,
) *PasswordCheckedEvent {
return &PasswordCheckedEvent{
BaseEvent: *eventstore.NewBaseEventForPush(
ctx,
aggregate,
PasswordCheckedType,
),
CheckedAt: checkedAt,
}
}
func PasswordCheckedEventMapper(event *repository.Event) (eventstore.Event, error) {
added := &PasswordCheckedEvent{
BaseEvent: *eventstore.BaseEventFromRepo(event),
}
err := json.Unmarshal(event.Data, added)
if err != nil {
return nil, errors.ThrowInternal(err, "SESSION-DGt21", "unable to unmarshal password checked")
}
return added, nil
}
type TokenSetEvent struct {
eventstore.BaseEvent `json:"-"`
TokenID string `json:"tokenID"`
}
func (e *TokenSetEvent) Data() interface{} {
return e
}
func (e *TokenSetEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
return nil
}
func NewTokenSetEvent(
ctx context.Context,
aggregate *eventstore.Aggregate,
tokenID string,
) *TokenSetEvent {
return &TokenSetEvent{
BaseEvent: *eventstore.NewBaseEventForPush(
ctx,
aggregate,
TokenSetType,
),
TokenID: tokenID,
}
}
func TokenSetEventMapper(event *repository.Event) (eventstore.Event, error) {
added := &TokenSetEvent{
BaseEvent: *eventstore.BaseEventFromRepo(event),
}
err := json.Unmarshal(event.Data, added)
if err != nil {
return nil, errors.ThrowInternal(err, "SESSION-Sf3va", "unable to unmarshal token set")
}
return added, nil
}
type MetadataSetEvent struct {
eventstore.BaseEvent `json:"-"`
Metadata map[string][]byte `json:"metadata"`
}
func (e *MetadataSetEvent) Data() interface{} {
return e
}
func (e *MetadataSetEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
return nil
}
func NewMetadataSetEvent(
ctx context.Context,
aggregate *eventstore.Aggregate,
metadata map[string][]byte,
) *MetadataSetEvent {
return &MetadataSetEvent{
BaseEvent: *eventstore.NewBaseEventForPush(
ctx,
aggregate,
MetadataSetType,
),
Metadata: metadata,
}
}
func MetadataSetEventMapper(event *repository.Event) (eventstore.Event, error) {
added := &MetadataSetEvent{
BaseEvent: *eventstore.BaseEventFromRepo(event),
}
err := json.Unmarshal(event.Data, added)
if err != nil {
return nil, errors.ThrowInternal(err, "SESSION-BD21d", "unable to unmarshal metadata set")
}
return added, nil
}
type TerminateEvent struct {
eventstore.BaseEvent `json:"-"`
}
func (e *TerminateEvent) Data() interface{} {
return e
}
func (e *TerminateEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
return nil
}
func NewTerminateEvent(
ctx context.Context,
aggregate *eventstore.Aggregate,
) *TerminateEvent {
return &TerminateEvent{
BaseEvent: *eventstore.NewBaseEventForPush(
ctx,
aggregate,
TerminateType,
),
}
}
func TerminateEventMapper(event *repository.Event) (eventstore.Event, error) {
return &TerminateEvent{
BaseEvent: *eventstore.BaseEventFromRepo(event),
}, nil
}

View File

@ -470,6 +470,11 @@ Errors:
Execution: Execution:
StorageFailed: Das Speichern des Action Logs in der Datenbank ist fehlgeschlagen StorageFailed: Das Speichern des Action Logs in der Datenbank ist fehlgeschlagen
ScanFailed: Das Abfragen der verbrauchten Actions Sekunden ist fehlgeschlagen ScanFailed: Das Abfragen der verbrauchten Actions Sekunden ist fehlgeschlagen
Session:
NotExisting: Session existiert nicht
Terminated: Session bereits beendet
Token:
Invalid: Session Token ist ungültig
AggregateTypes: AggregateTypes:
action: Action action: Action

View File

@ -470,6 +470,11 @@ Errors:
Execution: Execution:
StorageFailed: Storing action execution log to database failed StorageFailed: Storing action execution log to database failed
ScanFailed: Querying usage for action execution seconds failed ScanFailed: Querying usage for action execution seconds failed
Session:
NotExisting: Session does not exist
Terminated: Session already terminated
Token:
Invalid: Session Token is invalid
AggregateTypes: AggregateTypes:
action: Action action: Action

View File

@ -470,6 +470,11 @@ Errors:
Execution: Execution:
StorageFailed: Ha fallado el almacenaje del registro de ejecución de acciones en la base de datos StorageFailed: Ha fallado el almacenaje del registro de ejecución de acciones en la base de datos
ScanFailed: La consulta de uso de los segundos de ejecuciónde acciones ha fallado ScanFailed: La consulta de uso de los segundos de ejecuciónde acciones ha fallado
Session:
NotExisting: La sesión no existe
Terminated: Sesión ya terminada
Token:
Invalid: El identificador de sesión no es válido
AggregateTypes: AggregateTypes:
action: Acción action: Acción

View File

@ -470,6 +470,11 @@ Errors:
Execution: Execution:
StorageFailed: L'enregistrement du journal d'action dans la base de données a échoué StorageFailed: L'enregistrement du journal d'action dans la base de données a échoué
ScanFailed: L'interrogation des secondes d'action consommées a échoué ScanFailed: L'interrogation des secondes d'action consommées a échoué
Session:
NotExisting: La session n'existe pas
Terminated: La session est déjà terminée
Token:
Invalid: Le jeton de session n'est pas valide
AggregateTypes: AggregateTypes:
action: Action action: Action

View File

@ -470,6 +470,11 @@ Errors:
Execution: Execution:
StorageFailed: Il salvataggio del registro delle azioni nel database non è riuscito StorageFailed: Il salvataggio del registro delle azioni nel database non è riuscito
ScanFailed: La query dei secondi delle azioni utilizzate non è riuscita ScanFailed: La query dei secondi delle azioni utilizzate non è riuscita
Session:
NotExisting: La sessione non esiste
Terminated: Sessione già terminata
Token:
Invalid: Il token della sessione non è valido
AggregateTypes: AggregateTypes:
action: Azione action: Azione

View File

@ -459,6 +459,11 @@ Errors:
Execution: Execution:
StorageFailed: アクション実行ログのデータベースへの保存に失敗しました StorageFailed: アクション実行ログのデータベースへの保存に失敗しました
ScanFailed: アクション実行時間を取得する使用状況クエリに失敗しました ScanFailed: アクション実行時間を取得する使用状況クエリに失敗しました
Session:
NotExisting: セッションが存在しない
Terminated: セッションはすでに終了しています
Token:
Invalid: セッショントークンが無効です
AggregateTypes: AggregateTypes:
action: アクション action: アクション

View File

@ -470,6 +470,11 @@ Errors:
Execution: Execution:
StorageFailed: Zapisywanie dziennika wykonania akcji do bazy danych nie powiodło się StorageFailed: Zapisywanie dziennika wykonania akcji do bazy danych nie powiodło się
ScanFailed: Zapytanie o użycie dla sekund wykonania akcji nie powiodło się ScanFailed: Zapytanie o użycie dla sekund wykonania akcji nie powiodło się
Session:
NotExisting: Sesja nie istnieje
Terminated: Sesja już zakończona
Token:
Invalid: Token sesji jest nieprawidłowy
AggregateTypes: AggregateTypes:
action: Działanie action: Działanie

View File

@ -470,6 +470,11 @@ Errors:
Execution: Execution:
StorageFailed: 将行动执行日志存储到数据库失败 StorageFailed: 将行动执行日志存储到数据库失败
ScanFailed: Q查询动作执行秒数的使用情况失败 ScanFailed: Q查询动作执行秒数的使用情况失败
Session:
NotExisting: 会话不存在
Terminated: 会话已经终止
Token:
Invalid: 会话令牌是无效的
AggregateTypes: AggregateTypes:
action: 动作 action: 动作

View File

@ -1,5 +0,0 @@
package user
func (r *AddHumanUserRequest) AuthContext() string {
return r.GetOrganisation().GetOrgId()
}

View File

@ -11,9 +11,35 @@ import "validate/validate.proto";
message Organisation { message Organisation {
oneof org { oneof org {
string org_id = 1; string org_id = 1;
string org_domain = 2;
} }
} }
message ListQuery {
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = {
json_schema: {
title: "General List Query"
description: "Object unspecific list filters like offset, limit and asc/desc."
}
};
uint64 offset = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"0\"";
}
];
uint32 limit = 2 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "100";
description: "Maximum amount of events returned. The default is set to 1000 in https://github.com/zitadel/zitadel/blob/new-eventstore/cmd/zitadel/startup.yaml. If the limit exceeds the maximum configured ZITADEL will throw an error. If no limit is present the default is taken.";
}
];
bool asc = 3 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "default is descending"
}
];
}
message Details { message Details {
//sequence represents the order of events. It's always counting //sequence represents the order of events. It's always counting
// //
@ -38,3 +64,21 @@ message Details {
} }
]; ];
} }
message ListDetails {
uint64 total_result = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"2\"";
}
];
uint64 processed_sequence = 2 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"267831\"";
}
];
google.protobuf.Timestamp timestamp = 3 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "the last time the projection got updated"
}
];
}

View File

@ -2,11 +2,90 @@ syntax = "proto3";
package zitadel.session.v2alpha; package zitadel.session.v2alpha;
import "zitadel/user/v2alpha/user.proto"; import "google/api/field_behavior.proto";
import "google/protobuf/timestamp.proto";
import "protoc-gen-openapiv2/options/annotations.proto";
import "validate/validate.proto";
option go_package = "github.com/zitadel/zitadel/pkg/grpc/session/v2alpha;session"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/session/v2alpha;session";
message Session { message Session {
string id = 1; string id = 1 [
zitadel.user.v2alpha.User user = 2; (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "\"id of the session\"";
}
];
google.protobuf.Timestamp creation_date = 2 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "\"time when the session was created\"";
}
];
google.protobuf.Timestamp change_date = 3 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "\"time when the session was last updated\"";
}
];
uint64 sequence = 4 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "\"sequence of the session\"";
}
];
Factors factors = 5 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "\"checked factors of the session, e.g. the user, password and more\"";
}
];
map<string, bytes> metadata = 6 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "\"custom key value list\"";
}
];
}
message Factors {
UserFactor user = 1;
PasswordFactor password = 2;
}
message UserFactor {
google.protobuf.Timestamp verified_at = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "\"time when the user was last checked\"";
}
];
string id = 2 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "\"id of the checked user\"";
}
];
string login_name = 3 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "\"login name of the checked user\"";
}
];
string display_name = 4 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "\"display name of the checked user\"";
}
];
}
message PasswordFactor {
google.protobuf.Timestamp verified_at = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "\"time when the password was last checked\"";
}
];
}
message SearchQuery {
oneof query {
option (validate.required) = true;
IDsQuery ids_query = 1;
}
}
message IDsQuery {
repeated string ids = 1;
} }

View File

@ -3,21 +3,82 @@ syntax = "proto3";
package zitadel.session.v2alpha; package zitadel.session.v2alpha;
import "zitadel/object/v2alpha/object.proto";
import "zitadel/protoc_gen_zitadel/v2/options.proto"; import "zitadel/protoc_gen_zitadel/v2/options.proto";
import "zitadel/session/v2alpha/session.proto"; import "zitadel/session/v2alpha/session.proto";
import "google/api/annotations.proto"; import "google/api/annotations.proto";
import "google/api/field_behavior.proto";
import "protoc-gen-openapiv2/options/annotations.proto";
import "validate/validate.proto"; import "validate/validate.proto";
option go_package = "github.com/zitadel/zitadel/pkg/grpc/session/v2alpha;session"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/session/v2alpha;session";
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = {
info: {
title: "Session Service";
version: "2.0-alpha";
description: "This API is intended to manage sessions in a ZITADEL instance. This project is in alpha state. It can AND will continue breaking until the services provide the same functionality as the current login.";
contact:{
name: "ZITADEL"
url: "https://zitadel.com"
email: "hi@zitadel.com"
}
license: {
name: "Apache 2.0",
url: "https://github.com/zitadel/zitadel/blob/main/LICENSE";
};
};
schemes: HTTPS;
schemes: HTTP;
consumes: "application/json";
consumes: "application/grpc";
produces: "application/json";
produces: "application/grpc";
consumes: "application/grpc-web+proto";
produces: "application/grpc-web+proto";
host: "$ZITADEL_DOMAIN";
base_path: "/";
external_docs: {
description: "Detailed information about ZITADEL",
url: "https://zitadel.com/docs"
}
responses: {
key: "403";
value: {
description: "Returned when the user does not have permission to access the resource.";
schema: {
json_schema: {
ref: "#/definitions/rpcStatus";
}
}
}
}
responses: {
key: "404";
value: {
description: "Returned when the resource does not exist.";
schema: {
json_schema: {
ref: "#/definitions/rpcStatus";
}
}
}
}
};
service SessionService { service SessionService {
// GetSession is to demonstrate an authenticated request, where the authenticated user (usage of another grpc package) is returned // Search sessions
// rpc ListSessions (ListSessionsRequest) returns (ListSessionsResponse) {
// this request is subject to change and currently used for demonstration only
rpc GetSession (GetSessionRequest) returns (GetSessionResponse) {
option (google.api.http) = { option (google.api.http) = {
get: "/v2alpha/sessions/{id}" post: "/v2alpha/sessions/_search"
body: "*"
}; };
option (zitadel.protoc_gen_zitadel.v2.options) = { option (zitadel.protoc_gen_zitadel.v2.options) = {
@ -25,12 +86,280 @@ service SessionService {
permission: "authenticated" permission: "authenticated"
} }
}; };
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
summary: "Search sessions";
description: "Search for sessions"
responses: {
key: "200"
value: {
description: "OK";
}
};
responses: {
key: "400";
value: {
description: "invalid list query";
schema: {
json_schema: {
ref: "#/definitions/rpcStatus";
};
};
};
};
};
}
// GetSession a session
rpc GetSession (GetSessionRequest) returns (GetSessionResponse) {
option (google.api.http) = {
get: "/v2alpha/sessions/{session_id}"
};
option (zitadel.protoc_gen_zitadel.v2.options) = {
auth_option: {
permission: "authenticated"
}
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
summary: "Get a session";
description: "Get a session and all its information like the time of the user or password verification"
responses: {
key: "200"
value: {
description: "OK";
}
};
};
}
// Create a new session
rpc CreateSession (CreateSessionRequest) returns (CreateSessionResponse) {
option (google.api.http) = {
post: "/v2alpha/sessions"
body: "*"
};
option (zitadel.protoc_gen_zitadel.v2.options) = {
auth_option: {
permission: "authenticated"
}
http_response: {
success_code: 201
}
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
summary: "Create a new session";
description: "Create a new session. A token will be returned, which is required for further updates of the session."
responses: {
key: "200"
value: {
description: "OK";
}
};
};
}
// Update a session
rpc SetSession (SetSessionRequest) returns (SetSessionResponse) {
option (google.api.http) = {
patch: "/v2alpha/sessions/{session_id}"
body: "*"
};
option (zitadel.protoc_gen_zitadel.v2.options) = {
auth_option: {
permission: "authenticated"
}
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
summary: "Update an existing session";
description: "Update an existing session with new information."
responses: {
key: "200"
value: {
description: "OK";
}
};
};
}
// Terminate a session
rpc DeleteSession (DeleteSessionRequest) returns (DeleteSessionResponse) {
option (google.api.http) = {
delete: "/v2alpha/sessions/{session_id}"
body: "*"
};
option (zitadel.protoc_gen_zitadel.v2.options) = {
auth_option: {
permission: "authenticated"
}
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
summary: "Terminate an existing session";
description: "Terminate your own session or if granted any other session."
responses: {
key: "200"
value: {
description: "OK";
}
};
};
} }
} }
message ListSessionsRequest{
zitadel.object.v2alpha.ListQuery query = 1;
repeated SearchQuery queries = 2;
}
message ListSessionsResponse{
zitadel.object.v2alpha.ListDetails details = 1;
repeated Session sessions = 2;
}
message GetSessionRequest{ message GetSessionRequest{
string id = 1; string session_id = 1;
optional string session_token = 2;
} }
message GetSessionResponse{ message GetSessionResponse{
Session session = 1; Session session = 1;
} }
message CreateSessionRequest{
Checks checks = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "\"Check for user and password. Successful checks will be stated as factors on the session.\"";
}
];
map<string, bytes> metadata = 2 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "\"custom key value list to be stored on the session\"";
}
];
}
message CreateSessionResponse{
zitadel.object.v2alpha.Details details = 1;
string session_id = 2 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "\"id of the session\"";
example: "\"d654e6ba-70a3-48ef-a95d-37c8d8a7901a\"";
}
];
string session_token = 3 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "\"token of the session, which is required for further updates of the session or the request other resources\"";
}
];
}
message SetSessionRequest{
string session_id = 1 [
(validate.rules).string = {min_len: 1, max_len: 200},
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
min_length: 1;
max_length: 200;
description: "\"id of the session to update\"";
example: "\"d654e6ba-70a3-48ef-a95d-37c8d8a7901a\"";
}
];
string session_token = 2 [
(validate.rules).string = {min_len: 1, max_len: 200},
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
min_length: 1;
max_length: 200;
description: "\"token of the session, previously returned on the create / update request\"";
}
];
Checks checks = 3[
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "\"Check for user and password. Successful checks will be stated as factors on the session.\"";
}
];
map<string, bytes> metadata = 4 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "\"custom key value list to be stored on the session\"";
}
];
}
message SetSessionResponse{
zitadel.object.v2alpha.Details details = 1;
string session_token = 2 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "\"token of the session, which is required for further updates of the session or the request other resources\"";
}
];
}
message DeleteSessionRequest{
string session_id = 1 [
(validate.rules).string = {min_len: 1, max_len: 200},
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
min_length: 1;
max_length: 200;
description: "\"id of the session to terminate\"";
example: "\"d654e6ba-70a3-48ef-a95d-37c8d8a7901a\"";
}
];
optional string session_token = 2 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "\"token of the session, previously returned on the create / update request\"";
}
];
}
message DeleteSessionResponse{
zitadel.object.v2alpha.Details details = 1;
}
message Checks {
optional CheckUser user = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "\"checks the user and updates the session on success\"";
}
];
optional CheckPassword password = 2 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "\"Checks the password and updates the session on success. Requires that the user is already checked, either in the previous or the same request.\"";
}
];
}
message CheckUser {
oneof search {
string user_id = 1 [
(validate.rules).string = {min_len: 1, max_len: 200},
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
min_length: 1;
max_length: 200;
example: "\"d654e6ba-70a3-48ef-a95d-37c8d8a7901a\"";
}
];
string login_name = 2 [
(validate.rules).string = {min_len: 1, max_len: 200},
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
min_length: 1;
max_length: 200;
example: "\"mini@mouse.com\"";
}
];
}
}
message CheckPassword {
string password = 1 [
(validate.rules).string = {min_len: 1, max_len: 200},
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
min_length: 1;
max_length: 200;
example: "\"V3ryS3cure!\"";
}
];
}