From 8e158d52bb24e62a90c28b98209b10c0bdd446b5 Mon Sep 17 00:00:00 2001 From: Ralf Haferkamp Date: Thu, 29 Aug 2024 10:48:25 +0200 Subject: [PATCH] graph(oidc): Consume UserSignedIn events in graph service Pass them to the identity backend to update the last sign-in date of the user. --- services/graph/.mockery.yaml | 6 ++- services/graph/pkg/identity/backend.go | 2 + services/graph/pkg/identity/cs3.go | 6 +++ services/graph/pkg/identity/ldap.go | 34 +++++++++++++ .../pkg/identity/ldap_education_school.go | 2 - services/graph/pkg/identity/mocks/backend.go | 50 +++++++++++++++++++ services/graph/pkg/server/http/server.go | 8 +-- services/graph/pkg/service/v0/graph.go | 7 +-- services/graph/pkg/service/v0/option.go | 17 +++++++ services/graph/pkg/service/v0/service.go | 38 ++++++++++++++ 10 files changed, 158 insertions(+), 12 deletions(-) diff --git a/services/graph/.mockery.yaml b/services/graph/.mockery.yaml index 36e5b4b87..692fa6b12 100644 --- a/services/graph/.mockery.yaml +++ b/services/graph/.mockery.yaml @@ -12,8 +12,12 @@ packages: DriveItemPermissionsProvider: HTTPClient: Permissions: - Publisher: RoleService: + github.com/cs3org/reva/v2/pkg/events: + config: + dir: "mocks" + interfaces: + Publisher: github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool: config: dir: "mocks" diff --git a/services/graph/pkg/identity/backend.go b/services/graph/pkg/identity/backend.go index adfc6185b..ce52bbdcc 100644 --- a/services/graph/pkg/identity/backend.go +++ b/services/graph/pkg/identity/backend.go @@ -3,6 +3,7 @@ package identity import ( "context" "net/url" + "time" "github.com/CiscoM31/godata" cs3group "github.com/cs3org/go-cs3apis/cs3/identity/group/v1beta1" @@ -36,6 +37,7 @@ type Backend interface { UpdateUser(ctx context.Context, nameOrID string, user libregraph.UserUpdate) (*libregraph.User, error) GetUser(ctx context.Context, nameOrID string, oreq *godata.GoDataRequest) (*libregraph.User, error) GetUsers(ctx context.Context, oreq *godata.GoDataRequest) ([]*libregraph.User, error) + UpdateLastSignInDate(ctx context.Context, userID string, timestamp time.Time) error // CreateGroup creates the supplied group in the identity backend. CreateGroup(ctx context.Context, group libregraph.Group) (*libregraph.Group, error) diff --git a/services/graph/pkg/identity/cs3.go b/services/graph/pkg/identity/cs3.go index cf3c5ac66..51532b5cb 100644 --- a/services/graph/pkg/identity/cs3.go +++ b/services/graph/pkg/identity/cs3.go @@ -3,6 +3,7 @@ package identity import ( "context" "net/url" + "time" "github.com/CiscoM31/godata" gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" @@ -111,6 +112,11 @@ func (i *CS3) GetUsers(ctx context.Context, oreq *godata.GoDataRequest) ([]*libr return users, nil } +// UpdateLastSignInDate implements the Backend Interface. It's currently not supported for the CS3 backend +func (i *CS3) UpdateLastSignInDate(ctx context.Context, userID string, timestamp time.Time) error { + return errNotImplemented +} + // GetGroups implements the Backend Interface. func (i *CS3) GetGroups(ctx context.Context, oreq *godata.GoDataRequest) ([]*libregraph.Group, error) { logger := i.Logger.SubloggerWithRequestID(ctx) diff --git a/services/graph/pkg/identity/ldap.go b/services/graph/pkg/identity/ldap.go index 6c73981b3..848816f24 100644 --- a/services/graph/pkg/identity/ldap.go +++ b/services/graph/pkg/identity/ldap.go @@ -8,6 +8,7 @@ import ( "slices" "strconv" "strings" + "time" "github.com/CiscoM31/godata" "github.com/go-ldap/ldap/v3" @@ -24,6 +25,7 @@ const ( _givenNameAttribute = "givenname" _surNameAttribute = "sn" _identitiesAttribute = "oCExternalIdentity" + ldapDateFormat = "20060102150405Z0700" ) // DisableUserMechanismType is used instead of directly using the string values from the configuration. @@ -670,6 +672,38 @@ func (i *LDAP) GetUsers(ctx context.Context, oreq *godata.GoDataRequest) ([]*lib return users, nil } +// UpdateLastSignInDate implements the Backend Interface. +func (i *LDAP) UpdateLastSignInDate(ctx context.Context, userID string, timestamp time.Time) error { + if !i.writeEnabled { + i.logger.Debug().Str("backend", "ldap").Msg("The LDAP Server is readonly. Skipping update of last sign in date") + return nil + } + e, err := i.getLDAPUserByID(userID) + switch { + case errors.Is(err, ErrNotFound): + i.logger.Warn().Err(err).Str("userID", userID).Msg("Failed to update last sign in date for user") + return nil + case err != nil: + return err + } + + mr := ldap.ModifyRequest{DN: e.DN} + mr.Replace("oCLastSignInTimestamp", []string{timestamp.UTC().Format(ldapDateFormat)}) + if err := i.conn.Modify(&mr); err != nil { + msg := "error updating last sign in date for user" + i.logger.Error().Err(err).Str("userid", userID).Msg(msg) + errMap := ldapResultToErrMap{ + ldap.LDAPResultNoSuchObject: errorcode.New(errorcode.ItemNotFound, msg), + ldap.LDAPResultUnwillingToPerform: errorcode.New(errorcode.NotAllowed, msg), + ldap.LDAPResultInsufficientAccessRights: errorcode.New(errorcode.NotAllowed, msg), + ldapGenericErr: errorcode.New(errorcode.GeneralException, msg), + } + return i.mapLDAPError(err, errMap) + } + + return nil +} + func (i *LDAP) changeUserName(ctx context.Context, dn, originalUserName, newUserName string) (*ldap.Entry, error) { logger := i.logger.SubloggerWithRequestID(ctx) diff --git a/services/graph/pkg/identity/ldap_education_school.go b/services/graph/pkg/identity/ldap_education_school.go index fbd846570..fe0ef5d13 100644 --- a/services/graph/pkg/identity/ldap_education_school.go +++ b/services/graph/pkg/identity/ldap_education_school.go @@ -47,8 +47,6 @@ const ( schoolPropertiesUpdated ) -const ldapDateFormat = "20060102150405Z0700" - var ( errNotSet = errors.New("attribute not set") errSchoolNameExists = errorcode.New(errorcode.NameAlreadyExists, "A school with that name is already present") diff --git a/services/graph/pkg/identity/mocks/backend.go b/services/graph/pkg/identity/mocks/backend.go index 33012ab31..35ce4cabd 100644 --- a/services/graph/pkg/identity/mocks/backend.go +++ b/services/graph/pkg/identity/mocks/backend.go @@ -11,6 +11,8 @@ import ( mock "github.com/stretchr/testify/mock" + time "time" + url "net/url" ) @@ -681,6 +683,54 @@ func (_c *Backend_UpdateGroupName_Call) RunAndReturn(run func(context.Context, s return _c } +// UpdateLastSignInDate provides a mock function with given fields: ctx, userID, timestamp +func (_m *Backend) UpdateLastSignInDate(ctx context.Context, userID string, timestamp time.Time) error { + ret := _m.Called(ctx, userID, timestamp) + + if len(ret) == 0 { + panic("no return value specified for UpdateLastSignInDate") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, time.Time) error); ok { + r0 = rf(ctx, userID, timestamp) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Backend_UpdateLastSignInDate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateLastSignInDate' +type Backend_UpdateLastSignInDate_Call struct { + *mock.Call +} + +// UpdateLastSignInDate is a helper method to define mock.On call +// - ctx context.Context +// - userID string +// - timestamp time.Time +func (_e *Backend_Expecter) UpdateLastSignInDate(ctx interface{}, userID interface{}, timestamp interface{}) *Backend_UpdateLastSignInDate_Call { + return &Backend_UpdateLastSignInDate_Call{Call: _e.mock.On("UpdateLastSignInDate", ctx, userID, timestamp)} +} + +func (_c *Backend_UpdateLastSignInDate_Call) Run(run func(ctx context.Context, userID string, timestamp time.Time)) *Backend_UpdateLastSignInDate_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(time.Time)) + }) + return _c +} + +func (_c *Backend_UpdateLastSignInDate_Call) Return(_a0 error) *Backend_UpdateLastSignInDate_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Backend_UpdateLastSignInDate_Call) RunAndReturn(run func(context.Context, string, time.Time) error) *Backend_UpdateLastSignInDate_Call { + _c.Call.Return(run) + return _c +} + // UpdateUser provides a mock function with given fields: ctx, nameOrID, user func (_m *Backend) UpdateUser(ctx context.Context, nameOrID string, user libregraph.UserUpdate) (*libregraph.User, error) { ret := _m.Called(ctx, nameOrID, user) diff --git a/services/graph/pkg/server/http/server.go b/services/graph/pkg/server/http/server.go index 133ecaae3..060070569 100644 --- a/services/graph/pkg/server/http/server.go +++ b/services/graph/pkg/server/http/server.go @@ -49,11 +49,11 @@ func Server(opts ...Option) (http.Service, error) { return http.Service{}, fmt.Errorf("could not initialize http service: %w", err) } - var publisher events.Stream + var eventsStream events.Stream if options.Config.Events.Endpoint != "" { var err error - publisher, err = stream.NatsFromConfig(options.Config.Service.Name, false, stream.NatsConfig(options.Config.Events)) + eventsStream, err = stream.NatsFromConfig(options.Config.Service.Name, false, stream.NatsConfig(options.Config.Events)) if err != nil { options.Logger.Error(). Err(err). @@ -130,10 +130,12 @@ func Server(opts ...Option) (http.Service, error) { var handle svc.Service handle, err = svc.NewService( + svc.Context(options.Context), svc.Logger(options.Logger), svc.Config(options.Config), svc.Middleware(middlewares...), - svc.EventsPublisher(publisher), + svc.EventsPublisher(eventsStream), + svc.EventsConsumer(eventsStream), svc.WithRoleService(roleService), svc.WithValueService(valueService), svc.WithRequireAdminMiddleware(requireAdminMiddleware), diff --git a/services/graph/pkg/service/v0/graph.go b/services/graph/pkg/service/v0/graph.go index 10248d150..e8a6bc24c 100644 --- a/services/graph/pkg/service/v0/graph.go +++ b/services/graph/pkg/service/v0/graph.go @@ -13,7 +13,6 @@ import ( "github.com/go-chi/chi/v5" "github.com/jellydator/ttlcache/v3" "go-micro.dev/v4/client" - mevents "go-micro.dev/v4/events" "go.opentelemetry.io/otel/trace" "google.golang.org/protobuf/types/known/emptypb" @@ -28,11 +27,6 @@ import ( "github.com/owncloud/ocis/v2/services/graph/pkg/identity" ) -// Publisher is the interface for events publisher -type Publisher interface { - Publish(string, interface{}, ...mevents.PublishOption) error -} - // Permissions is the interface used to access the permissions service type Permissions interface { ListPermissions(ctx context.Context, req *settingssvc.ListPermissionsRequest, opts ...client.CallOption) (*settingssvc.ListPermissionsResponse, error) @@ -68,6 +62,7 @@ type Graph struct { valueService settingssvc.ValueService specialDriveItemsCache *ttlcache.Cache[string, interface{}] eventsPublisher events.Publisher + eventsConsumer events.Consumer searchService searchsvc.SearchProviderService keycloakClient keycloak.Client historyClient ehsvc.EventHistoryService diff --git a/services/graph/pkg/service/v0/option.go b/services/graph/pkg/service/v0/option.go index f68633b48..e6560fb1a 100644 --- a/services/graph/pkg/service/v0/option.go +++ b/services/graph/pkg/service/v0/option.go @@ -1,6 +1,7 @@ package svc import ( + "context" "net/http" gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" @@ -22,6 +23,7 @@ type Option func(o *Options) // Options defines the available options for this package. type Options struct { + Context context.Context Logger log.Logger Config *config.Config Middleware []func(http.Handler) http.Handler @@ -34,6 +36,7 @@ type Options struct { ValueService settingssvc.ValueService RoleManager *roles.Manager EventsPublisher events.Publisher + EventsConsumer events.Consumer SearchService searchsvc.SearchProviderService KeycloakClient keycloak.Client EventHistoryClient ehsvc.EventHistoryService @@ -51,6 +54,13 @@ func newOptions(opts ...Option) Options { return opt } +// Context provides a function to set the context option. +func Context(ctx context.Context) Option { + return func(o *Options) { + o.Context = ctx + } +} + // Logger provides a function to set the logger option. func Logger(val log.Logger) Option { return func(o *Options) { @@ -142,6 +152,13 @@ func EventsPublisher(val events.Publisher) Option { } } +// EventsConsumer provides a function to set the EventsConsumer option. +func EventsConsumer(val events.Consumer) Option { + return func(o *Options) { + o.EventsConsumer = val + } +} + // KeycloakClient provides a function to set the KeycloakCient option. func KeycloakClient(val keycloak.Client) Option { return func(o *Options) { diff --git a/services/graph/pkg/service/v0/service.go b/services/graph/pkg/service/v0/service.go index 2d566f21e..1209d42c8 100644 --- a/services/graph/pkg/service/v0/service.go +++ b/services/graph/pkg/service/v0/service.go @@ -1,6 +1,7 @@ package svc import ( + "context" "crypto/tls" "crypto/x509" "errors" @@ -16,10 +17,13 @@ import ( "github.com/jellydator/ttlcache/v3" microstore "go-micro.dev/v4/store" + "github.com/cs3org/reva/v2/pkg/events" "github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool" "github.com/cs3org/reva/v2/pkg/store" + "github.com/cs3org/reva/v2/pkg/utils" ocisldap "github.com/owncloud/ocis/v2/ocis-pkg/ldap" + "github.com/owncloud/ocis/v2/ocis-pkg/log" "github.com/owncloud/ocis/v2/ocis-pkg/registry" "github.com/owncloud/ocis/v2/ocis-pkg/roles" "github.com/owncloud/ocis/v2/ocis-pkg/service/grpc" @@ -148,6 +152,7 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx mux: m, specialDriveItemsCache: spacePropertiesCache, eventsPublisher: options.EventsPublisher, + eventsConsumer: options.EventsConsumer, searchService: options.SearchService, identityEducationBackend: options.IdentityEducationBackend, keycloakClient: options.KeycloakClient, @@ -515,6 +520,39 @@ func setIdentityBackends(options Options, svc *Graph) error { svc.identityBackend = options.IdentityBackend } + return svc.StartListenForLogonEvents(options.Context, options.Logger) +} + +func (g *Graph) StartListenForLogonEvents(ctx context.Context, l log.Logger) error { + if g.eventsConsumer == nil { + return nil + } + var _registeredEvents = []events.Unmarshaller{ + events.UserSignedIn{}, + } + evChannel, err := events.Consume(g.eventsConsumer, "graph", _registeredEvents...) + if err != nil { + l.Error().Err(err).Msg("cannot consume from nats") + return err + } + go func() { + for loop := true; loop; { + select { + case e := <-evChannel: + switch ev := e.Event.(type) { + default: + l.Error().Interface("event", e).Msg("unhandled event") + case events.UserSignedIn: + if err := g.identityBackend.UpdateLastSignInDate(ctx, ev.Executant.OpaqueId, utils.TSToTime(ev.Timestamp)); err != nil { + l.Error().Err(err).Str("userid", ev.Executant.OpaqueId).Msg("Error updating last sign in date") + } + } + case <-ctx.Done(): + l.Info().Msg("context cancelled") + loop = false + } + } + }() return nil }