package svc import ( "context" "crypto/tls" "crypto/x509" "errors" "fmt" "net/http" "net/url" "os" "strconv" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" ldapv3 "github.com/go-ldap/ldap/v3" "github.com/jellydator/ttlcache/v3" "github.com/qsfera/server/services/graph/pkg/identity/cache" "github.com/riandyrn/otelchi" microstore "go-micro.dev/v4/store" "github.com/opencloud-eu/reva/v2/pkg/events" "github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool" "github.com/opencloud-eu/reva/v2/pkg/store" "github.com/opencloud-eu/reva/v2/pkg/utils" "github.com/opencloud-eu/reva/v2/pkg/utils/ldap" ocldap "github.com/qsfera/server/pkg/ldap" "github.com/qsfera/server/pkg/log" "github.com/qsfera/server/pkg/registry" "github.com/qsfera/server/pkg/roles" "github.com/qsfera/server/pkg/service/grpc" "github.com/qsfera/server/pkg/tracing" settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0" "github.com/qsfera/server/services/graph/pkg/identity" graphm "github.com/qsfera/server/services/graph/pkg/middleware" "github.com/qsfera/server/services/graph/pkg/unifiedrole" ) const ( // HeaderPurge defines the header name for the purge header. HeaderPurge = "Purge" displayNameAttr = "displayName" ) // Service defines the service handlers. type Service interface { //nolint:interfacebloat ServeHTTP(w http.ResponseWriter, r *http.Request) ListApplications(w http.ResponseWriter, r *http.Request) GetApplication(w http.ResponseWriter, r *http.Request) GetMe(w http.ResponseWriter, r *http.Request) GetUsers(w http.ResponseWriter, r *http.Request) GetUser(w http.ResponseWriter, r *http.Request) PostUser(w http.ResponseWriter, r *http.Request) DeleteUser(w http.ResponseWriter, r *http.Request) PatchUser(w http.ResponseWriter, r *http.Request) ChangeOwnPassword(w http.ResponseWriter, r *http.Request) ListAppRoleAssignments(w http.ResponseWriter, r *http.Request) CreateAppRoleAssignment(w http.ResponseWriter, r *http.Request) DeleteAppRoleAssignment(w http.ResponseWriter, r *http.Request) GetGroups(w http.ResponseWriter, r *http.Request) GetGroup(w http.ResponseWriter, r *http.Request) PostGroup(w http.ResponseWriter, r *http.Request) PatchGroup(w http.ResponseWriter, r *http.Request) DeleteGroup(w http.ResponseWriter, r *http.Request) GetGroupMembers(w http.ResponseWriter, r *http.Request) PostGroupMember(w http.ResponseWriter, r *http.Request) DeleteGroupMember(w http.ResponseWriter, r *http.Request) GetEducationSchools(w http.ResponseWriter, r *http.Request) GetEducationSchool(w http.ResponseWriter, r *http.Request) PostEducationSchool(w http.ResponseWriter, r *http.Request) PatchEducationSchool(w http.ResponseWriter, r *http.Request) DeleteEducationSchool(w http.ResponseWriter, r *http.Request) GetEducationSchoolUsers(w http.ResponseWriter, r *http.Request) PostEducationSchoolUser(w http.ResponseWriter, r *http.Request) DeleteEducationSchoolUser(w http.ResponseWriter, r *http.Request) GetEducationSchoolClasses(w http.ResponseWriter, r *http.Request) PostEducationSchoolClass(w http.ResponseWriter, r *http.Request) DeleteEducationSchoolClass(w http.ResponseWriter, r *http.Request) GetEducationClasses(w http.ResponseWriter, r *http.Request) GetEducationClass(w http.ResponseWriter, r *http.Request) PostEducationClass(w http.ResponseWriter, r *http.Request) PatchEducationClass(w http.ResponseWriter, r *http.Request) DeleteEducationClass(w http.ResponseWriter, r *http.Request) GetEducationClassMembers(w http.ResponseWriter, r *http.Request) PostEducationClassMember(w http.ResponseWriter, r *http.Request) GetEducationUsers(w http.ResponseWriter, r *http.Request) GetEducationUser(w http.ResponseWriter, r *http.Request) PostEducationUser(w http.ResponseWriter, r *http.Request) DeleteEducationUser(w http.ResponseWriter, r *http.Request) PatchEducationUser(w http.ResponseWriter, r *http.Request) DeleteEducationClassMember(w http.ResponseWriter, r *http.Request) GetEducationClassTeachers(w http.ResponseWriter, r *http.Request) PostEducationClassTeacher(w http.ResponseWriter, r *http.Request) DeleteEducationClassTeacher(w http.ResponseWriter, r *http.Request) GetDrivesV1(w http.ResponseWriter, r *http.Request) GetDrivesV1Beta1(w http.ResponseWriter, r *http.Request) GetSingleDrive(w http.ResponseWriter, r *http.Request) GetAllDrivesV1(w http.ResponseWriter, r *http.Request) GetAllDrivesV1Beta1(w http.ResponseWriter, r *http.Request) CreateDrive(w http.ResponseWriter, r *http.Request) UpdateDrive(w http.ResponseWriter, r *http.Request) DeleteDrive(w http.ResponseWriter, r *http.Request) GetSharedByMe(w http.ResponseWriter, r *http.Request) ListSharedWithMe(w http.ResponseWriter, r *http.Request) GetRootDriveChildren(w http.ResponseWriter, r *http.Request) GetDriveItem(w http.ResponseWriter, r *http.Request) GetDriveItemChildren(w http.ResponseWriter, r *http.Request) CreateUploadSession(w http.ResponseWriter, r *http.Request) GetTags(w http.ResponseWriter, r *http.Request) AssignTags(w http.ResponseWriter, r *http.Request) UnassignTags(w http.ResponseWriter, r *http.Request) } // NewService returns a service implementation for Service. func NewService(opts ...Option) (Graph, error) { //nolint:maintidx options := newOptions(opts...) m := chi.NewMux() m.Use(options.Middleware...) m.Use( otelchi.Middleware( "graph", otelchi.WithChiRoutes(m), otelchi.WithTracerProvider(options.TraceProvider), otelchi.WithPropagators(tracing.GetPropagator()), ), ) spacePropertiesCache := ttlcache.New( ttlcache.WithTTL[string, any]( time.Duration(options.Config.Spaces.ExtendedSpacePropertiesCacheTTL), ), ttlcache.WithDisableTouchOnHit[string, any](), ) go spacePropertiesCache.Start() identityCache := cache.NewIdentityCache( cache.IdentityCacheWithGatewaySelector(options.GatewaySelector), cache.IdentityCacheWithUsersTTL(time.Duration(options.Config.Spaces.UsersCacheTTL)), cache.IdentityCacheWithGroupsTTL(time.Duration(options.Config.Spaces.GroupsCacheTTL)), ) publicBaseURL, err := url.Parse(options.Config.Spaces.WebDavBase) if err != nil { return Graph{}, fmt.Errorf("could not parse graph.spaces.webdav_base: %w", err) } baseGraphService := BaseGraphService{ logger: &options.Logger, identityCache: identityCache, gatewaySelector: options.GatewaySelector, config: options.Config, availableRoles: unifiedrole.GetRoles(unifiedrole.RoleFilterIDs(options.Config.UnifiedRoles.AvailableRoles...)), publicBaseURL: publicBaseURL, } drivesDriveItemService, err := NewDrivesDriveItemService(options.Logger, options.GatewaySelector) if err != nil { return Graph{}, err } drivesDriveItemApi, err := NewDrivesDriveItemApi(drivesDriveItemService, baseGraphService, options.Logger) if err != nil { return Graph{}, err } driveItemPermissionsService, err := NewDriveItemPermissionsService(options.Logger, options.GatewaySelector, identityCache, options.Config) if err != nil { return Graph{}, err } driveItemPermissionsApi, err := NewDriveItemPermissionsApi(driveItemPermissionsService, options.Logger, options.Config) if err != nil { return Graph{}, err } usersUserProfilePhotoApi, err := NewUsersUserProfilePhotoApi(options.UserProfilePhotoService, options.Logger) if err != nil { return Graph{}, err } svc := Graph{ BaseGraphService: baseGraphService, mux: m, specialDriveItemsCache: spacePropertiesCache, eventsPublisher: options.EventsPublisher, eventsConsumer: options.EventsConsumer, searchService: options.SearchService, identityEducationBackend: options.IdentityEducationBackend, keycloakClient: options.KeycloakClient, historyClient: options.EventHistoryClient, traceProvider: options.TraceProvider, valueService: options.ValueService, natskv: options.NatsKeyValue, } if err := setIdentityBackends(options, &svc); err != nil { return svc, err } if options.PermissionService == nil { grpcClient, err := grpc.NewClient(append(grpc.GetClientOptions(options.Config.GRPCClientTLS), grpc.WithTraceProvider(options.TraceProvider))...) if err != nil { return svc, err } svc.permissionsService = settingssvc.NewPermissionService("qsfera.api.settings", grpcClient) } else { svc.permissionsService = options.PermissionService } svc.roleService = options.RoleService roleManager := options.RoleManager if roleManager == nil { storeOptions := []microstore.Option{ store.Store(options.Config.Cache.Store), store.TTL(options.Config.Cache.TTL), microstore.Nodes(options.Config.Cache.Nodes...), microstore.Database(options.Config.Cache.Database), microstore.Table(options.Config.Cache.Table), store.DisablePersistence(options.Config.Cache.DisablePersistence), store.Authentication(options.Config.Cache.AuthUsername, options.Config.Cache.AuthPassword), } m := roles.NewManager( roles.StoreOptions(storeOptions), roles.Logger(options.Logger), roles.RoleService(options.RoleService), ) roleManager = &m } var requireAdmin func(http.Handler) http.Handler if options.RequireAdminMiddleware == nil { requireAdmin = graphm.RequireAdmin(roleManager, options.Logger) } else { requireAdmin = options.RequireAdminMiddleware } m.Route(options.Config.HTTP.Root, func(r chi.Router) { r.Use(middleware.StripSlashes) r.Route("/v1beta1", func(r chi.Router) { r.Route("/me", func(r chi.Router) { r.Get("/drives", svc.GetDrives(APIVersion_1_Beta_1)) r.Route("/drive", func(r chi.Router) { r.Get("/sharedByMe", svc.GetSharedByMe) r.Get("/sharedWithMe", svc.ListSharedWithMe) }) }) r.Route("/drives", func(r chi.Router) { r.Get("/", svc.GetAllDrives(APIVersion_1_Beta_1)) r.Route("/{driveID}", func(r chi.Router) { r.Route("/root", func(r chi.Router) { r.Post("/children", drivesDriveItemApi.CreateDriveItem) r.Post("/invite", driveItemPermissionsApi.SpaceRootInvite) r.Post("/createLink", driveItemPermissionsApi.CreateSpaceRootLink) r.Route("/permissions", func(r chi.Router) { r.Get("/", driveItemPermissionsApi.ListSpaceRootPermissions) r.Route("/{permissionID}", func(r chi.Router) { r.Delete("/", driveItemPermissionsApi.DeleteSpaceRootPermission) r.Patch("/", driveItemPermissionsApi.UpdateSpaceRootPermission) r.Post("/setPassword", driveItemPermissionsApi.SetSpaceRootLinkPassword) }) }) }) r.Route("/items/{itemID}", func(r chi.Router) { r.Get("/", drivesDriveItemApi.GetDriveItem) r.Patch("/", drivesDriveItemApi.UpdateDriveItem) r.Delete("/", drivesDriveItemApi.DeleteDriveItem) r.Post("/invite", driveItemPermissionsApi.Invite) r.Post("/createLink", driveItemPermissionsApi.CreateLink) r.Route("/permissions", func(r chi.Router) { r.Get("/", driveItemPermissionsApi.ListPermissions) r.Route("/{permissionID}", func(r chi.Router) { r.Delete("/", driveItemPermissionsApi.DeletePermission) r.Patch("/", driveItemPermissionsApi.UpdatePermission) r.Post("/setPassword", driveItemPermissionsApi.SetLinkPassword) }) }) }) }) }) r.Route("/roleManagement/permissions/roleDefinitions", func(r chi.Router) { r.Get("/", svc.GetRoleDefinitions) r.Get("/{roleID}", svc.GetRoleDefinition) }) }) r.Route("/v1.0", func(r chi.Router) { r.Route("/extensions/org.libregraph", func(r chi.Router) { r.Get("/tags", svc.GetTags) r.Put("/tags", svc.AssignTags) r.Delete("/tags", svc.UnassignTags) }) r.Route("/applications", func(r chi.Router) { r.Get("/", svc.ListApplications) r.Get("/{applicationID}", svc.GetApplication) }) r.Route("/me", func(r chi.Router) { r.Get("/", svc.GetMe) r.Patch("/", svc.PatchMe) r.Route("/drive", func(r chi.Router) { r.Get("/", svc.GetUserDrive) r.Get("/root/children", svc.GetRootDriveChildren) r.Post("/items/{itemID}/follow", svc.FollowDriveItem) r.Delete("/following/{itemID}", svc.UnfollowDriveItem) }) r.Get("/drives", svc.GetDrives(APIVersion_1)) r.Post("/changePassword", svc.ChangeOwnPassword) r.Route("/photo/$value", func(r chi.Router) { r.Get("/", usersUserProfilePhotoApi.GetProfilePhoto(GetUserIDFromCTX)) r.Put("/", usersUserProfilePhotoApi.UpsertProfilePhoto(GetUserIDFromCTX)) r.Patch("/", usersUserProfilePhotoApi.UpsertProfilePhoto(GetUserIDFromCTX)) r.Delete("/", usersUserProfilePhotoApi.DeleteProfilePhoto(GetUserIDFromCTX)) }) }) r.Route("/users", func(r chi.Router) { r.Get("/", svc.GetUsers) r.With(requireAdmin).Post("/", svc.PostUser) r.Route("/{userID}", func(r chi.Router) { r.Get("/", svc.GetUser) r.Get("/drive", svc.GetUserDrive) r.Post("/exportPersonalData", svc.ExportPersonalData) r.Route("/photo/$value", func(r chi.Router) { r.Get("/", usersUserProfilePhotoApi.GetProfilePhoto(GetSlugValue("userID"))) }) r.With(requireAdmin).Delete("/", svc.DeleteUser) r.With(requireAdmin).Patch("/", svc.PatchUser) if svc.roleService != nil { r.With(requireAdmin).Route("/appRoleAssignments", func(r chi.Router) { r.Get("/", svc.ListAppRoleAssignments) r.Post("/", svc.CreateAppRoleAssignment) r.Delete("/{appRoleAssignmentID}", svc.DeleteAppRoleAssignment) }) } }) }) r.Route("/groups", func(r chi.Router) { r.Get("/", svc.GetGroups) r.With(requireAdmin).Post("/", svc.PostGroup) r.Route("/{groupID}", func(r chi.Router) { r.Get("/", svc.GetGroup) r.With(requireAdmin).Delete("/", svc.DeleteGroup) r.With(requireAdmin).Patch("/", svc.PatchGroup) r.Route("/members", func(r chi.Router) { r.With(requireAdmin).Get("/", svc.GetGroupMembers) r.With(requireAdmin).Post("/$ref", svc.PostGroupMember) r.With(requireAdmin).Delete("/{memberID}/$ref", svc.DeleteGroupMember) }) }) }) r.Route("/drives", func(r chi.Router) { r.Get("/", svc.GetAllDrives(APIVersion_1)) r.Post("/", svc.CreateDrive) r.Route("/{driveID}", func(r chi.Router) { r.Patch("/", svc.UpdateDrive) r.Get("/", svc.GetSingleDrive) r.Delete("/", svc.DeleteDrive) r.Route("/items/{driveItemID}", func(r chi.Router) { r.Get("/", svc.GetDriveItem) r.Get("/children", svc.GetDriveItemChildren) r.Post("/createUploadSession", svc.CreateUploadSession) }) }) }) r.With(requireAdmin).Route("/education", func(r chi.Router) { r.Route("/schools", func(r chi.Router) { r.Get("/", svc.GetEducationSchools) r.Post("/", svc.PostEducationSchool) r.Route("/{schoolID}", func(r chi.Router) { r.Get("/", svc.GetEducationSchool) r.Delete("/", svc.DeleteEducationSchool) r.Patch("/", svc.PatchEducationSchool) r.Route("/users", func(r chi.Router) { r.Get("/", svc.GetEducationSchoolUsers) r.Post("/$ref", svc.PostEducationSchoolUser) r.Delete("/{userID}/$ref", svc.DeleteEducationSchoolUser) }) r.Route("/classes", func(r chi.Router) { r.Get("/", svc.GetEducationSchoolClasses) r.Post("/$ref", svc.PostEducationSchoolClass) r.Delete("/{classID}/$ref", svc.DeleteEducationSchoolClass) }) }) }) r.Route("/users", func(r chi.Router) { r.Get("/", svc.GetEducationUsers) r.Post("/", svc.PostEducationUser) r.Route("/{userID}", func(r chi.Router) { r.Get("/", svc.GetEducationUser) r.Delete("/", svc.DeleteEducationUser) r.Patch("/", svc.PatchEducationUser) }) }) r.Route("/classes", func(r chi.Router) { r.Get("/", svc.GetEducationClasses) r.Post("/", svc.PostEducationClass) r.Route("/{classID}", func(r chi.Router) { r.Get("/", svc.GetEducationClass) r.Delete("/", svc.DeleteEducationClass) r.Patch("/", svc.PatchEducationClass) r.Route("/members", func(r chi.Router) { r.Get("/", svc.GetEducationClassMembers) r.Post("/$ref", svc.PostEducationClassMember) r.Delete("/{memberID}/$ref", svc.DeleteEducationClassMember) }) r.Route("/teachers", func(r chi.Router) { r.Get("/", svc.GetEducationClassTeachers) r.Post("/$ref", svc.PostEducationClassTeacher) r.Delete("/{teacherID}/$ref", svc.DeleteEducationClassTeacher) }) }) }) }) }) }) _ = chi.Walk(m, func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error { options.Logger.Debug().Str("method", method).Str("route", route).Int("middlewares", len(middlewares)).Msg("serving endpoint") return nil }) return svc, nil } func setIdentityBackends(options Options, svc *Graph) error { if options.IdentityBackend == nil { switch options.Config.Identity.Backend { case "cs3": gatewaySelector, err := pool.GatewaySelector( options.Config.Reva.Address, append( options.Config.Reva.GetRevaOptions(), pool.WithRegistry(registry.GetRegistry()), pool.WithTracerProvider(options.TraceProvider), )..., ) if err != nil { return err } svc.identityBackend = &identity.CS3{ Config: options.Config.Reva, Logger: &options.Logger, GatewaySelector: gatewaySelector, } case "ldap": var err error var tlsConf *tls.Config if options.Config.Identity.LDAP.Insecure { // When insecure is set to true then we don't need a certificate. options.Config.Identity.LDAP.CACert = "" tlsConf = &tls.Config{ MinVersion: tls.VersionTLS12, //nolint:gosec // We need the ability to run with "insecure" (dev/testing) InsecureSkipVerify: options.Config.Identity.LDAP.Insecure, } } if options.Config.Identity.LDAP.CACert != "" { if err := ocldap.WaitForCA(options.Logger, options.Config.Identity.LDAP.Insecure, options.Config.Identity.LDAP.CACert); err != nil { options.Logger.Fatal().Err(err).Msg("The configured LDAP CA cert does not exist") } if tlsConf == nil { tlsConf = &tls.Config{ MinVersion: tls.VersionTLS12, } } certs := x509.NewCertPool() pemData, err := os.ReadFile(options.Config.Identity.LDAP.CACert) if err != nil { options.Logger.Error().Err(err).Msg("Error initializing LDAP Backend") return err } if !certs.AppendCertsFromPEM(pemData) { options.Logger.Error().Msg("Error initializing LDAP Backend. Adding CA cert failed") return err } tlsConf.RootCAs = certs } conn := ldap.NewLDAPWithReconnect( ldap.Config{ URI: options.Config.Identity.LDAP.URI, BindDN: options.Config.Identity.LDAP.BindDN, BindPassword: options.Config.Identity.LDAP.BindPassword, TLSConfig: tlsConf, }, ) conn.SetLogger(&options.Logger.Logger) lb, err := identity.NewLDAPBackend(conn, options.Config.Identity.LDAP, &options.Logger) if err != nil { options.Logger.Error().Err(err).Msg("Error initializing LDAP Backend") return err } svc.identityBackend = lb if options.IdentityEducationBackend == nil { if options.Config.Identity.LDAP.EducationResourcesEnabled { svc.identityEducationBackend = lb } else { errEduBackend := &identity.ErrEducationBackend{} svc.identityEducationBackend = errEduBackend } } disableMechanismType, err := identity.ParseDisableMechanismType(options.Config.Identity.LDAP.DisableUserMechanism) if err != nil { options.Logger.Error().Err(err).Msg("Error initializing LDAP Backend") return err } if disableMechanismType == identity.DisableMechanismGroup { options.Logger.Info().Msg("LocalUserDisable is true, will create group if not exists") err := lb.CreateLDAPGroupByDN(options.Config.Identity.LDAP.LdapDisabledUsersGroupDN) if err != nil { isAnError := false var lerr *ldapv3.Error if errors.As(err, &lerr) { if lerr.ResultCode != ldapv3.LDAPResultEntryAlreadyExists { isAnError = true } } else { isAnError = true } if isAnError { msg := "error adding group for disabling users" options.Logger.Error().Err(err).Str("local_user_disable", options.Config.Identity.LDAP.LdapDisabledUsersGroupDN).Msg(msg) return err } } } default: err := fmt.Errorf("unknown identity backend: '%s'", options.Config.Identity.Backend) options.Logger.Err(err) return err } } else { 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 } // parseHeaderPurge parses the 'Purge' header. // '1', 't', 'T', 'TRUE', 'true', 'True' are parsed as true // all other values are false. func parsePurgeHeader(h http.Header) bool { val := h.Get(HeaderPurge) if b, err := strconv.ParseBool(val); err == nil { return b } return false }