Repository: owncloud/ocis
Pull request #3635 (2022-04-29 13:13:10)

Add initial version of the search extension
## Description

This pr adds the new search extension which indexes files and directories and makes them searchable using the search service from within the web UI.

## Types of changes
<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [X] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
- [ ] Technical debt
- [ ] Tests only (no source changes)

## Checklist:
<!-- Tick the checkboxes when done. -->
<!-- Raise documentation ticket in owncloud.github.io/ -->
- [X] Code changes
- [X] Unit tests added
- [ ] Acceptance tests added
- [ ] Documentation ticket raised: <link> 



Patch:
diff --git a/changelog/unreleased/search-extension.md b/changelog/unreleased/search-extension.md
new file mode 100644
index 00000000000..c4e7bffa4bb
--- /dev/null
+++ b/changelog/unreleased/search-extension.md
@@ -0,0 +1,5 @@
+Enhancement: Add initial version of the search extensions
+
+It is now possible to search for files and directories by their name using the web UI. Therefor new search extension indexes files in a persistent local index.
+
+https://github.com/owncloud/ocis/pull/3635
diff --git a/docs/extensions/port-ranges.md b/docs/extensions/port-ranges.md
index ac28f54c53c..bc3143c08e7 100644
--- a/docs/extensions/port-ranges.md
+++ b/docs/extensions/port-ranges.md
@@ -42,7 +42,7 @@ We also suggest to use the last port in your extensions' range as a debug/metric
 | 9205-9209  | [markdown-editor](https://github.com/owncloud/ocis-markdown-editor)           |
 | 9210-9214  | [reva](https://github.com/owncloud/ocis-reva) unused?                         |
 | 9215-9219  | reva metadata storage                                                         |
-| 9220-9224  | FREE                                                                          |
+| 9220-9224  | search                                                                          |
 | 9225-9229  | photoprism (state: PoC)                                                       |
 | 9230-9234  | [nats](https://github.com/owncloud/ocis/tree/master/nats)                     |
 | 9235-9239  | idm TBD                                                                       |
diff --git a/docs/extensions/storage/ports.md b/docs/extensions/storage/ports.md
index 2ab34f5dd92..62bd6ac82e1 100644
--- a/docs/extensions/storage/ports.md
+++ b/docs/extensions/storage/ports.md
@@ -34,10 +34,13 @@ For now, the storage service uses these ports to preconfigure those services:
 | 9159      | storage users debug                           |
 | 9160      | groups                                        |
 | 9161      | groups debug                                  |
-| 9164      | storage appprovider                                   |
-| 9165      | storage appprovider debug                             |
+| 9164      | storage appprovider                           |
+| 9165      | storage appprovider debug                     |
 | 9178      | storage public link                           |
 | 9179      | storage public link data                      |
+| 9180      | accounts grpc                                 |
+| 9181      | accounts http                                 |
+| 9182      | accounts debug                                |
 | 9215      | storage meta grpc                             |
 | 9216      | storage meta http                             |
 | 9217      | storage meta debug                            |
diff --git a/extensions/audit/pkg/types/events.go b/extensions/audit/pkg/types/events.go
index 1862b1f8070..2fe9d9aef6f 100644
--- a/extensions/audit/pkg/types/events.go
+++ b/extensions/audit/pkg/types/events.go
@@ -16,6 +16,7 @@ func RegisteredEvents() []events.Unmarshaller {
 		events.ReceivedShareUpdated{},
 		events.LinkAccessed{},
 		events.LinkAccessFailed{},
+		events.ContainerCreated{},
 		events.FileUploaded{},
 		events.FileDownloaded{},
 		events.ItemTrashed{},
diff --git a/extensions/frontend/pkg/command/command.go b/extensions/frontend/pkg/command/command.go
index b613e8fc76a..d14917d1c6a 100644
--- a/extensions/frontend/pkg/command/command.go
+++ b/extensions/frontend/pkg/command/command.go
@@ -236,7 +236,9 @@ func frontendConfigFromStruct(c *cli.Context, cfg *config.Config, filesCfg map[s
 								"preferred_upload_type": cfg.Checksums.PreferredUploadType,
 							},
 							"files": filesCfg,
-							"dav":   map[string]interface{}{},
+							"dav": map[string]interface{}{
+								"reports": []string{"search-files"},
+							},
 							"files_sharing": map[string]interface{}{
 								"api_enabled":                       true,
 								"resharing":                         false,
diff --git a/extensions/proxy/pkg/config/config.go b/extensions/proxy/pkg/config/config.go
index b1959b9ccd5..f6264f413d6 100644
--- a/extensions/proxy/pkg/config/config.go
+++ b/extensions/proxy/pkg/config/config.go
@@ -45,8 +45,10 @@ type Policy struct {
 
 // Route defines forwarding routes
 type Route struct {
-	Type     RouteType `yaml:"type"`
-	Endpoint string    `yaml:"endpoint"`
+	Type RouteType `yaml:"type"`
+	// Method optionally limits the route to this HTTP method
+	Method   string `yaml:"method"`
+	Endpoint string `yaml:"endpoint"`
 	// Backend is a static URL to forward the request to
 	Backend string `yaml:"backend"`
 	// Service name to look up in the registry
diff --git a/extensions/proxy/pkg/config/defaults/defaultconfig.go b/extensions/proxy/pkg/config/defaults/defaultconfig.go
index 1b45e273f84..c04ed2e27c4 100644
--- a/extensions/proxy/pkg/config/defaults/defaultconfig.go
+++ b/extensions/proxy/pkg/config/defaults/defaultconfig.go
@@ -97,6 +97,15 @@ func DefaultPolicies() []config.Policy {
 					Endpoint: "/remote.php/?preview=1",
 					Backend:  "http://localhost:9115",
 				},
+				{
+					// TODO the actual REPORT goes to /dav/files/{username}, which is user specific ... how would this work in a spaces world?
+					// TODO what paths are returned? the href contains the full path so it should be possible to return urls from other spaces?
+					// TODO or we allow a REPORT on /dav/spaces to search all spaces and /dav/space/{spaceid} to search a specific space
+					// send webdav REPORT requests to search service
+					Method:   "REPORT",
+					Endpoint: "/remote.php/dav/",
+					Backend:  "http://localhost:9115", // TODO use registry?
+				},
 				{
 					Endpoint: "/remote.php/",
 					Service:  "ocdav",
diff --git a/extensions/proxy/pkg/proxy/proxy.go b/extensions/proxy/pkg/proxy/proxy.go
index ba9ce4addc4..b910452c256 100644
--- a/extensions/proxy/pkg/proxy/proxy.go
+++ b/extensions/proxy/pkg/proxy/proxy.go
@@ -29,7 +29,8 @@ import (
 // MultiHostReverseProxy extends "httputil" to support multiple hosts with different policies
 type MultiHostReverseProxy struct {
 	httputil.ReverseProxy
-	Directors      map[string]map[config.RouteType]map[string]func(req *http.Request)
+	// Directors holds policy        route type        method    endpoint         Director
+	Directors      map[string]map[config.RouteType]map[string]map[string]func(req *http.Request)
 	PolicySelector policy.Selector
 	logger         log.Logger
 	config         *config.Config
@@ -40,7 +41,7 @@ func NewMultiHostReverseProxy(opts ...Option) *MultiHostReverseProxy {
 	options := newOptions(opts...)
 
 	rp := &MultiHostReverseProxy{
-		Directors: make(map[string]map[config.RouteType]map[string]func(req *http.Request)),
+		Directors: make(map[string]map[config.RouteType]map[string]map[string]func(req *http.Request)),
 		logger:    options.Logger,
 		config:    options.Config,
 	}
@@ -124,6 +125,7 @@ func (p *MultiHostReverseProxy) directorSelectionDirector(r *http.Request) {
 		return
 	}
 
+	method := ""
 	// find matching director
 	for _, rt := range config.RouteTypes {
 		var handler func(string, url.URL) bool
@@ -137,25 +139,36 @@ func (p *MultiHostReverseProxy) directorSelectionDirector(r *http.Request) {
 		default:
 			handler = p.prefixRouteMatcher
 		}
-		for endpoint := range p.Directors[pol][rt] {
+		if p.Directors[pol][rt][r.Method] != nil {
+			// use specific method
+			method = r.Method
+		}
+		for endpoint := range p.Directors[pol][rt][method] {
 			if handler(endpoint, *r.URL) {
 
 				p.logger.Debug().
 					Str("policy", pol).
+					Str("method", r.Method).
 					Str("prefix", endpoint).
 					Str("path", r.URL.Path).
 					Str("routeType", string(rt)).
 					Msg("director found")
 
-				p.Directors[pol][rt][endpoint](r)
+				p.Directors[pol][rt][method][endpoint](r)
 				return
 			}
 		}
 	}
 
 	// override default director with root. If any
-	if p.Directors[pol][config.PrefixRoute]["/"] != nil {
-		p.Directors[pol][config.PrefixRoute]["/"](r)
+	switch {
+	case p.Directors[pol][config.PrefixRoute][method]["/"] != nil:
+		// try specific method
+		p.Directors[pol][config.PrefixRoute][method]["/"](r)
+		return
+	case p.Directors[pol][config.PrefixRoute][""]["/"] != nil:
+		// fallback to unspecific method
+		p.Directors[pol][config.PrefixRoute][""]["/"](r)
 		return
 	}
 
@@ -182,20 +195,23 @@ func singleJoiningSlash(a, b string) string {
 func (p *MultiHostReverseProxy) AddHost(policy string, target *url.URL, rt config.Route) {
 	targetQuery := target.RawQuery
 	if p.Directors[policy] == nil {
-		p.Directors[policy] = make(map[config.RouteType]map[string]func(req *http.Request))
+		p.Directors[policy] = make(map[config.RouteType]map[string]map[string]func(req *http.Request))
 	}
 	routeType := config.DefaultRouteType
 	if rt.Type != "" {
 		routeType = rt.Type
 	}
 	if p.Directors[policy][routeType] == nil {
-		p.Directors[policy][routeType] = make(map[string]func(req *http.Request))
+		p.Directors[policy][routeType] = make(map[string]map[string]func(req *http.Request))
+	}
+	if p.Directors[policy][routeType][rt.Method] == nil {
+		p.Directors[policy][routeType][rt.Method] = make(map[string]func(req *http.Request))
 	}
 
 	reg := registry.GetRegistry()
 	sel := selector.NewSelector(selector.Registry(reg))
 
-	p.Directors[policy][routeType][rt.Endpoint] = func(req *http.Request) {
+	p.Directors[policy][routeType][rt.Method][rt.Endpoint] = func(req *http.Request) {
 		if rt.Service != "" {
 			// select next node
 			next, err := sel.Select(rt.Service)
diff --git a/extensions/search/cmd/search/main.go b/extensions/search/cmd/search/main.go
new file mode 100644
index 00000000000..aa078dc41c8
--- /dev/null
+++ b/extensions/search/cmd/search/main.go
@@ -0,0 +1,14 @@
+package main
+
+import (
+	"os"
+
+	"github.com/owncloud/ocis/extensions/search/pkg/command"
+	"github.com/owncloud/ocis/extensions/search/pkg/config/defaults"
+)
+
+func main() {
+	if err := command.Execute(defaults.DefaultConfig()); err != nil {
+		os.Exit(1)
+	}
+}
diff --git a/extensions/search/pkg/command/health.go b/extensions/search/pkg/command/health.go
new file mode 100644
index 00000000000..ff4e0ec3010
--- /dev/null
+++ b/extensions/search/pkg/command/health.go
@@ -0,0 +1,53 @@
+package command
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/owncloud/ocis/extensions/search/pkg/config"
+	"github.com/owncloud/ocis/extensions/search/pkg/config/parser"
+	"github.com/owncloud/ocis/extensions/search/pkg/logging"
+	"github.com/urfave/cli/v2"
+)
+
+// Health is the entrypoint for the health command.
+func Health(cfg *config.Config) *cli.Command {
+	return &cli.Command{
+		Name:     "health",
+		Usage:    "check health status",
+		Category: "info",
+		Before: func(c *cli.Context) error {
+			return parser.ParseConfig(cfg)
+		},
+		Action: func(c *cli.Context) error {
+			logger := logging.Configure(cfg.Service.Name, cfg.Log)
+
+			resp, err := http.Get(
+				fmt.Sprintf(
+					"http://%s/healthz",
+					cfg.Debug.Addr,
+				),
+			)
+
+			if err != nil {
+				logger.Fatal().
+					Err(err).
+					Msg("Failed to request health check")
+			}
+
+			defer resp.Body.Close()
+
+			if resp.StatusCode != http.StatusOK {
+				logger.Fatal().
+					Int("code", resp.StatusCode).
+					Msg("Health seems to be in bad state")
+			}
+
+			logger.Debug().
+				Int("code", resp.StatusCode).
+				Msg("Health got a good state")
+
+			return nil
+		},
+	}
+}
diff --git a/extensions/search/pkg/command/index.go b/extensions/search/pkg/command/index.go
new file mode 100644
index 00000000000..3d57e926425
--- /dev/null
+++ b/extensions/search/pkg/command/index.go
@@ -0,0 +1,52 @@
+package command
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/urfave/cli/v2"
+
+	"github.com/owncloud/ocis/extensions/search/pkg/config"
+	"github.com/owncloud/ocis/extensions/search/pkg/config/parser"
+	"github.com/owncloud/ocis/ocis-pkg/service/grpc"
+	searchsvc "github.com/owncloud/ocis/protogen/gen/ocis/services/search/v0"
+)
+
+// Index is the entrypoint for the server command.
+func Index(cfg *config.Config) *cli.Command {
+	return &cli.Command{
+		Name:     "index",
+		Usage:    "index the files for one one more users",
+		Category: "index management",
+		Aliases:  []string{"i"},
+		Flags: []cli.Flag{
+			&cli.StringFlag{
+				Name:     "space",
+				Aliases:  []string{"s"},
+				Required: true,
+				Usage:    "the id of the space to travers and index the files of",
+			},
+			&cli.StringFlag{
+				Name:     "user",
+				Aliases:  []string{"u"},
+				Required: true,
+				Usage:    "the username of the user tha shall be used to access the files",
+			},
+		},
+		Before: func(c *cli.Context) error {
+			return parser.ParseConfig(cfg)
+		},
+		Action: func(c *cli.Context) error {
+			client := searchsvc.NewSearchProviderService("com.owncloud.api.search", grpc.DefaultClient)
+			_, err := client.IndexSpace(context.Background(), &searchsvc.IndexSpaceRequest{
+				SpaceId: c.String("space"),
+				UserId:  c.String("user"),
+			})
+			if err != nil {
+				fmt.Println("failed to index space: " + err.Error())
+				return err
+			}
+			return nil
+		},
+	}
+}
diff --git a/extensions/search/pkg/command/root.go b/extensions/search/pkg/command/root.go
new file mode 100644
index 00000000000..a43261631fe
--- /dev/null
+++ b/extensions/search/pkg/command/root.go
@@ -0,0 +1,65 @@
+package command
+
+import (
+	"context"
+	"os"
+
+	"github.com/owncloud/ocis/ocis-pkg/clihelper"
+	"github.com/thejerf/suture/v4"
+
+	"github.com/owncloud/ocis/extensions/search/pkg/config"
+	ociscfg "github.com/owncloud/ocis/ocis-pkg/config"
+	"github.com/urfave/cli/v2"
+)
+
+// GetCommands provides all commands for this service
+func GetCommands(cfg *config.Config) cli.Commands {
+	return []*cli.Command{
+		// start this service
+		Server(cfg),
+
+		// interaction with this service
+		Index(cfg),
+
+		// infos about this service
+		Health(cfg),
+		Version(cfg),
+	}
+}
+
+// Execute is the entry point for the ocis-search command.
+func Execute(cfg *config.Config) error {
+	app := clihelper.DefaultApp(&cli.App{
+		Name:     "ocis-search",
+		Usage:    "Serve search API for oCIS",
+		Commands: GetCommands(cfg),
+	})
+	cli.HelpFlag = &cli.BoolFlag{
+		Name:  "help,h",
+		Usage: "Show the help",
+	}
+
+	return app.Run(os.Args)
+}
+
+// SutureService allows for the search command to be embedded and supervised by a suture supervisor tree.
+type SutureService struct {
+	cfg *config.Config
+}
+
+// NewSutureService creates a new search.SutureService
+func NewSutureService(cfg *ociscfg.Config) suture.Service {
+	cfg.Search.Commons = cfg.Commons
+	return SutureService{
+		cfg: cfg.Search,
+	}
+}
+
+func (s SutureService) Serve(ctx context.Context) error {
+	s.cfg.Context = ctx
+	if err := Execute(s.cfg); err != nil {
+		return err
+	}
+
+	return nil
+}
diff --git a/extensions/search/pkg/command/server.go b/extensions/search/pkg/command/server.go
new file mode 100644
index 00000000000..2582213c493
--- /dev/null
+++ b/extensions/search/pkg/command/server.go
@@ -0,0 +1,83 @@
+package command
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/oklog/run"
+	"github.com/owncloud/ocis/extensions/search/pkg/config"
+	"github.com/owncloud/ocis/extensions/search/pkg/config/parser"
+	"github.com/owncloud/ocis/extensions/search/pkg/logging"
+	"github.com/owncloud/ocis/extensions/search/pkg/metrics"
+	"github.com/owncloud/ocis/extensions/search/pkg/server/debug"
+	"github.com/owncloud/ocis/extensions/search/pkg/server/grpc"
+	"github.com/owncloud/ocis/extensions/search/pkg/tracing"
+	"github.com/owncloud/ocis/ocis-pkg/version"
+	"github.com/urfave/cli/v2"
+)
+
+// Server is the entrypoint for the server command.
+func Server(cfg *config.Config) *cli.Command {
+	return &cli.Command{
+		Name:     "server",
+		Usage:    fmt.Sprintf("start %s extension without runtime (unsupervised mode)", cfg.Service.Name),
+		Category: "server",
+		Before: func(c *cli.Context) error {
+			err := parser.ParseConfig(cfg)
+			if err != nil {
+				fmt.Printf("%v", err)
+			}
+			return err
+		},
+		Action: func(c *cli.Context) error {
+			logger := logging.Configure(cfg.Service.Name, cfg.Log)
+			err := tracing.Configure(cfg)
+			if err != nil {
+				return err
+			}
+
+			gr := run.Group{}
+			ctx, cancel := func() (context.Context, context.CancelFunc) {
+				if cfg.Context == nil {
+					return context.WithCancel(context.Background())
+				}
+				return context.WithCancel(cfg.Context)
+			}()
+			defer cancel()
+
+			mtrcs := metrics.New()
+			mtrcs.BuildInfo.WithLabelValues(version.String).Set(1)
+
+			grpcServer := grpc.Server(
+				grpc.Config(cfg),
+				grpc.Logger(logger),
+				grpc.Name(cfg.Service.Name),
+				grpc.Context(ctx),
+				grpc.Metrics(mtrcs),
+			)
+
+			gr.Add(grpcServer.Run, func(_ error) {
+				logger.Info().Str("server", "grpc").Msg("shutting down server")
+				cancel()
+			})
+
+			server, err := debug.Server(
+				debug.Logger(logger),
+				debug.Context(ctx),
+				debug.Config(cfg),
+			)
+
+			if err != nil {
+				logger.Info().Err(err).Str("transport", "debug").Msg("Failed to initialize server")
+				return err
+			}
+
+			gr.Add(server.ListenAndServe, func(_ error) {
+				_ = server.Shutdown(ctx)
+				cancel()
+			})
+
+			return gr.Run()
+		},
+	}
+}
diff --git a/extensions/search/pkg/command/version.go b/extensions/search/pkg/command/version.go
new file mode 100644
index 00000000000..12326e77d99
--- /dev/null
+++ b/extensions/search/pkg/command/version.go
@@ -0,0 +1,50 @@
+package command
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/owncloud/ocis/ocis-pkg/registry"
+	"github.com/owncloud/ocis/ocis-pkg/version"
+
+	tw "github.com/olekukonko/tablewriter"
+	"github.com/owncloud/ocis/extensions/search/pkg/config"
+	"github.com/urfave/cli/v2"
+)
+
+// Version prints the service versions of all running instances.
+func Version(cfg *config.Config) *cli.Command {
+	return &cli.Command{
+		Name:     "version",
+		Usage:    "print the version of this binary and the running extension instances",
+		Category: "info",
+		Action: func(c *cli.Context) error {
+			fmt.Println("Version: " + version.String)
+			fmt.Printf("Compiled: %s\n", version.Compiled())
+			fmt.Println("")
+
+			reg := registry.GetRegistry()
+			services, err := reg.GetService(cfg.GRPC.Namespace + "." + cfg.Service.Name)
+			if err != nil {
+				fmt.Println(fmt.Errorf("could not get %s services from the registry: %v", cfg.Service.Name, err))
+				return err
+			}
+
+			if len(services) == 0 {
+				fmt.Println("No running " + cfg.Service.Name + " service found.")
+				return nil
+			}
+
+			table := tw.NewWriter(os.Stdout)
+			table.SetHeader([]string{"Version", "Address", "Id"})
+			table.SetAutoFormatHeaders(false)
+			for _, s := range services {
+				for _, n := range s.Nodes {
+					table.Append([]string{s.Version, n.Address, n.Id})
+				}
+			}
+			table.Render()
+			return nil
+		},
+	}
+}
diff --git a/extensions/search/pkg/config/config.go b/extensions/search/pkg/config/config.go
new file mode 100644
index 00000000000..7c7ac33ff61
--- /dev/null
+++ b/extensions/search/pkg/config/config.go
@@ -0,0 +1,35 @@
+package config
+
+import (
+	"context"
+
+	"github.com/owncloud/ocis/ocis-pkg/shared"
+)
+
+// Config combines all available configuration parts.
+type Config struct {
+	*shared.Commons `ocisConfig:"-" yaml:"-"`
+
+	Service Service `ocisConfig:"-" yaml:"-"`
+
+	Tracing *Tracing `ocisConfig:"tracing"`
+	Log     *Log     `ocisConfig:"log"`
+	Debug   Debug    `ocisConfig:"debug"`
+
+	GRPC GRPC `ocisConfig:"grpc"`
+
+	Datapath string `yaml:"data_path" env:"SEARCH_DATA_PATH"`
+	Reva     Reva   `ocisConfig:"reva"`
+	Events   Events `yaml:"events"`
+
+	MachineAuthAPIKey string `yaml:"machine_auth_api_key" env:"OCIS_MACHINE_AUTH_API_KEY;SEARCH_MACHINE_AUTH_API_KEY"`
+
+	Context context.Context `ocisConfig:"-" yaml:"-"`
+}
+
+// Events combines the configuration options for the event bus.
+type Events struct {
+	Endpoint      string `yaml:"events_endpoint" env:"SEARCH_EVENTS_ENDPOINT" desc:"the address of the streaming service"`
+	Cluster       string `yaml:"events_cluster" env:"SEARCH_EVENTS_CLUSTER" desc:"the clusterID of the streaming service. Mandatory when using nats"`
+	ConsumerGroup string `yaml:"events_group" env:"SEARCH_EVENTS_GROUP" desc:"the customergroup of the service. One group will only get one copy of an event"`
+}
diff --git a/extensions/search/pkg/config/debug.go b/extensions/search/pkg/config/debug.go
new file mode 100644
index 00000000000..a6ab80cbdaa
--- /dev/null
+++ b/extensions/search/pkg/config/debug.go
@@ -0,0 +1,9 @@
+package config
+
+// Debug defines the available debug configuration.
+type Debug struct {
+	Addr   string `ocisConfig:"addr" env:"SEARCH_DEBUG_ADDR"`
+	Token  string `ocisConfig:"token" env:"SEARCH_DEBUG_TOKEN"`
+	Pprof  bool   `ocisConfig:"pprof" env:"SEARCH_DEBUG_PPROF"`
+	Zpages bool   `ocisConfig:"zpages" env:"SEARCH_DEBUG_ZPAGES"`
+}
diff --git a/extensions/search/pkg/config/defaults/defaultconfig.go b/extensions/search/pkg/config/defaults/defaultconfig.go
new file mode 100644
index 00000000000..c3c40a7fea4
--- /dev/null
+++ b/extensions/search/pkg/config/defaults/defaultconfig.go
@@ -0,0 +1,75 @@
+package defaults
+
+import (
+	"path"
+
+	"github.com/owncloud/ocis/extensions/search/pkg/config"
+	"github.com/owncloud/ocis/ocis-pkg/config/defaults"
+)
+
+func FullDefaultConfig() *config.Config {
+	cfg := DefaultConfig()
+
+	EnsureDefaults(cfg)
+
+	return cfg
+}
+
+func DefaultConfig() *config.Config {
+	return &config.Config{
+		Debug: config.Debug{
+			Addr:  "127.0.0.1:9224",
+			Token: "",
+		},
+		GRPC: config.GRPC{
+			Addr:      "127.0.0.1:9220",
+			Namespace: "com.owncloud.api",
+		},
+		Service: config.Service{
+			Name: "search",
+		},
+		Datapath: path.Join(defaults.BaseDataPath(), "search"),
+		Reva: config.Reva{
+			Address: "127.0.0.1:9142",
+		},
+		Events: config.Events{
+			Endpoint:      "127.0.0.1:9233",
+			Cluster:       "ocis-cluster",
+			ConsumerGroup: "search",
+		},
+		MachineAuthAPIKey: "change-me-please",
+	}
+}
+
+func EnsureDefaults(cfg *config.Config) {
+	// provide with defaults for shared logging, since we need a valid destination address for BindEnv.
+	if cfg.Log == nil && cfg.Commons != nil && cfg.Commons.Log != nil {
+		cfg.Log = &config.Log{
+			Level:  cfg.Commons.Log.Level,
+			Pretty: cfg.Commons.Log.Pretty,
+			Color:  cfg.Commons.Log.Color,
+			File:   cfg.Commons.Log.File,
+		}
+	} else if cfg.Log == nil {
+		cfg.Log = &config.Log{}
+	}
+	// provide with defaults for shared tracing, since we need a valid destination address for BindEnv.
+	if cfg.Tracing == nil && cfg.Commons != nil && cfg.Commons.Tracing != nil {
+		cfg.Tracing = &config.Tracing{
+			Enabled:   cfg.Commons.Tracing.Enabled,
+			Type:      cfg.Commons.Tracing.Type,
+			Endpoint:  cfg.Commons.Tracing.Endpoint,
+			Collector: cfg.Commons.Tracing.Collector,
+		}
+	} else if cfg.Tracing == nil {
+		cfg.Tracing = &config.Tracing{}
+	}
+
+	if cfg.MachineAuthAPIKey == "" && cfg.Commons != nil && cfg.Commons.MachineAuthAPIKey != "" {
+		cfg.MachineAuthAPIKey = cfg.Commons.MachineAuthAPIKey
+	}
+}
+
+func Sanitize(cfg *config.Config) {
+	// no http endpoint to be sanitized
+}
diff --git a/extensions/search/pkg/config/grpc.go b/extensions/search/pkg/config/grpc.go
new file mode 100644
index 00000000000..148fda92505
--- /dev/null
+++ b/extensions/search/pkg/config/grpc.go
@@ -0,0 +1,7 @@
+package config
+
+// GRPC defines the available grpc configuration.
+type GRPC struct {
+	Addr      string `ocisConfig:"addr" env:"ACCOUNTS_GRPC_ADDR" desc:"The address of the grpc service."`
+	Namespace string `ocisConfig:"-" yaml:"-"`
+}
diff --git a/extensions/search/pkg/config/http.go b/extensions/search/pkg/config/http.go
new file mode 100644
index 00000000000..018f8c551f1
--- /dev/null
+++ b/extensions/search/pkg/config/http.go
@@ -0,0 +1,8 @@
+package config
+
+// HTTP defines the available http configuration.
+type HTTP struct {
+	Addr      string `ocisConfig:"addr" env:"SEARCH_HTTP_ADDR"`
+	Namespace string `ocisConfig:"-" yaml:"-"`
+	Root      string `ocisConfig:"root" env:"SEARCH_HTTP_ROOT"`
+}
diff --git a/extensions/search/pkg/config/log.go b/extensions/search/pkg/config/log.go
new file mode 100644
index 00000000000..6e4d2fa9386
--- /dev/null
+++ b/extensions/search/pkg/config/log.go
@@ -0,0 +1,9 @@
+package config
+
+// Log defines the available log configuration.
+type Log struct {
+	Level  string `mapstructure:"level" env:"OCIS_LOG_LEVEL;SEARCH_LOG_LEVEL"`
+	Pretty bool   `mapstructure:"pretty" env:"OCIS_LOG_PRETTY;SEARCH_LOG_PRETTY"`
+	Color  bool   `mapstructure:"color" env:"OCIS_LOG_COLOR;SEARCH_LOG_COLOR"`
+	File   string `mapstructure:"file" env:"OCIS_LOG_FILE;SEARCH_LOG_FILE"`
+}
diff --git a/extensions/search/pkg/config/parser/parse.go b/extensions/search/pkg/config/parser/parse.go
new file mode 100644
index 00000000000..9183f5be88c
--- /dev/null
+++ b/extensions/search/pkg/config/parser/parse.go
@@ -0,0 +1,41 @@
+package parser
+
+import (
+	"errors"
+
+	"github.com/owncloud/ocis/extensions/search/pkg/config"
+	"github.com/owncloud/ocis/extensions/search/pkg/config/defaults"
+	ociscfg "github.com/owncloud/ocis/ocis-pkg/config"
+	"github.com/owncloud/ocis/ocis-pkg/shared"
+
+	"github.com/owncloud/ocis/ocis-pkg/config/envdecode"
+)
+
+// ParseConfig loads configuration from known paths.
+func ParseConfig(cfg *config.Config) error {
+	_, err := ociscfg.BindSourcesToStructs(cfg.Service.Name, cfg)
+	if err != nil {
+		return err
+	}
+
+	defaults.EnsureDefaults(cfg)
+
+	// load all env variables relevant to the config in the current context.
+	if err := envdecode.Decode(cfg); err != nil {
+		// no environment variable set for this config is an expected "error"
+		if !errors.Is(err, envdecode.ErrNoTargetFieldsAreSet) {
+			return err
+		}
+	}
+
+	defaults.Sanitize(cfg)
+
+	return Validate(cfg)
+}
+
+func Validate(cfg *config.Config) error {
+	if cfg.MachineAuthAPIKey == "" {
+		return shared.MissingMachineAuthApiKeyError(cfg.Service.Name)
+	}
+	return nil
+}
diff --git a/extensions/search/pkg/config/reva.go b/extensions/search/pkg/config/reva.go
new file mode 100644
index 00000000000..2b299a0f651
--- /dev/null
+++ b/extensions/search/pkg/config/reva.go
@@ -0,0 +1,6 @@
+package config
+
+// Reva defines all available REVA configuration.
+type Reva struct {
+	Address string `ocisConfig:"address" env:"REVA_GATEWAY"`
+}
diff --git a/extensions/search/pkg/config/service.go b/extensions/search/pkg/config/service.go
new file mode 100644
index 00000000000..c019b73046e
--- /dev/null
+++ b/extensions/search/pkg/config/service.go
@@ -0,0 +1,6 @@
+package config
+
+// Service defines the available service configuration.
+type Service struct {
+	Name string `ocisConfig:"-" yaml:"-"`
+}
diff --git a/extensions/search/pkg/config/tracing.go b/extensions/search/pkg/config/tracing.go
new file mode 100644
index 00000000000..50c49234d6c
--- /dev/null
+++ b/extensions/search/pkg/config/tracing.go
@@ -0,0 +1,9 @@
+package config
+
+// Tracing defines the available tracing configuration.
+type Tracing struct {
+	Enabled   bool   `ocisConfig:"enabled" env:"OCIS_TRACING_ENABLED;SEARCH_TRACING_ENABLED"`
+	Type      string `ocisConfig:"type" env:"OCIS_TRACING_TYPE;SEARCH_TRACING_TYPE"`
+	Endpoint  string `ocisConfig:"endpoint" env:"OCIS_TRACING_ENDPOINT;SEARCH_TRACING_ENDPOINT"`
+	Collector string `ocisConfig:"collector" env:"OCIS_TRACING_COLLECTOR;SEARCH_TRACING_COLLECTOR"`
+}
diff --git a/extensions/search/pkg/logging/logging.go b/extensions/search/pkg/logging/logging.go
new file mode 100644
index 00000000000..7d80232313d
--- /dev/null
+++ b/extensions/search/pkg/logging/logging.go
@@ -0,0 +1,17 @@
+package logging
+
+import (
+	"github.com/owncloud/ocis/extensions/search/pkg/config"
+	"github.com/owncloud/ocis/ocis-pkg/log"
+)
+
+// LoggerFromConfig initializes a service-specific logger instance.
+func Configure(name string, cfg *config.Log) log.Logger {
+	return log.NewLogger(
+		log.Name(name),
+		log.Level(cfg.Level),
+		log.Pretty(cfg.Pretty),
+		log.Color(cfg.Color),
+		log.File(cfg.File),
+	)
+}
diff --git a/extensions/search/pkg/metrics/metrics.go b/extensions/search/pkg/metrics/metrics.go
new file mode 100644
index 00000000000..c3076bb0195
--- /dev/null
+++ b/extensions/search/pkg/metrics/metrics.go
@@ -0,0 +1,33 @@
+package metrics
+
+import "github.com/prometheus/client_golang/prometheus"
+
+var (
+	// Namespace defines the namespace for the defines metrics.
+	Namespace = "ocis"
+
+	// Subsystem defines the subsystem for the defines metrics.
+	Subsystem = "search"
+)
+
+// Metrics defines the available metrics of this service.
+type Metrics struct {
+	// Counter  *prometheus.CounterVec
+	BuildInfo *prometheus.GaugeVec
+}
+
+// New initializes the available metrics.
+func New() *Metrics {
+	m := &Metrics{
+		BuildInfo: prometheus.NewGaugeVec(prometheus.GaugeOpts{
+			Namespace: Namespace,
+			Subsystem: Subsystem,
+			Name:      "build_info",
+			Help:      "Build information",
+		}, []string{"version"}),
+	}
+
+	_ = prometheus.Register(m.BuildInfo)
+	// TODO: implement metrics
+	return m
+}
diff --git a/extensions/search/pkg/search/index/index.go b/extensions/search/pkg/search/index/index.go
new file mode 100644
index 00000000000..8cba7f7a6ca
--- /dev/null
+++ b/extensions/search/pkg/search/index/index.go
@@ -0,0 +1,311 @@
+// Copyright 2018-2022 CERN
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// In applying this license, CERN does not waive the privileges and immunities
+// granted to it by virtue of its status as an Intergovernmental Organization
+// or submit itself to any jurisdiction.
+
+package index
+
+import (
+	"context"
+	"errors"
+	"path"
+	"strings"
+	"time"
+
+	"github.com/blevesearch/bleve/v2"
+	"github.com/blevesearch/bleve/v2/analysis/analyzer/keyword"
+	"github.com/blevesearch/bleve/v2/mapping"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	sprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
+	"github.com/cs3org/reva/v2/pkg/utils"
+	searchmsg "github.com/owncloud/ocis/protogen/gen/ocis/messages/search/v0"
+	searchsvc "github.com/owncloud/ocis/protogen/gen/ocis/services/search/v0"
+)
+
+type indexDocument struct {
+	RootID string
+	Path   string
+	ID     string
+
+	Name     string
+	Size     uint64
+	Mtime    string
+	MimeType string
+	Type     uint64
+
+	Deleted bool
+}
+
+// Index represents a bleve based search index
+type Index struct {
+	bleveIndex bleve.Index
+}
+
+// NewPersisted returns a new instance of Index with the data being persisted in the given directory
+func NewPersisted(path string) (*Index, error) {
+	bi, err := bleve.New(path, BuildMapping())
+	if err != nil {
+		return nil, err
+	}
+	return New(bi)
+}
+
+// New returns a new instance of Index using the given bleve Index as the backend
+func New(bleveIndex bleve.Index) (*Index, error) {
+	return &Index{
+		bleveIndex: bleveIndex,
+	}, nil
+}
+
+// DocCount returns the number of elemenst in the index
+func (i *Index) DocCount() (uint64, error) {
+	return i.bleveIndex.DocCount()
+}
+
+// Add adds a new entity to the Index
+func (i *Index) Add(ref *sprovider.Reference, ri *sprovider.ResourceInfo) error {
+	entity := toEntity(ref, ri)
+	return i.bleveIndex.Index(idToBleveId(ri.Id), entity)
+}
+
+// Delete marks an entity from the index as deleten (still keeping it around)
+func (i *Index) Delete(id *sprovider.ResourceId) error {
+	return i.markAsDeleted(idToBleveId(id), true)
+}
+
+// Restore marks an entity from the index as not being deleted
+func (i *Index) Restore(id *sprovider.ResourceId) error {
+	return i.markAsDeleted(idToBleveId(id), false)
+}
+
+func (i *Index) markAsDeleted(id string, deleted bool) error {
+	doc, err := i.updateEntity(id, func(doc *indexDocument) {
+		doc.Deleted = deleted
+	})
+	if err != nil {
+		return err
+	}
+
+	if doc.Type == uint64(sprovider.ResourceType_RESOURCE_TYPE_CONTAINER) {
+		query := bleve.NewConjunctionQuery(
+			bleve.NewQueryStringQuery("RootID:"+doc.RootID),
+			bleve.NewQueryStringQuery("Path:"+doc.Path+"/*"),
+		)
+		bleveReq := bleve.NewSearchRequest(query)
+		bleveReq.Fields = []string{"*"}
+		res, err := i.bleveIndex.Search(bleveReq)
+		if err != nil {
+			return err
+		}
+
+		for _, h := range res.Hits {
+			_, err := i.updateEntity(h.ID, func(doc *indexDocument) {
+				doc.Deleted = deleted
+			})
+			if err != nil {
+				return err
+			}
+		}
+	}
+
+	return nil
+}
+
+func (i *Index) updateEntity(id string, mutateFunc func(doc *indexDocument)) (*indexDocument, error) {
+	doc, err := i.getEntity(id)
+	if err != nil {
+		return nil, err
+	}
+	mutateFunc(doc)
+	err = i.bleveIndex.Index(doc.ID, doc)
+	if err != nil {
+		return nil, err
+	}
+
+	return doc, nil
+}
+
+func (i *Index) getEntity(id string) (*indexDocument, error) {
+	req := bleve.NewSearchRequest(bleve.NewDocIDQuery([]string{id}))
+	req.Fields = []string{"*"}
+	res, err := i.bleveIndex.Search(req)
+	if err != nil {
+		return nil, err
+	}
+	if res.Hits.Len() == 0 {
+		return nil, errors.New("entity not found")
+	}
+	return fieldsToEntity(res.Hits[0].Fields), nil
+}
+
+// Purge removes an entity from the index
+func (i *Index) Purge(id *sprovider.ResourceId) error {
+	return i.bleveIndex.Delete(idToBleveId(id))
+}
+
+// Purge removes an entity from the index
+func (i *Index) Move(ri *sprovider.ResourceInfo) error {
+	doc, err := i.getEntity(idToBleveId(ri.Id))
+	if err != nil {
+		return err
+	}
+	oldName := doc.Path
+	newName := utils.MakeRelativePath(ri.Path)
+
+	doc, err = i.updateEntity(idToBleveId(ri.Id), func(doc *indexDocument) {
+		doc.Path = newName
+		doc.Name = path.Base(newName)
+	})
+	if err != nil {
+		return err
+	}
+
+	if doc.Type == uint64(sprovider.ResourceType_RESOURCE_TYPE_CONTAINER) {
+		query := bleve.NewConjunctionQuery(
+			bleve.NewQueryStringQuery("RootID:"+doc.RootID),
+			bleve.NewQueryStringQuery("Path:"+oldName+"/*"),
+		)
+		bleveReq := bleve.NewSearchRequest(query)
+		bleveReq.Fields = []string{"*"}
+		res, err := i.bleveIndex.Search(bleveReq)
+		if err != nil {
+			return err
+		}
+
+		for _, h := range res.Hits {
+			_, err := i.updateEntity(h.ID, func(doc *indexDocument) {
+				doc.Path = strings.Replace(doc.Path, oldName, newName, 1)
+			})
+			if err != nil {
+				return err
+			}
+		}
+	}
+
+	return nil
+}
+
+// Search searches the index according to the criteria specified in the given SearchIndexRequest
+func (i *Index) Search(ctx context.Context, req *searchsvc.SearchIndexRequest) (*searchsvc.SearchIndexResponse, error) {
+	deletedQuery := bleve.NewBoolFieldQuery(false)
+	deletedQuery.SetField("Deleted")
+	query := bleve.NewConjunctionQuery(
+		bleve.NewQueryStringQuery("Name:"+req.Query),
+		deletedQuery, // Skip documents that have been marked as deleted
+		bleve.NewQueryStringQuery("RootID:"+req.Ref.ResourceId.StorageId+"!"+req.Ref.ResourceId.OpaqueId), // Limit search to the space
+		bleve.NewQueryStringQuery("Path:"+req.Ref.Path+"*"),                                               // Limit search to this directory in the space
+	)
+	bleveReq := bleve.NewSearchRequest(query)
+	bleveReq.Size = 200
+	bleveReq.Fields = []string{"*"}
+	res, err := i.bleveIndex.Search(bleveReq)
+	if err != nil {
+		return nil, err
+	}
+
+	matches := []*searchmsg.Match{}
+	for _, h := range res.Hits {
+		match, err := fromFields(h.Fields)
+		if err != nil {
+			return nil, err
+		}
+		matches = append(matches, match)
+	}
+
+	return &searchsvc.SearchIndexResponse{
+		Matches: matches,
+	}, nil
+}
+
+// BuildMapping builds a bleve index mapping which can be used for indexing
+func BuildMapping() mapping.IndexMapping {
+	indexMapping := bleve.NewIndexMapping()
+	indexMapping.DefaultAnalyzer = keyword.Name
+	return indexMapping
+}
+
+func toEntity(ref *sprovider.Reference, ri *sprovider.ResourceInfo) *indexDocument {
+	doc := &indexDocument{
+		RootID:   idToBleveId(ref.ResourceId),
+		Path:     ref.Path,
+		ID:       idToBleveId(ri.Id),
+		Name:     ri.Path,
+		Size:     ri.Size,
+		MimeType: ri.MimeType,
+		Type:     uint64(ri.Type),
+		Deleted:  false,
+	}
+
+	if ri.Mtime != nil {
+		doc.Mtime = time.Unix(int64(ri.Mtime.Seconds), int64(ri.Mtime.Nanos)).UTC().Format(time.RFC3339)
+	}
+
+	return doc
+}
+
+func fieldsToEntity(fields map[string]interface{}) *indexDocument {
+	doc := &indexDocument{
+		RootID:   fields["RootID"].(string),
+		Path:     fields["Path"].(string),
+		ID:       fields["ID"].(string),
+		Name:     fields["Name"].(string),
+		Size:     uint64(fields["Size"].(float64)),
+		Mtime:    fields["Mtime"].(string),
+		MimeType: fields["MimeType"].(string),
+		Type:     uint64(fields["Type"].(float64)),
+	}
+	return doc
+}
+
+func fromFields(fields map[string]interface{}) (*searchmsg.Match, error) {
+	rootIDParts := strings.SplitN(fields["RootID"].(string), "!", 2)
+	IDParts := strings.SplitN(fields["ID"].(string), "!", 2)
+
+	match := &searchmsg.Match{
+		Entity: &searchmsg.Entity{
+			Ref: &searchmsg.Reference{
+				ResourceId: &searchmsg.ResourceID{
+					StorageId: rootIDParts[0],
+					OpaqueId:  rootIDParts[1],
+				},
+				Path: fields["Path"].(string),
+			},
+			Id: &searchmsg.ResourceID{
+				StorageId: IDParts[0],
+				OpaqueId:  IDParts[1],
+			},
+			Name:     fields["Name"].(string),
+			Size:     uint64(fields["Size"].(float64)),
+			Type:     uint64(fields["Type"].(float64)),
+			MimeType: fields["MimeType"].(string),
+			Deleted:  fields["Deleted"].(bool),
+		},
+	}
+
+	if mtime, err := time.Parse(time.RFC3339, fields["Mtime"].(string)); err == nil {
+		match.Entity.LastModifiedTime = &timestamppb.Timestamp{Seconds: mtime.Unix(), Nanos: int32(mtime.Nanosecond())}
+	}
+
+	return match, nil
+}
+
+func idToBleveId(id *sprovider.ResourceId) string {
+	if id == nil {
+		return ""
+	}
+	return id.StorageId + "!" + id.OpaqueId
+}
diff --git a/extensions/search/pkg/search/index/mocks/BleveIndex.go b/extensions/search/pkg/search/index/mocks/BleveIndex.go
new file mode 100644
index 00000000000..1bb4f1b28d3
--- /dev/null
+++ b/extensions/search/pkg/search/index/mocks/BleveIndex.go
@@ -0,0 +1,415 @@
+// Code generated by mockery v2.10.0. DO NOT EDIT.
+
+package mocks
+
+import (
+	context "context"
+
+	bleve "github.com/blevesearch/bleve/v2"
+
+	index "github.com/blevesearch/bleve_index_api"
+
+	mapping "github.com/blevesearch/bleve/v2/mapping"
+
+	mock "github.com/stretchr/testify/mock"
+)
+
+// BleveIndex is an autogenerated mock type for the BleveIndex type
+type BleveIndex struct {
+	mock.Mock
+}
+
+// Advanced provides a mock function with given fields:
+func (_m *BleveIndex) Advanced() (index.Index, error) {
+	ret := _m.Called()
+
+	var r0 index.Index
+	if rf, ok := ret.Get(0).(func() index.Index); ok {
+		r0 = rf()
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(index.Index)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func() error); ok {
+		r1 = rf()
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// Batch provides a mock function with given fields: b
+func (_m *BleveIndex) Batch(b *bleve.Batch) error {
+	ret := _m.Called(b)
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(*bleve.Batch) error); ok {
+		r0 = rf(b)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// Close provides a mock function with given fields:
+func (_m *BleveIndex) Close() error {
+	ret := _m.Called()
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func() error); ok {
+		r0 = rf()
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// Delete provides a mock function with given fields: id
+func (_m *BleveIndex) Delete(id string) error {
+	ret := _m.Called(id)
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(string) error); ok {
+		r0 = rf(id)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// DeleteInternal provides a mock function with given fields: key
+func (_m *BleveIndex) DeleteInternal(key []byte) error {
+	ret := _m.Called(key)
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func([]byte) error); ok {
+		r0 = rf(key)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// DocCount provides a mock function with given fields:
+func (_m *BleveIndex) DocCount() (uint64, error) {
+	ret := _m.Called()
+
+	var r0 uint64
+	if rf, ok := ret.Get(0).(func() uint64); ok {
+		r0 = rf()
+	} else {
+		r0 = ret.Get(0).(uint64)
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func() error); ok {
+		r1 = rf()
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// Document provides a mock function with given fields: id
+func (_m *BleveIndex) Document(id string) (index.Document, error) {
+	ret := _m.Called(id)
+
+	var r0 index.Document
+	if rf, ok := ret.Get(0).(func(string) index.Document); ok {
+		r0 = rf(id)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(index.Document)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(string) error); ok {
+		r1 = rf(id)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// FieldDict provides a mock function with given fields: field
+func (_m *BleveIndex) FieldDict(field string) (index.FieldDict, error) {
+	ret := _m.Called(field)
+
+	var r0 index.FieldDict
+	if rf, ok := ret.Get(0).(func(string) index.FieldDict); ok {
+		r0 = rf(field)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(index.FieldDict)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(string) error); ok {
+		r1 = rf(field)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// FieldDictPrefix provides a mock function with given fields: field, termPrefix
+func (_m *BleveIndex) FieldDictPrefix(field string, termPrefix []byte) (index.FieldDict, error) {
+	ret := _m.Called(field, termPrefix)
+
+	var r0 index.FieldDict
+	if rf, ok := ret.Get(0).(func(string, []byte) index.FieldDict); ok {
+		r0 = rf(field, termPrefix)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(index.FieldDict)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(string, []byte) error); ok {
+		r1 = rf(field, termPrefix)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// FieldDictRange provides a mock function with given fields: field, startTerm, endTerm
+func (_m *BleveIndex) FieldDictRange(field string, startTerm []byte, endTerm []byte) (index.FieldDict, error) {
+	ret := _m.Called(field, startTerm, endTerm)
+
+	var r0 index.FieldDict
+	if rf, ok := ret.Get(0).(func(string, []byte, []byte) index.FieldDict); ok {
+		r0 = rf(field, startTerm, endTerm)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(index.FieldDict)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(string, []byte, []byte) error); ok {
+		r1 = rf(field, startTerm, endTerm)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// Fields provides a mock function with given fields:
+func (_m *BleveIndex) Fields() ([]string, error) {
+	ret := _m.Called()
+
+	var r0 []string
+	if rf, ok := ret.Get(0).(func() []string); ok {
+		r0 = rf()
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]string)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func() error); ok {
+		r1 = rf()
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// GetInternal provides a mock function with given fields: key
+func (_m *BleveIndex) GetInternal(key []byte) ([]byte, error) {
+	ret := _m.Called(key)
+
+	var r0 []byte
+	if rf, ok := ret.Get(0).(func([]byte) []byte); ok {
+		r0 = rf(key)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]byte)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func([]byte) error); ok {
+		r1 = rf(key)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// Index provides a mock function with given fields: id, data
+func (_m *BleveIndex) Index(id string, data interface{}) error {
+	ret := _m.Called(id, data)
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(string, interface{}) error); ok {
+		r0 = rf(id, data)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// Mapping provides a mock function with given fields:
+func (_m *BleveIndex) Mapping() mapping.IndexMapping {
+	ret := _m.Called()
+
+	var r0 mapping.IndexMapping
+	if rf, ok := ret.Get(0).(func() mapping.IndexMapping); ok {
+		r0 = rf()
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(mapping.IndexMapping)
+		}
+	}
+
+	return r0
+}
+
+// Name provides a mock function with given fields:
+func (_m *BleveIndex) Name() string {
+	ret := _m.Called()
+
+	var r0 string
+	if rf, ok := ret.Get(0).(func() string); ok {
+		r0 = rf()
+	} else {
+		r0 = ret.Get(0).(string)
+	}
+
+	return r0
+}
+
+// NewBatch provides a mock function with given fields:
+func (_m *BleveIndex) NewBatch() *bleve.Batch {
+	ret := _m.Called()
+
+	var r0 *bleve.Batch
+	if rf, ok := ret.Get(0).(func() *bleve.Batch); ok {
+		r0 = rf()
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*bleve.Batch)
+		}
+	}
+
+	return r0
+}
+
+// Search provides a mock function with given fields: req
+func (_m *BleveIndex) Search(req *bleve.SearchRequest) (*bleve.SearchResult, error) {
+	ret := _m.Called(req)
+
+	var r0 *bleve.SearchResult
+	if rf, ok := ret.Get(0).(func(*bleve.SearchRequest) *bleve.SearchResult); ok {
+		r0 = rf(req)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*bleve.SearchResult)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(*bleve.SearchRequest) error); ok {
+		r1 = rf(req)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// SearchInContext provides a mock function with given fields: ctx, req
+func (_m *BleveIndex) SearchInContext(ctx context.Context, req *bleve.SearchRequest) (*bleve.SearchResult, error) {
+	ret := _m.Called(ctx, req)
+
+	var r0 *bleve.SearchResult
+	if rf, ok := ret.Get(0).(func(context.Context, *bleve.SearchRequest) *bleve.SearchResult); ok {
+		r0 = rf(ctx, req)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*bleve.SearchResult)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(context.Context, *bleve.SearchRequest) error); ok {
+		r1 = rf(ctx, req)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// SetInternal provides a mock function with given fields: key, val
+func (_m *BleveIndex) SetInternal(key []byte, val []byte) error {
+	ret := _m.Called(key, val)
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func([]byte, []byte) error); ok {
+		r0 = rf(key, val)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// SetName provides a mock function with given fields: _a0
+func (_m *BleveIndex) SetName(_a0 string) {
+	_m.Called(_a0)
+}
+
+// Stats provides a mock function with given fields:
+func (_m *BleveIndex) Stats() *bleve.IndexStat {
+	ret := _m.Called()
+
+	var r0 *bleve.IndexStat
+	if rf, ok := ret.Get(0).(func() *bleve.IndexStat); ok {
+		r0 = rf()
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*bleve.IndexStat)
+		}
+	}
+
+	return r0
+}
+
+// StatsMap provides a mock function with given fields:
+func (_m *BleveIndex) StatsMap() map[string]interface{} {
+	ret := _m.Called()
+
+	var r0 map[string]interface{}
+	if rf, ok := ret.Get(0).(func() map[string]interface{}); ok {
+		r0 = rf()
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(map[string]interface{})
+		}
+	}
+
+	return r0
+}
diff --git a/extensions/search/pkg/search/mocks/IndexClient.go b/extensions/search/pkg/search/mocks/IndexClient.go
new file mode 100644
index 00000000000..f87fd726441
--- /dev/null
+++ b/extensions/search/pkg/search/mocks/IndexClient.go
@@ -0,0 +1,131 @@
+// Code generated by mockery v2.10.0. DO NOT EDIT.
+
+package mocks
+
+import (
+	context "context"
+
+	providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
+	mock "github.com/stretchr/testify/mock"
+
+	v0 "github.com/owncloud/ocis/protogen/gen/ocis/services/search/v0"
+)
+
+// IndexClient is an autogenerated mock type for the IndexClient type
+type IndexClient struct {
+	mock.Mock
+}
+
+// Add provides a mock function with given fields: ref, ri
+func (_m *IndexClient) Add(ref *providerv1beta1.Reference, ri *providerv1beta1.ResourceInfo) error {
+	ret := _m.Called(ref, ri)
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(*providerv1beta1.Reference, *providerv1beta1.ResourceInfo) error); ok {
+		r0 = rf(ref, ri)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// Delete provides a mock function with given fields: ri
+func (_m *IndexClient) Delete(ri *providerv1beta1.ResourceId) error {
+	ret := _m.Called(ri)
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(*providerv1beta1.ResourceId) error); ok {
+		r0 = rf(ri)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// DocCount provides a mock function with given fields:
+func (_m *IndexClient) DocCount() (uint64, error) {
+	ret := _m.Called()
+
+	var r0 uint64
+	if rf, ok := ret.Get(0).(func() uint64); ok {
+		r0 = rf()
+	} else {
+		r0 = ret.Get(0).(uint64)
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func() error); ok {
+		r1 = rf()
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// Move provides a mock function with given fields: ri
+func (_m *IndexClient) Move(ri *providerv1beta1.ResourceInfo) error {
+	ret := _m.Called(ri)
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(*providerv1beta1.ResourceInfo) error); ok {
+		r0 = rf(ri)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// Purge provides a mock function with given fields: ri
+func (_m *IndexClient) Purge(ri *providerv1beta1.ResourceId) error {
+	ret := _m.Called(ri)
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(*providerv1beta1.ResourceId) error); ok {
+		r0 = rf(ri)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// Restore provides a mock function with given fields: ri
+func (_m *IndexClient) Restore(ri *providerv1beta1.ResourceId) error {
+	ret := _m.Called(ri)
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(*providerv1beta1.ResourceId) error); ok {
+		r0 = rf(ri)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// Search provides a mock function with given fields: ctx, req
+func (_m *IndexClient) Search(ctx context.Context, req *v0.SearchIndexRequest) (*v0.SearchIndexResponse, error) {
+	ret := _m.Called(ctx, req)
+
+	var r0 *v0.SearchIndexResponse
+	if rf, ok := ret.Get(0).(func(context.Context, *v0.SearchIndexRequest) *v0.SearchIndexResponse); ok {
+		r0 = rf(ctx, req)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*v0.SearchIndexResponse)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(context.Context, *v0.SearchIndexRequest) error); ok {
+		r1 = rf(ctx, req)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
diff --git a/extensions/search/pkg/search/mocks/ProviderClient.go b/extensions/search/pkg/search/mocks/ProviderClient.go
new file mode 100644
index 00000000000..d1d551909e6
--- /dev/null
+++ b/extensions/search/pkg/search/mocks/ProviderClient.go
@@ -0,0 +1,62 @@
+// Code generated by mockery v2.10.0. DO NOT EDIT.
+
+package mocks
+
+import (
+	context "context"
+
+	mock "github.com/stretchr/testify/mock"
+
+	v0 "github.com/owncloud/ocis/protogen/gen/ocis/services/search/v0"
+)
+
+// ProviderClient is an autogenerated mock type for the ProviderClient type
+type ProviderClient struct {
+	mock.Mock
+}
+
+// IndexSpace provides a mock function with given fields: ctx, req
+func (_m *ProviderClient) IndexSpace(ctx context.Context, req *v0.IndexSpaceRequest) (*v0.IndexSpaceResponse, error) {
+	ret := _m.Called(ctx, req)
+
+	var r0 *v0.IndexSpaceResponse
+	if rf, ok := ret.Get(0).(func(context.Context, *v0.IndexSpaceRequest) *v0.IndexSpaceResponse); ok {
+		r0 = rf(ctx, req)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*v0.IndexSpaceResponse)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(context.Context, *v0.IndexSpaceRequest) error); ok {
+		r1 = rf(ctx, req)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// Search provides a mock function with given fields: ctx, req
+func (_m *ProviderClient) Search(ctx context.Context, req *v0.SearchRequest) (*v0.SearchResponse, error) {
+	ret := _m.Called(ctx, req)
+
+	var r0 *v0.SearchResponse
+	if rf, ok := ret.Get(0).(func(context.Context, *v0.SearchRequest) *v0.SearchResponse); ok {
+		r0 = rf(ctx, req)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*v0.SearchResponse)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(context.Context, *v0.SearchRequest) error); ok {
+		r1 = rf(ctx, req)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
diff --git a/extensions/search/pkg/search/provider/searchprovider.go b/extensions/search/pkg/search/provider/searchprovider.go
new file mode 100644
index 00000000000..3aa2f6cda1f
--- /dev/null
+++ b/extensions/search/pkg/search/provider/searchprovider.go
@@ -0,0 +1,278 @@
+package provider
+
+import (
+	"context"
+	"fmt"
+	"path/filepath"
+	"strings"
+
+	gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
+	user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
+	rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
+	rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
+	provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
+	typesv1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
+	ctxpkg "github.com/cs3org/reva/v2/pkg/ctx"
+	"github.com/cs3org/reva/v2/pkg/errtypes"
+	"github.com/cs3org/reva/v2/pkg/events"
+	"github.com/cs3org/reva/v2/pkg/storage/utils/walker"
+	"github.com/cs3org/reva/v2/pkg/utils"
+	"github.com/owncloud/ocis/extensions/search/pkg/search"
+	"github.com/owncloud/ocis/ocis-pkg/log"
+	"google.golang.org/grpc/metadata"
+
+	searchmsg "github.com/owncloud/ocis/protogen/gen/ocis/messages/search/v0"
+	searchsvc "github.com/owncloud/ocis/protogen/gen/ocis/services/search/v0"
+)
+
+type Provider struct {
+	logger            log.Logger
+	gwClient          gateway.GatewayAPIClient
+	indexClient       search.IndexClient
+	machineAuthAPIKey string
+}
+
+func New(gwClient gateway.GatewayAPIClient, indexClient search.IndexClient, machineAuthAPIKey string, eventsChan <-chan interface{}, logger log.Logger) *Provider {
+	p := &Provider{
+		gwClient:          gwClient,
+		indexClient:       indexClient,
+		machineAuthAPIKey: machineAuthAPIKey,
+		logger:            logger,
+	}
+
+	go func() {
+		for {
+			ev := <-eventsChan
+			var ref *provider.Reference
+			var owner *user.User
+			switch e := ev.(type) {
+			case events.ItemTrashed:
+				err := p.indexClient.Delete(e.ID)
+				if err != nil {
+					p.logger.Error().Err(err).Interface("Id", e.ID).Msg("failed to remove item from index")
+				}
+				continue
+			case events.ItemRestored:
+				ref = e.Ref
+				owner = &user.User{
+					Id: e.Executant,
+				}
+
+				statRes, err := p.statResource(ref, owner)
+				if err != nil {
+					p.logger.Error().Err(err).Msg("failed to stat the changed resource")
+				}
+
+				switch statRes.Status.Code {
+				case rpc.Code_CODE_OK:
+					err = p.indexClient.Restore(statRes.Info.Id)
+					if err != nil {
+						p.logger.Error().Err(err).Msg("failed to restore the changed resource in the index")
+					}
+				default:
+					p.logger.Error().Interface("statRes", statRes).Msg("failed to stat the changed resource")
+				}
+
+				continue
+			case events.ItemMoved:
+				ref = e.Ref
+				owner = &user.User{
+					Id: e.Executant,
+				}
+
+				statRes, err := p.statResource(ref, owner)
+				if err != nil {
+					p.logger.Error().Err(err).Msg("failed to stat the changed resource")
+				}
+
+				switch statRes.Status.Code {
+				case rpc.Code_CODE_OK:
+					err = p.indexClient.Move(statRes.Info)
+					if err != nil {
+						p.logger.Error().Err(err).Msg("failed to restore the changed resource in the index")
+					}
+				default:
+					p.logger.Error().Interface("statRes", statRes).Msg("failed to stat the changed resource")
+				}
+
+				continue
+			case events.ContainerCreated:
+				ref = e.Ref
+				owner = &user.User{
+					Id: e.Executant,
+				}
+			case events.FileUploaded:
+				ref = e.Ref
+				owner = &user.User{
+					Id: e.Executant,
+				}
+			case events.FileVersionRestored:
+				ref = e.Ref
+				owner = &user.User{
+					Id: e.Executant,
+				}
+			default:
+				// Not sure what to do here. Skip.
+				continue
+			}
+
+			statRes, err := p.statResource(ref, owner)
+			if err != nil {
+				p.logger.Error().Err(err).Msg("failed to stat the changed resource")
+			}
+
+			switch statRes.Status.Code {
+			case rpc.Code_CODE_OK:
+				err = p.indexClient.Add(ref, statRes.Info)
+				if err != nil {
+					p.logger.Error().Err(err).Msg("error adding updating the resource in the index")
+				} else {
+					p.logDocCount()
+				}
+			default:
+				p.logger.Error().Interface("statRes", statRes).Msg("failed to stat the changed resource")
+			}
+		}
+	}()
+
+	return p
+}
+
+func (p *Provider) statResource(ref *provider.Reference, owner *user.User) (*provider.StatResponse, error) {
+	// Get auth
+	ownerCtx := ctxpkg.ContextSetUser(context.Background(), owner)
+	authRes, err := p.gwClient.Authenticate(ownerCtx, &gateway.AuthenticateRequest{
+		Type:         "machine",
+		ClientId:     "userid:" + owner.Id.OpaqueId,
+		ClientSecret: p.machineAuthAPIKey,
+	})
+	if err != nil || authRes.GetStatus().GetCode() != rpc.Code_CODE_OK {
+		p.logger.Error().Err(err).Interface("authRes", authRes).Msg("error using machine auth")
+	}
+	ownerCtx = metadata.AppendToOutgoingContext(ownerCtx, ctxpkg.TokenHeader, authRes.Token)
+
+	// Stat changed resource resource
+	return p.gwClient.Stat(ownerCtx, &provider.StatRequest{Ref: ref})
+}
+
+func (p *Provider) logDocCount() {
+	c, err := p.indexClient.DocCount()
+	if err != nil {
+		p.logger.Error().Err(err).Msg("error getting document count from the index")
+	}
+	p.logger.Debug().Interface("count", c).Msg("new document count")
+}
+
+func (p *Provider) Search(ctx context.Context, req *searchsvc.SearchRequest) (*searchsvc.SearchResponse, error) {
+	if req.Query == "" {
+		return nil, errtypes.PreconditionFailed("empty query provided")
+	}
+
+	listSpacesRes, err := p.gwClient.ListStorageSpaces(ctx, &provider.ListStorageSpacesRequest{
+		Opaque: &typesv1beta1.Opaque{Map: map[string]*typesv1beta1.OpaqueEntry{
+			"path": {
+				Decoder: "plain",
+				Value:   []byte("/"),
+			},
+		}},
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	matches := []*searchmsg.Match{}
+	for _, space := range listSpacesRes.StorageSpaces {
+		pathPrefix := ""
+		if space.SpaceType == "grant" {
+			gpRes, err := p.gwClient.GetPath(ctx, &provider.GetPathRequest{
+				ResourceId: space.Root,
+			})
+			if err != nil {
+				return nil, err
+			}
+			if gpRes.Status.Code != rpcv1beta1.Code_CODE_OK {
+				return nil, errtypes.NewErrtypeFromStatus(gpRes.Status)
+			}
+			pathPrefix = utils.MakeRelativePath(gpRes.Path)
+		}
+
+		res, err := p.indexClient.Search(ctx, &searchsvc.SearchIndexRequest{
+			Query: req.Query,
+			Ref: &searchmsg.Reference{
+				ResourceId: &searchmsg.ResourceID{
+					StorageId: space.Root.StorageId,
+					OpaqueId:  space.Root.OpaqueId,
+				},
+				Path: pathPrefix,
+			},
+		})
+		if err != nil {
+			return nil, err
+		}
+
+		for _, match := range res.Matches {
+			if pathPrefix != "" {
+				match.Entity.Ref.Path = utils.MakeRelativePath(strings.TrimPrefix(match.Entity.Ref.Path, pathPrefix))
+			}
+			matches = append(matches, match)
+		}
+	}
+
+	return &searchsvc.SearchResponse{
+		Matches: matches,
+	}, nil
+}
+
+func (p *Provider) IndexSpace(ctx context.Context, req *searchsvc.IndexSpaceRequest) (*searchsvc.IndexSpaceResponse, error) {
+	// get user
+	res, err := p.gwClient.GetUserByClaim(context.Background(), &user.GetUserByClaimRequest{
+		Claim: "username",
+		Value: req.UserId,
+	})
+	if err != nil || res.Status.Code != rpc.Code_CODE_OK {
+		fmt.Println("error: Could not get user by userid")
+		return nil, err
+	}
+
+	// Get auth context
+	ownerCtx := ctxpkg.ContextSetUser(context.Background(), res.User)
+	authRes, err := p.gwClient.Authenticate(ownerCtx, &gateway.AuthenticateRequest{
+		Type:         "machine",
+		ClientId:     "userid:" + res.User.Id.OpaqueId,
+		ClientSecret: p.machineAuthAPIKey,
+	})
+	if err != nil || authRes.GetStatus().GetCode() != rpc.Code_CODE_OK {
+		return nil, err
+	}
+
+	if authRes.GetStatus().GetCode() != rpc.Code_CODE_OK {
+		return nil, fmt.Errorf("could not get authenticated context for user")
+	}
+	ownerCtx = metadata.AppendToOutgoingContext(ownerCtx, ctxpkg.TokenHeader, authRes.Token)
+
+	// Walk the space and index all files
+	walker := walker.NewWalker(p.gwClient)
+	rootId := &provider.ResourceId{StorageId: req.SpaceId, OpaqueId: req.SpaceId}
+	err = walker.Walk(ownerCtx, rootId, func(wd string, info *provider.ResourceInfo, err error) error {
+		if err != nil {
+			p.logger.Error().Err(err).Msg("error walking the tree")
+		}
+		ref := &provider.Reference{
+			Path:       utils.MakeRelativePath(filepath.Join(wd, info.Path)),
+			ResourceId: rootId,
+		}
+		err = p.indexClient.Add(ref, info)
+		if err != nil {
+			p.logger.Error().Err(err).Msg("error adding resource to the index")
+		} else {
+			p.logger.Debug().Interface("ref", ref).Msg("added resource to index")
+		}
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	p.logDocCount()
+	return &searchsvc.IndexSpaceResponse{}, nil
+}
diff --git a/extensions/search/pkg/search/search.go b/extensions/search/pkg/search/search.go
new file mode 100644
index 00000000000..14f0baf8c63
--- /dev/null
+++ b/extensions/search/pkg/search/search.go
@@ -0,0 +1,46 @@
+// Copyright 2018-2022 CERN
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// In applying this license, CERN does not waive the privileges and immunities
+// granted to it by virtue of its status as an Intergovernmental Organization
+// or submit itself to any jurisdiction.
+
+package search
+
+import (
+	"context"
+
+	providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
+	searchsvc "github.com/owncloud/ocis/protogen/gen/ocis/services/search/v0"
+)
+
+//go:generate mockery --name=ProviderClient
+//go:generate mockery --name=IndexClient
+
+// ProviderClient is the interface to the search provider service
+type ProviderClient interface {
+	Search(ctx context.Context, req *searchsvc.SearchRequest) (*searchsvc.SearchResponse, error)
+	IndexSpace(ctx context.Context, req *searchsvc.IndexSpaceRequest) (*searchsvc.IndexSpaceResponse, error)
+}
+
+// IndexClient is the interface to the search index
+type IndexClient interface {
+	Search(ctx context.Context, req *searchsvc.SearchIndexRequest) (*searchsvc.SearchIndexResponse, error)
+	Add(ref *providerv1beta1.Reference, ri *providerv1beta1.ResourceInfo) error
+	Move(ri *providerv1beta1.ResourceInfo) error
+	Delete(ri *providerv1beta1.ResourceId) error
+	Restore(ri *providerv1beta1.ResourceId) error
+	Purge(ri *providerv1beta1.ResourceId) error
+	DocCount() (uint64, error)
+}
diff --git a/extensions/search/pkg/server/debug/option.go b/extensions/search/pkg/server/debug/option.go
new file mode 100644
index 00000000000..33470673de6
--- /dev/null
+++ b/extensions/search/pkg/server/debug/option.go
@@ -0,0 +1,50 @@
+package debug
+
+import (
+	"context"
+
+	"github.com/owncloud/ocis/extensions/search/pkg/config"
+	"github.com/owncloud/ocis/ocis-pkg/log"
+)
+
+// Option defines a single option function.
+type Option func(o *Options)
+
+// Options defines the available options for this package.
+type Options struct {
+	Logger  log.Logger
+	Context context.Context
+	Config  *config.Config
+}
+
+// newOptions initializes the available default options.
+func newOptions(opts ...Option) Options {
+	opt := Options{}
+
+	for _, o := range opts {
+		o(&opt)
+	}
+
+	return opt
+}
+
+// Logger provides a function to set the logger option.
+func Logger(val log.Logger) Option {
+	return func(o *Options) {
+		o.Logger = val
+	}
+}
+
+// Context provides a function to set the context option.
+func Context(val context.Context) Option {
+	return func(o *Options) {
+		o.Context = val
+	}
+}
+
+// Config provides a function to set the config option.
+func Config(val *config.Config) Option {
+	return func(o *Options) {
+		o.Config = val
+	}
+}
diff --git a/extensions/search/pkg/server/debug/server.go b/extensions/search/pkg/server/debug/server.go
new file mode 100644
index 00000000000..8fe769df7a4
--- /dev/null
+++ b/extensions/search/pkg/server/debug/server.go
@@ -0,0 +1,59 @@
+package debug
+
+import (
+	"io"
+	"net/http"
+
+	"github.com/owncloud/ocis/extensions/search/pkg/config"
+	"github.com/owncloud/ocis/ocis-pkg/service/debug"
+	"github.com/owncloud/ocis/ocis-pkg/version"
+)
+
+// Server initializes the debug service and server.
+func Server(opts ...Option) (*http.Server, error) {
+	options := newOptions(opts...)
+
+	return debug.NewService(
+		debug.Logger(options.Logger),
+		debug.Name(options.Config.Service.Name),
+		debug.Version(version.String),
+		debug.Address(options.Config.Debug.Addr),
+		debug.Token(options.Config.Debug.Token),
+		debug.Pprof(options.Config.Debug.Pprof),
+		debug.Zpages(options.Config.Debug.Zpages),
+		debug.Health(health(options.Config)),
+		debug.Ready(ready(options.Config)),
+	), nil
+}
+
+// health implements the health check.
+func health(cfg *config.Config) func(http.ResponseWriter, *http.Request) {
+	return func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "text/plain")
+		w.WriteHeader(http.StatusOK)
+
+		// TODO: check if services are up and running
+
+		_, err := io.WriteString(w, http.StatusText(http.StatusOK))
+		// io.WriteString should not fail but if it does we want to know.
+		if err != nil {
+			panic(err)
+		}
+	}
+}
+
+// ready implements the ready check.
+func ready(cfg *config.Config) func(http.ResponseWriter, *http.Request) {
+	return func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "text/plain")
+		w.WriteHeader(http.StatusOK)
+
+		// TODO: check if services are up and running
+
+		_, err := io.WriteString(w, http.StatusText(http.StatusOK))
+		// io.WriteString should not fail but if it does we want to know.
+		if err != nil {
+			panic(err)
+		}
+	}
+}
diff --git a/extensions/search/pkg/server/grpc/option.go b/extensions/search/pkg/server/grpc/option.go
new file mode 100644
index 00000000000..b5378458417
--- /dev/null
+++ b/extensions/search/pkg/server/grpc/option.go
@@ -0,0 +1,85 @@
+package grpc
+
+import (
+	"context"
+
+	"github.com/owncloud/ocis/extensions/search/pkg/config"
+	"github.com/owncloud/ocis/extensions/search/pkg/metrics"
+	svc "github.com/owncloud/ocis/extensions/search/pkg/service/v0"
+	"github.com/owncloud/ocis/ocis-pkg/log"
+	"github.com/urfave/cli/v2"
+)
+
+// Option defines a single option function.
+type Option func(o *Options)
+
+// Options defines the available options for this package.
+type Options struct {
+	Name    string
+	Logger  log.Logger
+	Context context.Context
+	Config  *config.Config
+	Metrics *metrics.Metrics
+	Flags   []cli.Flag
+	Handler *svc.Service
+}
+
+// newOptions initializes the available default options.
+func newOptions(opts ...Option) Options {
+	opt := Options{}
+
+	for _, o := range opts {
+		o(&opt)
+	}
+
+	return opt
+}
+
+// Name provides a name for the service.
+func Name(val string) Option {
+	return func(o *Options) {
+		o.Name = val
+	}
+}
+
+// Logger provides a function to set the logger option.
+func Logger(val log.Logger) Option {
+	return func(o *Options) {
+		o.Logger = val
+	}
+}
+
+// Context provides a function to set the context option.
+func Context(val context.Context) Option {
+	return func(o *Options) {
+		o.Context = val
+	}
+}
+
+// Config provides a function to set the config option.
+func Config(val *config.Config) Option {
+	return func(o *Options) {
+		o.Config = val
+	}
+}
+
+// Metrics provides a function to set the metrics option.
+func Metrics(val *metrics.Metrics) Option {
+	return func(o *Options) {
+		o.Metrics = val
+	}
+}
+
+// Flags provides a function to set the flags option.
+func Flags(val []cli.Flag) Option {
+	return func(o *Options) {
+		o.Flags = append(o.Flags, val...)
+	}
+}
+
+// Handler provides a function to set the handler option.
+func Handler(val *svc.Service) Option {
+	return func(o *Options) {
+		o.Handler = val
+	}
+}
diff --git a/extensions/search/pkg/server/grpc/server.go b/extensions/search/pkg/server/grpc/server.go
new file mode 100644
index 00000000000..ef1ea0c0760
--- /dev/null
+++ b/extensions/search/pkg/server/grpc/server.go
@@ -0,0 +1,39 @@
+package grpc
+
+import (
+	svc "github.com/owncloud/ocis/extensions/search/pkg/service/v0"
+	"github.com/owncloud/ocis/ocis-pkg/service/grpc"
+	"github.com/owncloud/ocis/ocis-pkg/version"
+	searchsvc "github.com/owncloud/ocis/protogen/gen/ocis/services/search/v0"
+)
+
+// Server initializes a new go-micro service ready to run
+func Server(opts ...Option) grpc.Service {
+	options := newOptions(opts...)
+
+	service := grpc.NewService(
+		grpc.Name(options.Config.Service.Name),
+		grpc.Context(options.Context),
+		grpc.Address(options.Config.GRPC.Addr),
+		grpc.Namespace(options.Config.GRPC.Namespace),
+		grpc.Logger(options.Logger),
+		grpc.Flags(options.Flags...),
+		grpc.Version(version.String),
+	)
+
+	handle, err := svc.NewHandler(
+		svc.Config(options.Config),
+		svc.Logger(options.Logger),
+	)
+	if err != nil {
+		options.Logger.Error().
+			Err(err).
+			Msg("Error initializing search service")
+		return grpc.Service{}
+	}
+	_ = searchsvc.RegisterSearchProviderHandler(
+		service.Server(),
+		handle,
+	)
+	return service
+}
diff --git a/extensions/search/pkg/service/v0/option.go b/extensions/search/pkg/service/v0/option.go
new file mode 100644
index 00000000000..1b592e3a3d5
--- /dev/null
+++ b/extensions/search/pkg/service/v0/option.go
@@ -0,0 +1,39 @@
+package service
+
+import (
+	"github.com/owncloud/ocis/extensions/search/pkg/config"
+	"github.com/owncloud/ocis/ocis-pkg/log"
+)
+
+// Option defines a single option function.
+type Option func(o *Options)
+
+// Options defines the available options for this package.
+type Options struct {
+	Logger log.Logger
+	Config *config.Config
+}
+
+func newOptions(opts ...Option) Options {
+	opt := Options{}
+
+	for _, o := range opts {
+		o(&opt)
+	}
+
+	return opt
+}
+
+// Logger provides a function to set the Logger option.
+func Logger(val log.Logger) Option {
+	return func(o *Options) {
+		o.Logger = val
+	}
+}
+
+// Config provides a function to set the Config option.
+func Config(val *config.Config) Option {
+	return func(o *Options) {
+		o.Config = val
+	}
+}
diff --git a/extensions/search/pkg/service/v0/service.go b/extensions/search/pkg/service/v0/service.go
new file mode 100644
index 00000000000..90349b43937
--- /dev/null
+++ b/extensions/search/pkg/service/v0/service.go
@@ -0,0 +1,106 @@
+package service
+
+import (
+	"context"
+	"errors"
+	"path/filepath"
+
+	"github.com/blevesearch/bleve/v2"
+	revactx "github.com/cs3org/reva/v2/pkg/ctx"
+	"github.com/cs3org/reva/v2/pkg/events"
+	"github.com/cs3org/reva/v2/pkg/events/server"
+	"github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool"
+	"github.com/go-micro/plugins/v4/events/natsjs"
+	"go-micro.dev/v4/metadata"
+	grpcmetadata "google.golang.org/grpc/metadata"
+
+	"github.com/owncloud/ocis/extensions/audit/pkg/types"
+	"github.com/owncloud/ocis/extensions/search/pkg/config"
+	"github.com/owncloud/ocis/extensions/search/pkg/search"
+	"github.com/owncloud/ocis/extensions/search/pkg/search/index"
+	searchprovider "github.com/owncloud/ocis/extensions/search/pkg/search/provider"
+	"github.com/owncloud/ocis/ocis-pkg/log"
+	searchsvc "github.com/owncloud/ocis/protogen/gen/ocis/services/search/v0"
+)
+
+// NewHandler returns a service implementation for Service.
+func NewHandler(opts ...Option) (searchsvc.SearchProviderHandler, error) {
+	options := newOptions(opts...)
+	logger := options.Logger
+	cfg := options.Config
+
+	// Connect to nats to listen for changes that need to trigger an index update
+	evtsCfg := cfg.Events
+	client, err := server.NewNatsStream(
+		natsjs.Address(evtsCfg.Endpoint),
+		natsjs.ClusterID(evtsCfg.Cluster),
+	)
+	if err != nil {
+		return nil, err
+	}
+	evts, err := events.Consume(client, evtsCfg.ConsumerGroup, types.RegisteredEvents()...)
+	if err != nil {
+		return nil, err
+	}
+
+	indexDir := filepath.Join(cfg.Datapath, "index.bleve")
+	bleveIndex, err := bleve.Open(indexDir)
+	if err != nil {
+		bleveIndex, err = bleve.New(indexDir, index.BuildMapping())
+		if err != nil {
+			return nil, err
+		}
+	}
+	index, err := index.New(bleveIndex)
+	if err != nil {
+		return nil, err
+	}
+
+	gwclient, err := pool.GetGatewayServiceClient(cfg.Reva.Address)
+	if err != nil {
+		logger.Fatal().Err(err).Str("addr", cfg.Reva.Address).Msg("could not get reva client")
+	}
+
+	provider := searchprovider.New(gwclient, index, cfg.MachineAuthAPIKey, evts, logger)
+
+	return &Service{
+		id:       cfg.GRPC.Namespace + "." + cfg.Service.Name,
+		log:      logger,
+		Config:   cfg,
+		provider: provider,
+	}, nil
+}
+
+// Service implements the searchServiceHandler interface
+type Service struct {
+	id       string
+	log      log.Logger
+	Config   *config.Config
+	provider search.ProviderClient
+}
+
+func (s Service) Search(ctx context.Context, in *searchsvc.SearchRequest, out *searchsvc.SearchResponse) error {
+	// Get token from the context (go-micro) and make it known to the reva client too (grpc)
+	t, ok := metadata.Get(ctx, revactx.TokenHeader)
+	if !ok {
+		s.log.Error().Msg("Could not get token from context")
+		return errors.New("could not get token from context")
+	}
+	ctx = grpcmetadata.AppendToOutgoingContext(ctx, revactx.TokenHeader, t)
+
+	res, err := s.provider.Search(ctx, &searchsvc.SearchRequest{
+		Query: in.Query,
+	})
+	if err != nil {
+		return err
+	}
+
+	out.Matches = res.Matches
+	out.NextPageToken = res.NextPageToken
+	return nil
+}
+
+func (s Service) IndexSpace(ctx context.Context, in *searchsvc.IndexSpaceRequest, out *searchsvc.IndexSpaceResponse) error {
+	_, err := s.provider.IndexSpace(ctx, in)
+	return err
+}
diff --git a/extensions/search/pkg/tracing/tracing.go b/extensions/search/pkg/tracing/tracing.go
new file mode 100644
index 00000000000..2f1f8e513f0
--- /dev/null
+++ b/extensions/search/pkg/tracing/tracing.go
@@ -0,0 +1,23 @@
+package tracing
+
+import (
+	"github.com/owncloud/ocis/extensions/search/pkg/config"
+	pkgtrace "github.com/owncloud/ocis/ocis-pkg/tracing"
+	"go.opentelemetry.io/otel/trace"
+)
+
+var (
+	// TraceProvider is the global trace provider for the proxy service.
+	TraceProvider = trace.NewNoopTracerProvider()
+)
+
+func Configure(cfg *config.Config) error {
+	var err error
+	if cfg.Tracing.Enabled {
+		if TraceProvider, err = pkgtrace.GetTraceProvider(cfg.Tracing.Endpoint, cfg.Tracing.Collector, cfg.Service.Name, cfg.Tracing.Type); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
diff --git a/extensions/web/pkg/config/defaults/defaultconfig.go b/extensions/web/pkg/config/defaults/defaultconfig.go
index f3411c2040b..baa74517b05 100644
--- a/extensions/web/pkg/config/defaults/defaultconfig.go
+++ b/extensions/web/pkg/config/defaults/defaultconfig.go
@@ -49,6 +49,9 @@ func DefaultConfig() *config.Config {
 					Scope:        "openid profile email",
 				},
 				Apps: []string{"files", "search", "preview", "text-editor", "pdf-viewer", "external", "user-management"},
+				Options: map[string]interface{}{
+					"hideSearchBar": false,
+				},
 			},
 		},
 	}
diff --git a/extensions/web/pkg/service/v0/service.go b/extensions/web/pkg/service/v0/service.go
index 4a0cf6d0747..f77074a4399 100644
--- a/extensions/web/pkg/service/v0/service.go
+++ b/extensions/web/pkg/service/v0/service.go
@@ -66,12 +66,6 @@ func (p Web) getPayload() (payload []byte, err error) {
 	if p.config.Web.Path == "" {
 		// render dynamically using config
 
-		// provide default ocis-web options
-		if p.config.Web.Config.Options == nil {
-			p.config.Web.Config.Options = make(map[string]interface{})
-			p.config.Web.Config.Options["hideSearchBar"] = true
-		}
-
 		// build theme url
 		if themeServer, err := url.Parse(p.config.Web.ThemeServer); err == nil {
 			p.config.Web.Config.Theme = themeServer.String() + p.config.Web.ThemePath
diff --git a/extensions/webdav/pkg/errors/error.go b/extensions/webdav/pkg/errors/error.go
new file mode 100644
index 00000000000..ba7f9b908ad
--- /dev/null
+++ b/extensions/webdav/pkg/errors/error.go
@@ -0,0 +1,170 @@
+package errors
+
+import (
+	"bytes"
+	"encoding/xml"
+	"net/http"
+
+	rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
+	"github.com/cs3org/reva/v2/pkg/rgrpc/status"
+	"github.com/pkg/errors"
+	"github.com/rs/zerolog"
+)
+
+var sabreException = map[int]string{
+
+	// the commented states have no corresponding exception in sabre/dav,
+	// see https://github.com/sabre-io/dav/tree/master/lib/DAV/Exception
+
+	// http.StatusMultipleChoices:   "Multiple Choices",
+	// http.StatusMovedPermanently:  "Moved Permanently",
+	// http.StatusFound:             "Found",
+	// http.StatusSeeOther:          "See Other",
+	// http.StatusNotModified:       "Not Modified",
+	// http.StatusUseProxy:          "Use Proxy",
+	// http.StatusTemporaryRedirect: "Temporary Redirect",
+	// http.StatusPermanentRedirect: "Permanent Redirect",
+
+	http.StatusBadRequest:       "Sabre\\DAV\\Exception\\BadRequest",
+	http.StatusUnauthorized:     "Sabre\\DAV\\Exception\\NotAuthenticated",
+	http.StatusPaymentRequired:  "Sabre\\DAV\\Exception\\PaymentRequired",
+	http.StatusForbidden:        "Sabre\\DAV\\Exception\\Forbidden", // InvalidResourceType, InvalidSyncToken, TooManyMatches
+	http.StatusNotFound:         "Sabre\\DAV\\Exception\\NotFound",
+	http.StatusMethodNotAllowed: "Sabre\\DAV\\Exception\\MethodNotAllowed",
+	// http.StatusNotAcceptable:                "Not Acceptable",
+	// http.StatusProxyAuthRequired:            "Proxy Authentication Required",
+	// http.StatusRequestTimeout:               "Request Timeout",
+	http.StatusConflict: "Sabre\\DAV\\Exception\\Conflict", // LockTokenMatchesRequestUri
+	// http.StatusGone:                         "Gone",
+	http.StatusLengthRequired:     "Sabre\\DAV\\Exception\\LengthRequired",
+	http.StatusPreconditionFailed: "Sabre\\DAV\\Exception\\PreconditionFailed",
+	// http.StatusRequestEntityTooLarge:        "Request Entity Too Large",
+	// http.StatusRequestURITooLong:            "Request URI Too Long",
+	http.StatusUnsupportedMediaType:         "Sabre\\DAV\\Exception\\UnsupportedMediaType", // ReportNotSupported
+	http.StatusRequestedRangeNotSatisfiable: "Sabre\\DAV\\Exception\\RequestedRangeNotSatisfiable",
+	// http.StatusExpectationFailed:            "Expectation Failed",
+	// http.StatusTeapot:                       "I'm a teapot",
+	// http.StatusMisdirectedRequest:           "Misdirected Request",
+	// http.StatusUnprocessableEntity:          "Unprocessable Entity",
+	http.StatusLocked: "Sabre\\DAV\\Exception\\Locked", // ConflictingLock
+	// http.StatusFailedDependency:             "Failed Dependency",
+	// http.StatusTooEarly:                     "Too Early",
+	// http.StatusUpgradeRequired:              "Upgrade Required",
+	// http.StatusPreconditionRequired:         "Precondition Required",
+	// http.StatusTooManyRequests:              "Too Many Requests",
+	// http.StatusRequestHeaderFieldsTooLarge:  "Request Header Fields Too Large",
+	// http.StatusUnavailableForLegalReasons:   "Unavailable For Legal Reasons",
+
+	// http.StatusInternalServerError:           "Internal Server Error",
+	http.StatusNotImplemented: "Sabre\\DAV\\Exception\\NotImplemented",
+	// http.StatusBadGateway:                    "Bad Gateway",
+	http.StatusServiceUnavailable: "Sabre\\DAV\\Exception\\ServiceUnavailable",
+	// http.StatusGatewayTimeout:                "Gateway Timeout",
+	// http.StatusHTTPVersionNotSupported:       "HTTP Version Not Supported",
+	// http.StatusVariantAlsoNegotiates:         "Variant Also Negotiates",
+	http.StatusInsufficientStorage: "Sabre\\DAV\\Exception\\InsufficientStorage",
+	// http.StatusLoopDetected:                  "Loop Detected",
+	// http.StatusNotExtended:                   "Not Extended",
+	// http.StatusNetworkAuthenticationRequired: "Network Authentication Required",
+}
+
+// SabreException returns a sabre exception text for the HTTP status code. It returns the empty
+// string if the code is unknown.
+func SabreException(code int) string {
+	return sabreException[code]
+}
+
+// Exception represents a ocdav exception
+type Exception struct {
+	Code    int
+	Message string
+	Header  string
+}
+
+// Marshal just calls the xml marshaller for a given exception.
+func Marshal(code int, message string, header string) ([]byte, error) {
+	xmlstring, err := xml.Marshal(&ErrorXML{
+		Xmlnsd:    "DAV",
+		Xmlnss:    "http://sabredav.org/ns",
+		Exception: sabreException[code],
+		Message:   message,
+		Header:    header,
+	})
+	if err != nil {
+		return nil, err
+	}
+	var buf bytes.Buffer
+	buf.WriteString(xml.Header)
+	buf.Write(xmlstring)
+	return buf.Bytes(), err
+}
+
+// ErrorXML holds the xml representation of an error
+// http://www.webdav.org/specs/rfc4918.html#ELEMENT_error
+type ErrorXML struct {
+	XMLName   xml.Name `xml:"d:error"`
+	Xmlnsd    string   `xml:"xmlns:d,attr"`
+	Xmlnss    string   `xml:"xmlns:s,attr"`
+	Exception string   `xml:"s:exception"`
+	Message   string   `xml:"s:message"`
+	InnerXML  []byte   `xml:",innerxml"`
+	// Header is used to indicate the conflicting request header
+	Header string `xml:"s:header,omitempty"`
+}
+
+var (
+	// ErrInvalidDepth is an invalid depth header error
+	ErrInvalidDepth = errors.New("webdav: invalid depth")
+	// ErrInvalidPropfind is an invalid propfind error
+	ErrInvalidPropfind = errors.New("webdav: invalid propfind")
+	// ErrInvalidProppatch is an invalid proppatch error
+	ErrInvalidProppatch = errors.New("webdav: invalid proppatch")
+	// ErrInvalidLockInfo is an invalid lock error
+	ErrInvalidLockInfo = errors.New("webdav: invalid lock info")
+	// ErrUnsupportedLockInfo is an unsupported lock error
+	ErrUnsupportedLockInfo = errors.New("webdav: unsupported lock info")
+	// ErrInvalidTimeout is an invalid timeout error
+	ErrInvalidTimeout = errors.New("webdav: invalid timeout")
+	// ErrInvalidIfHeader is an invalid if header error
+	ErrInvalidIfHeader = errors.New("webdav: invalid If header")
+	// ErrUnsupportedMethod is an unsupported method error
+	ErrUnsupportedMethod = errors.New("webdav: unsupported method")
+	// ErrInvalidLockToken is an invalid lock token error
+	ErrInvalidLockToken = errors.New("webdav: invalid lock token")
+	// ErrConfirmationFailed is returned by a LockSystem's Confirm method.
+	ErrConfirmationFailed = errors.New("webdav: confirmation failed")
+	// ErrForbidden is returned by a LockSystem's Unlock method.
+	ErrForbidden = errors.New("webdav: forbidden")
+	// ErrLocked is returned by a LockSystem's Create, Refresh and Unlock methods.
+	ErrLocked = errors.New("webdav: locked")
+	// ErrNoSuchLock is returned by a LockSystem's Refresh and Unlock methods.
+	ErrNoSuchLock = errors.New("webdav: no such lock")
+	// ErrNotImplemented is returned when hitting not implemented code paths
+	ErrNotImplemented = errors.New("webdav: not implemented")
+)
+
+// HandleErrorStatus checks the status code, logs a Debug or Error level message
+// and writes an appropriate http status
+func HandleErrorStatus(log *zerolog.Logger, w http.ResponseWriter, s *rpc.Status) {
+	hsc := status.HTTPStatusFromCode(s.Code)
+	if hsc == http.StatusInternalServerError {
+		log.Error().Interface("status", s).Int("code", hsc).Msg(http.StatusText(hsc))
+	} else {
+		log.Debug().Interface("status", s).Int("code", hsc).Msg(http.StatusText(hsc))
+	}
+	w.WriteHeader(hsc)
+}
+
+// HandleWebdavError checks the status code, logs an error and creates a webdav response body
+// if needed
+func HandleWebdavError(log *zerolog.Logger, w http.ResponseWriter, b []byte, err error) {
+	if err != nil {
+		log.Error().Msgf("error marshaling xml response: %s", b)
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+	_, err = w.Write(b)
+	if err != nil {
+		log.Err(err).Msg("error writing response")
+	}
+}
diff --git a/extensions/webdav/pkg/net/headers.go b/extensions/webdav/pkg/net/headers.go
new file mode 100644
index 00000000000..5c22d8509d8
--- /dev/null
+++ b/extensions/webdav/pkg/net/headers.go
@@ -0,0 +1,48 @@
+package net
+
+// Common HTTP headers.
+const (
+	HeaderAcceptRanges               = "Accept-Ranges"
+	HeaderAccessControlAllowHeaders  = "Access-Control-Allow-Headers"
+	HeaderAccessControlExposeHeaders = "Access-Control-Expose-Headers"
+	HeaderContentDisposistion        = "Content-Disposition"
+	HeaderContentLength              = "Content-Length"
+	HeaderContentRange               = "Content-Range"
+	HeaderContentType                = "Content-Type"
+	HeaderETag                       = "ETag"
+	HeaderLastModified               = "Last-Modified"
+	HeaderLocation                   = "Location"
+	HeaderRange                      = "Range"
+	HeaderIfMatch                    = "If-Match"
+)
+
+// webdav headers
+const (
+	HeaderDav         = "DAV"         // https://datatracker.ietf.org/doc/html/rfc4918#section-10.1
+	HeaderDepth       = "Depth"       // https://datatracker.ietf.org/doc/html/rfc4918#section-10.2
+	HeaderDestination = "Destination" // https://datatracker.ietf.org/doc/html/rfc4918#section-10.3
+	HeaderIf          = "If"          // https://datatracker.ietf.org/doc/html/rfc4918#section-10.4
+	HeaderLockToken   = "Lock-Token"  // https://datatracker.ietf.org/doc/html/rfc4918#section-10.5
+	HeaderOverwrite   = "Overwrite"   // https://datatracker.ietf.org/doc/html/rfc4918#section-10.6
+	HeaderTimeout     = "Timeout"     // https://datatracker.ietf.org/doc/html/rfc4918#section-10.7
+)
+
+// Non standard HTTP headers.
+const (
+	HeaderOCFileID             = "OC-FileId"
+	HeaderOCETag               = "OC-ETag"
+	HeaderOCChecksum           = "OC-Checksum"
+	HeaderOCPermissions        = "OC-Perm"
+	HeaderTusResumable         = "Tus-Resumable"
+	HeaderTusVersion           = "Tus-Version"
+	HeaderTusExtension         = "Tus-Extension"
+	HeaderTusChecksumAlgorithm = "Tus-Checksum-Algorithm"
+	HeaderTusUploadExpires     = "Upload-Expires"
+	HeaderUploadChecksum       = "Upload-Checksum"
+	HeaderUploadLength         = "Upload-Length"
+	HeaderUploadMetadata       = "Upload-Metadata"
+	HeaderUploadOffset         = "Upload-Offset"
+	HeaderOCMtime              = "X-OC-Mtime"
+	HeaderExpectedEntityLength = "X-Expected-Entity-Length"
+	HeaderLitmus               = "X-Litmus"
+)
diff --git a/extensions/webdav/pkg/net/net.go b/extensions/webdav/pkg/net/net.go
new file mode 100644
index 00000000000..6ac66b74a67
--- /dev/null
+++ b/extensions/webdav/pkg/net/net.go
@@ -0,0 +1,12 @@
+package net
+
+import (
+	"net/url"
+)
+
+// EncodePath encodes the path of a url.
+//
+// slashes (/) are treated as path-separators.
+func EncodePath(path string) string {
+	return (&url.URL{Path: path}).EscapedPath()
+}
diff --git a/extensions/webdav/pkg/prop/prop.go b/extensions/webdav/pkg/prop/prop.go
new file mode 100644
index 00000000000..b34ce19fe81
--- /dev/null
+++ b/extensions/webdav/pkg/prop/prop.go
@@ -0,0 +1,125 @@
+package prop
+
+import (
+	"bytes"
+	"encoding/xml"
+)
+
+// PropertyXML represents a single DAV resource property as defined in RFC 4918.
+// http://www.webdav.org/specs/rfc4918.html#data.model.for.resource.properties
+type PropertyXML struct {
+	// XMLName is the fully qualified name that identifies this property.
+	XMLName xml.Name
+
+	// Lang is an optional xml:lang attribute.
+	Lang string `xml:"xml:lang,attr,omitempty"`
+
+	// InnerXML contains the XML representation of the property value.
+	// See http://www.webdav.org/specs/rfc4918.html#property_values
+	//
+	// Property values of complex type or mixed-content must have fully
+	// expanded XML namespaces or be self-contained with according
+	// XML namespace declarations. They must not rely on any XML
+	// namespace declarations within the scope of the XML document,
+	// even including the DAV: namespace.
+	InnerXML []byte `xml:",innerxml"`
+}
+
+func xmlEscaped(val string) []byte {
+	buf := new(bytes.Buffer)
+	xml.Escape(buf, []byte(val))
+	return buf.Bytes()
+}
+
+// EscapedNS returns a new PropertyXML instance while xml-escaping the value
+func EscapedNS(namespace string, local string, val string) PropertyXML {
+	return PropertyXML{
+		XMLName:  xml.Name{Space: namespace, Local: local},
+		Lang:     "",
+		InnerXML: xmlEscaped(val),
+	}
+}
+
+// Escaped returns a new PropertyXML instance while xml-escaping the value
+// TODO properly use the space
+func Escaped(key, val string) PropertyXML {
+	return PropertyXML{
+		XMLName:  xml.Name{Space: "", Local: key},
+		Lang:     "",
+		InnerXML: xmlEscaped(val),
+	}
+}
+
+// NotFound returns a new PropertyXML instance with an empty value
+func NotFound(key string) PropertyXML {
+	return PropertyXML{
+		XMLName: xml.Name{Space: "", Local: key},
+		Lang:    "",
+	}
+}
+
+// NotFoundNS returns a new PropertyXML instance with the given namespace and an empty value
+func NotFoundNS(namespace, key string) PropertyXML {
+	return PropertyXML{
+		XMLName: xml.Name{Space: namespace, Local: key},
+		Lang:    "",
+	}
+}
+
+// Raw returns a new PropertyXML instance for the given key/value pair
+// TODO properly use the space
+func Raw(key, val string) PropertyXML {
+	return PropertyXML{
+		XMLName:  xml.Name{Space: "", Local: key},
+		Lang:     "",
+		InnerXML: []byte(val),
+	}
+}
+
+// Next returns the next token, if any, in the XML stream of d.
+// RFC 4918 requires to ignore comments, processing instructions
+// and directives.
+// http://www.webdav.org/specs/rfc4918.html#property_values
+// http://www.webdav.org/specs/rfc4918.html#xml-extensibility
+func Next(d *xml.Decoder) (xml.Token, error) {
+	for {
+		t, err := d.Token()
+		if err != nil {
+			return t, err
+		}
+		switch t.(type) {
+		case xml.Comment, xml.Directive, xml.ProcInst:
+			continue
+		default:
+			return t, nil
+		}
+	}
+}
+
+// ActiveLock holds active lock xml data
+//  http://www.webdav.org/specs/rfc4918.html#ELEMENT_activelock
+// <!ELEMENT activelock (lockscope, locktype, depth, owner?, timeout?,
+//           locktoken?, lockroot)>
+type ActiveLock struct {
+	XMLName   xml.Name  `xml:"activelock"`
+	Exclusive *struct{} `xml:"lockscope>exclusive,omitempty"`
+	Shared    *struct{} `xml:"lockscope>shared,omitempty"`
+	Write     *struct{} `xml:"locktype>write,omitempty"`
+	Depth     string    `xml:"depth"`
+	Owner     Owner     `xml:"owner,omitempty"`
+	Timeout   string    `xml:"timeout,omitempty"`
+	Locktoken string    `xml:"locktoken>href"`
+	Lockroot  string    `xml:"lockroot>href,omitempty"`
+}
+
+// Owner captures the inner UML of a lock owner element http://www.webdav.org/specs/rfc4918.html#ELEMENT_owner
+type Owner struct {
+	InnerXML string `xml:",innerxml"`
+}
+
+// Escape repaces ", &, ', < and > with their xml representation
+func Escape(s string) string {
+	b := bytes.NewBuffer(nil)
+	_ = xml.EscapeText(b, []byte(s))
+	return b.String()
+}
diff --git a/extensions/webdav/pkg/propfind/propfind.go b/extensions/webdav/pkg/propfind/propfind.go
new file mode 100644
index 00000000000..8be26112fb4
--- /dev/null
+++ b/extensions/webdav/pkg/propfind/propfind.go
@@ -0,0 +1,180 @@
+package propfind
+
+import (
+	"encoding/xml"
+	"fmt"
+	"io"
+	"net/http"
+
+	"github.com/owncloud/ocis/extensions/webdav/pkg/errors"
+	"github.com/owncloud/ocis/extensions/webdav/pkg/prop"
+)
+
+const (
+	_spaceTypeProject = "project"
+)
+
+type countingReader struct {
+	n int
+	r io.Reader
+}
+
+func (c *countingReader) Read(p []byte) (int, error) {
+	n, err := c.r.Read(p)
+	c.n += n
+	return n, err
+}
+
+// Props represents properties related to a resource
+// http://www.webdav.org/specs/rfc4918.html#ELEMENT_prop (for propfind)
+type Props []xml.Name
+
+// XML holds the xml representation of a propfind
+// http://www.webdav.org/specs/rfc4918.html#ELEMENT_propfind
+type XML struct {
+	XMLName  xml.Name  `xml:"DAV: propfind"`
+	Allprop  *struct{} `xml:"DAV: allprop"`
+	Propname *struct{} `xml:"DAV: propname"`
+	Prop     Props     `xml:"DAV: prop"`
+	Include  Props     `xml:"DAV: include"`
+}
+
+// PropstatXML holds the xml representation of a propfind response
+// http://www.webdav.org/specs/rfc4918.html#ELEMENT_propstat
+type PropstatXML struct {
+	// Prop requires DAV: to be the default namespace in the enclosing
+	// XML. This is due to the standard encoding/xml package currently
+	// not honoring namespace declarations inside a xmltag with a
+	// parent element for anonymous slice elements.
+	// Use of multistatusWriter takes care of this.
+	Prop                []prop.PropertyXML `xml:"d:prop>_ignored_"`
+	Status              string             `xml:"d:status"`
+	Error               *errors.ErrorXML   `xml:"d:error"`
+	ResponseDescription string             `xml:"d:responsedescription,omitempty"`
+}
+
+// ResponseXML holds the xml representation of a propfind response
+type ResponseXML struct {
+	XMLName             xml.Name         `xml:"d:response"`
+	Href                string           `xml:"d:href"`
+	Propstat            []PropstatXML    `xml:"d:propstat"`
+	Status              string           `xml:"d:status,omitempty"`
+	Error               *errors.ErrorXML `xml:"d:error"`
+	ResponseDescription string           `xml:"d:responsedescription,omitempty"`
+}
+
+// MultiStatusResponseXML holds the xml representation of a multistatus propfind response
+type MultiStatusResponseXML struct {
+	XMLName xml.Name `xml:"d:multistatus"`
+	XmlnsS  string   `xml:"xmlns:s,attr,omitempty"`
+	XmlnsD  string   `xml:"xmlns:d,attr,omitempty"`
+	XmlnsOC string   `xml:"xmlns:oc,attr,omitempty"`
+
+	Responses []*ResponseXML `xml:"d:response"`
+}
+
+// ResponseUnmarshalXML is a workaround for https://github.com/golang/go/issues/13400
+type ResponseUnmarshalXML struct {
+	XMLName             xml.Name               `xml:"response"`
+	Href                string                 `xml:"href"`
+	Propstat            []PropstatUnmarshalXML `xml:"propstat"`
+	Status              string                 `xml:"status,omitempty"`
+	Error               *errors.ErrorXML       `xml:"d:error"`
+	ResponseDescription string                 `xml:"responsedescription,omitempty"`
+}
+
+// MultiStatusResponseUnmarshalXML is a workaround for https://github.com/golang/go/issues/13400
+type MultiStatusResponseUnmarshalXML struct {
+	XMLName xml.Name `xml:"multistatus"`
+	XmlnsS  string   `xml:"xmlns:s,attr,omitempty"`
+	XmlnsD  string   `xml:"xmlns:d,attr,omitempty"`
+	XmlnsOC string   `xml:"xmlns:oc,attr,omitempty"`
+
+	Responses []*ResponseUnmarshalXML `xml:"response"`
+}
+
+// PropstatUnmarshalXML is a workaround for https://github.com/golang/go/issues/13400
+type PropstatUnmarshalXML struct {
+	// Prop requires DAV: to be the default namespace in the enclosing
+	// XML. This is due to the standard encoding/xml package currently
+	// not honoring namespace declarations inside a xmltag with a
+	// parent element for anonymous slice elements.
+	// Use of multistatusWriter takes care of this.
+	Prop                []*prop.PropertyXML `xml:"prop"`
+	Status              string              `xml:"status"`
+	Error               *errors.ErrorXML    `xml:"d:error"`
+	ResponseDescription string              `xml:"responsedescription,omitempty"`
+}
+
+// NewMultiStatusResponseXML returns a preconfigured instance of MultiStatusResponseXML
+func NewMultiStatusResponseXML() *MultiStatusResponseXML {
+	return &MultiStatusResponseXML{
+		XmlnsD:  "DAV:",
+		XmlnsS:  "http://sabredav.org/ns",
+		XmlnsOC: "http://owncloud.org/ns",
+	}
+}
+
+// ReadPropfind extracts and parses the propfind XML information from a Reader
+// from https://github.com/golang/net/blob/e514e69ffb8bc3c76a71ae40de0118d794855992/webdav/xml.go#L178-L205
+func ReadPropfind(r io.Reader) (pf XML, status int, err error) {
+	c := countingReader{r: r}
+	if err = xml.NewDecoder(&c).Decode(&pf); err != nil {
+		if err == io.EOF {
+			if c.n == 0 {
+				// An empty body means to propfind allprop.
+				// http://www.webdav.org/specs/rfc4918.html#METHOD_PROPFIND
+				return XML{Allprop: new(struct{})}, 0, nil
+			}
+			err = errors.ErrInvalidPropfind
+		}
+		return XML{}, http.StatusBadRequest, err
+	}
+
+	if pf.Allprop == nil && pf.Include != nil {
+		return XML{}, http.StatusBadRequest, errors.ErrInvalidPropfind
+	}
+	if pf.Allprop != nil && (pf.Prop != nil || pf.Propname != nil) {
+		return XML{}, http.StatusBadRequest, errors.ErrInvalidPropfind
+	}
+	if pf.Prop != nil && pf.Propname != nil {
+		return XML{}, http.StatusBadRequest, errors.ErrInvalidPropfind
+	}
+	if pf.Propname == nil && pf.Allprop == nil && pf.Prop == nil {
+		// jfd: I think <d:prop></d:prop> is perfectly valid ... treat it as allprop
+		return XML{Allprop: new(struct{})}, 0, nil
+	}
+	return pf, 0, nil
+}
+
+// UnmarshalXML appends the property names enclosed within start to pn.
+//
+// It returns an error if start does not contain any properties or if
+// properties contain values. Character data between properties is ignored.
+func (pn *Props) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
+	for {
+		t, err := prop.Next(d)
+		if err != nil {
+			return err
+		}
+		switch e := t.(type) {
+		case xml.EndElement:
+			// jfd: I think <d:prop></d:prop> is perfectly valid ... treat it as allprop
+			/*
+				if len(*pn) == 0 {
+					return fmt.Errorf("%s must not be empty", start.Name.Local)
+				}
+			*/
+			return nil
+		case xml.StartElement:
+			t, err = prop.Next(d)
+			if err != nil {
+				return err
+			}
+			if _, ok := t.(xml.EndElement); !ok {
+				return fmt.Errorf("unexpected token %T", t)
+			}
+			*pn = append(*pn, e.Name)
+		}
+	}
+}
diff --git a/extensions/webdav/pkg/service/v0/search.go b/extensions/webdav/pkg/service/v0/search.go
new file mode 100644
index 00000000000..5a7d15f5457
--- /dev/null
+++ b/extensions/webdav/pkg/service/v0/search.go
@@ -0,0 +1,221 @@
+package svc
+
+import (
+	"context"
+	"encoding/xml"
+	"io"
+	"net/http"
+	"path"
+	"strconv"
+	"time"
+
+	revactx "github.com/cs3org/reva/v2/pkg/ctx"
+	"github.com/owncloud/ocis/extensions/webdav/pkg/net"
+	"github.com/owncloud/ocis/extensions/webdav/pkg/prop"
+	"github.com/owncloud/ocis/extensions/webdav/pkg/propfind"
+	searchmsg "github.com/owncloud/ocis/protogen/gen/ocis/messages/search/v0"
+	searchsvc "github.com/owncloud/ocis/protogen/gen/ocis/services/search/v0"
+	merrors "go-micro.dev/v4/errors"
+	"go-micro.dev/v4/metadata"
+)
+
+const (
+	elementNameSearchFiles = "search-files"
+	// TODO elementNameFilterFiles = "filter-files"
+)
+
+// Search is the endpoint for retrieving search results for REPORT requests
+func (g Webdav) Search(w http.ResponseWriter, r *http.Request) {
+	rep, err := readReport(r.Body)
+	if err != nil {
+		renderError(w, r, errBadRequest(err.Error()))
+		g.log.Error().Err(err).Msg("error reading report")
+		return
+	}
+
+	if rep.SearchFiles == nil {
+		renderError(w, r, errBadRequest("missing search-files tag"))
+		g.log.Error().Err(err).Msg("error reading report")
+		return
+	}
+
+	t := r.Header.Get(TokenHeader)
+	ctx := revactx.ContextSetToken(r.Context(), t)
+	ctx = metadata.Set(ctx, revactx.TokenHeader, t)
+	rsp, err := g.searchClient.Search(ctx, &searchsvc.SearchRequest{
+		Query: "*" + rep.SearchFiles.Search.Pattern + "*",
+	})
+	if err != nil {
+		e := merrors.Parse(err.Error())
+		switch e.Code {
+		case http.StatusBadRequest:
+			renderError(w, r, errBadRequest(err.Error()))
+		default:
+			renderError(w, r, errInternalError(err.Error()))
+		}
+		g.log.Error().Err(err).Msg("could not get search results")
+		return
+	}
+
+	g.sendSearchResponse(rsp, w, r)
+}
+
+func (g Webdav) sendSearchResponse(rsp *searchsvc.SearchResponse, w http.ResponseWriter, r *http.Request) {
+	responsesXML, err := multistatusResponse(r.Context(), rsp.Matches)
+	if err != nil {
+		g.log.Error().Err(err).Msg("error formatting propfind")
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+	w.Header().Set(net.HeaderDav, "1, 3, extended-mkcol")
+	w.Header().Set(net.HeaderContentType, "application/xml; charset=utf-8")
+	w.WriteHeader(http.StatusMultiStatus)
+	if _, err := w.Write(responsesXML); err != nil {
+		g.log.Err(err).Msg("error writing response")
+	}
+}
+
+// multistatusResponse converts a list of matches into a multistatus response string
+func multistatusResponse(ctx context.Context, matches []*searchmsg.Match) ([]byte, error) {
+	responses := make([]*propfind.ResponseXML, 0, len(matches))
+	for i := range matches {
+		res, err := matchToPropResponse(ctx, matches[i])
+		if err != nil {
+			return nil, err
+		}
+		responses = append(responses, res)
+	}
+
+	msr := propfind.NewMultiStatusResponseXML()
+	msr.Responses = responses
+	msg, err := xml.Marshal(msr)
+	if err != nil {
+		return nil, err
+	}
+	return msg, nil
+}
+
+func matchToPropResponse(ctx context.Context, match *searchmsg.Match) (*propfind.ResponseXML, error) {
+	response := propfind.ResponseXML{
+		Href:     net.EncodePath(path.Join("/dav/spaces/", match.Entity.Ref.ResourceId.StorageId+"!"+match.Entity.Ref.ResourceId.OpaqueId, match.Entity.Ref.Path)),
+		Propstat: []propfind.PropstatXML{},
+	}
+
+	propstatOK := propfind.PropstatXML{
+		Status: "HTTP/1.1 200 OK",
+		Prop:   []prop.PropertyXML{},
+	}
+
+	// <oc:permissions>RDNVW</oc:permissions>
+	// <oc:favorite>0</oc:favorite>
+	// <oc:owner-id>demo</oc:owner-id>
+	// <oc:owner-display-name>demo</oc:owner-display-name>
+	// <oc:share-types/>
+	// <oc:privatelink>https://demo.owncloud.com/f/7</oc:privatelink>
+	// <d:getcontenttype>application/pdf</d:getcontenttype>
+	// <d:resourcetype/>
+	// <oc:downloadURL/>
+	// done:
+	// <oc:fileid>7</oc:fileid>
+	// <oc:size>6668668</oc:size>
+	// <d:getcontentlength>6668668</d:getcontentlength>
+	// <d:getetag>"0cdcdd1bb13a8fed3e54d3b2325dc97c"</d:getetag>
+	// <d:getlastmodified>Mon, 25 Apr 2022 06:48:26 GMT</d:getlastmodified>
+
+	propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:fileid", match.Entity.Id.StorageId+"!"+match.Entity.Id.OpaqueId))
+	propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("d:getetag", match.Entity.Etag))
+	propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("d:getlastmodified", match.Entity.LastModifiedTime.AsTime().Format(time.RFC3339)))
+	propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("d:getcontenttype", match.Entity.MimeType))
+
+	size := strconv.FormatUint(match.Entity.Size, 10)
+	propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:size", size))
+	propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("d:getcontentlength", size))
+
+	// TODO find name for score property
+	score := strconv.FormatFloat(float64(match.Score), 'f', -1, 64)
+	propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:score", score))
+
+	if len(propstatOK.Prop) > 0 {
+		response.Propstat = append(response.Propstat, propstatOK)
+	}
+
+	return &response, nil
+}
+
+type report struct {
+	SearchFiles *reportSearchFiles
+	// FilterFiles TODO add this for tag based search
+	FilterFiles *reportFilterFiles `xml:"filter-files"`
+}
+type reportSearchFiles struct {
+	XMLName xml.Name                `xml:"search-files"`
+	Lang    string                  `xml:"xml:lang,attr,omitempty"`
+	Prop    Props                   `xml:"DAV: prop"`
+	Search  reportSearchFilesSearch `xml:"search"`
+}
+type reportSearchFilesSearch struct {
+	Pattern string `xml:"pattern"`
+	Limit   int    `xml:"limit"`
+	Offset  int    `xml:"offset"`
+}
+
+type reportFilterFiles struct {
+	XMLName xml.Name               `xml:"filter-files"`
+	Lang    string                 `xml:"xml:lang,attr,omitempty"`
+	Prop    Props                  `xml:"DAV: prop"`
+	Rules   reportFilterFilesRules `xml:"filter-rules"`
+}
+
+type reportFilterFilesRules struct {
+	Favorite  bool `xml:"favorite"`
+	SystemTag int  `xml:"systemtag"`
+}
+
+// Props represents properties related to a resource
+// http://www.webdav.org/specs/rfc4918.html#ELEMENT_prop (for propfind)
+type Props []xml.Name
+
+// XML holds the xml representation of a propfind
+// http://www.webdav.org/specs/rfc4918.html#ELEMENT_propfind
+type XML struct {
+	XMLName  xml.Name  `xml:"DAV: propfind"`
+	Allprop  *struct{} `xml:"DAV: allprop"`
+	Propname *struct{} `xml:"DAV: propname"`
+	Prop     Props     `xml:"DAV: prop"`
+	Include  Props     `xml:"DAV: include"`
+}
+
+func readReport(r io.Reader) (rep *report, err error) {
+	decoder := xml.NewDecoder(r)
+	rep = &report{}
+	for {
+		t, err := decoder.Token()
+		if err == io.EOF {
+			// io.EOF is a successful end
+			return rep, nil
+		}
+		if err != nil {
+			return nil, err
+		}
+
+		if v, ok := t.(xml.StartElement); ok {
+			if v.Name.Local == elementNameSearchFiles {
+				var repSF reportSearchFiles
+				err = decoder.DecodeElement(&repSF, &v)
+				if err != nil {
+					return nil, err
+				}
+				rep.SearchFiles = &repSF
+				/*
+					} else if v.Name.Local == elementNameFilterFiles {
+						var repFF reportFilterFiles
+						err = decoder.DecodeElement(&repFF, &v)
+						if err != nil {
+							return nil, http.StatusBadRequest, err
+						}
+						rep.FilterFiles = &repFF
+				*/
+			}
+		}
+	}
+}
diff --git a/extensions/webdav/pkg/service/v0/service.go b/extensions/webdav/pkg/service/v0/service.go
index 747bafbace5..ad6faff6ded 100644
--- a/extensions/webdav/pkg/service/v0/service.go
+++ b/extensions/webdav/pkg/service/v0/service.go
@@ -23,6 +23,7 @@ import (
 	"github.com/owncloud/ocis/extensions/webdav/pkg/config"
 	"github.com/owncloud/ocis/extensions/webdav/pkg/dav/requests"
 	thumbnailsmsg "github.com/owncloud/ocis/protogen/gen/ocis/messages/thumbnails/v0"
+	searchsvc "github.com/owncloud/ocis/protogen/gen/ocis/services/search/v0"
 	thumbnailssvc "github.com/owncloud/ocis/protogen/gen/ocis/services/thumbnails/v0"
 )
 
@@ -51,6 +52,7 @@ func NewService(opts ...Option) (Service, error) {
 	conf := options.Config
 
 	m := chi.NewMux()
+	chi.RegisterMethod("REPORT")
 	m.Use(options.Middleware...)
 
 	gwc, err := pool.GetGatewayServiceClient(conf.RevaGateway)
@@ -62,6 +64,7 @@ func NewService(opts ...Option) (Service, error) {
 		config:           conf,
 		log:              options.Logger,
 		mux:              m,
+		searchClient:     searchsvc.NewSearchProviderService("com.owncloud.api.search", grpc.DefaultClient),
 		thumbnailsClient: thumbnailssvc.NewThumbnailService("com.owncloud.api.thumbnails", grpc.DefaultClient),
 		revaClient:       gwc,
 	}
@@ -71,16 +74,19 @@ func NewService(opts ...Option) (Service, error) {
 		r.Get("/remote.php/dav/files/{id}/*", svc.Thumbnail)
 		r.Get("/remote.php/dav/public-files/{token}/*", svc.PublicThumbnail)
 		r.Head("/remote.php/dav/public-files/{token}/*", svc.PublicThumbnailHead)
+
+		r.MethodFunc("REPORT", "/remote.php/dav/files/{id}/*", svc.Search)
 	})
 
 	return svc, nil
 }
 
-// Webdav defines implements the business logic for Service.
+// Webdav implements the business logic for Service.
 type Webdav struct {
 	config           *config.Config
 	log              log.Logger
 	mux              *chi.Mux
+	searchClient     searchsvc.SearchProviderService
 	thumbnailsClient thumbnailssvc.ThumbnailService
 	revaClient       gatewayv1beta1.GatewayAPIClient
 }
diff --git a/go.mod b/go.mod
index 90185fb87ae..346f6127cea 100644
--- a/go.mod
+++ b/go.mod
@@ -7,9 +7,10 @@ require (
 	github.com/GeertJohan/yubigo v0.0.0-20190917122436-175bc097e60e
 	github.com/ReneKroon/ttlcache/v2 v2.11.0
 	github.com/blevesearch/bleve/v2 v2.3.2
+	github.com/blevesearch/bleve_index_api v1.0.1
 	github.com/coreos/go-oidc/v3 v3.1.0
 	github.com/cs3org/go-cs3apis v0.0.0-20220412090512-93c5918b4bde
-	github.com/cs3org/reva/v2 v2.0.0-20220502075009-8bcec2e4663e
+	github.com/cs3org/reva/v2 v2.0.0-20220502122639-bfbf8690a043
 	github.com/disintegration/imaging v1.6.2
 	github.com/glauth/glauth/v2 v2.0.0-20211021011345-ef3151c28733
 	github.com/go-chi/chi/v5 v5.0.7
@@ -104,7 +105,6 @@ require (
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/bitly/go-simplejson v0.5.0 // indirect
 	github.com/bits-and-blooms/bitset v1.2.1 // indirect
-	github.com/blevesearch/bleve_index_api v1.0.1 // indirect
 	github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
 	github.com/blevesearch/gtreap v0.1.1 // indirect
 	github.com/blevesearch/mmap-go v1.0.3 // indirect
diff --git a/go.sum b/go.sum
index 2ee58f6e4f1..68f316b32f2 100644
--- a/go.sum
+++ b/go.sum
@@ -318,8 +318,8 @@ github.com/cs3org/go-cs3apis v0.0.0-20220412090512-93c5918b4bde h1:WrD9O8ZaWvsm0
 github.com/cs3org/go-cs3apis v0.0.0-20220412090512-93c5918b4bde/go.mod h1:UXha4TguuB52H14EMoSsCqDj7k8a/t7g4gVP+bgY5LY=
 github.com/cs3org/reva v1.18.0 h1:MbPS5ZAa8RzKcTxAVeSDdISB3XXqLIxqB03BTN5ReBY=
 github.com/cs3org/reva v1.18.0/go.mod h1:e5VDUDu4vVWIeVkZcW//n6UZzhGGMa+Tz/whCiX3N6o=
-github.com/cs3org/reva/v2 v2.0.0-20220502075009-8bcec2e4663e h1:ym80MMvfFLHMxt6aiU67kTe/pzRBaSOUNdPkmeKYejk=
-github.com/cs3org/reva/v2 v2.0.0-20220502075009-8bcec2e4663e/go.mod h1:2e/4HcIy54Mic3V7Ow0bz4n5dkZU0dHIZSWomFe5vng=
+github.com/cs3org/reva/v2 v2.0.0-20220502122639-bfbf8690a043 h1:wAvf45pBDnWIN4kpyWpD9uRl9y147ioAXJkfztrMSCM=
+github.com/cs3org/reva/v2 v2.0.0-20220502122639-bfbf8690a043/go.mod h1:2e/4HcIy54Mic3V7Ow0bz4n5dkZU0dHIZSWomFe5vng=
 github.com/cubewise-code/go-mime v0.0.0-20200519001935-8c5762b177d8 h1:Z9lwXumT5ACSmJ7WGnFl+OMLLjpz5uR2fyz7dC255FI=
 github.com/cubewise-code/go-mime v0.0.0-20200519001935-8c5762b177d8/go.mod h1:4abs/jPXcmJzYoYGF91JF9Uq9s/KL5n1jvFDix8KcqY=
 github.com/cyberdelia/templates v0.0.0-20141128023046-ca7fffd4298c/go.mod h1:GyV+0YP4qX0UQ7r2MoYZ+AvYDp12OF5yg4q8rGnyNh4=
diff --git a/ocis-pkg/config/config.go b/ocis-pkg/config/config.go
index 8cebbd071ac..115d8a0fe4f 100644
--- a/ocis-pkg/config/config.go
+++ b/ocis-pkg/config/config.go
@@ -22,6 +22,7 @@ import (
 	ocdav "github.com/owncloud/ocis/extensions/ocdav/pkg/config"
 	ocs "github.com/owncloud/ocis/extensions/ocs/pkg/config"
 	proxy "github.com/owncloud/ocis/extensions/proxy/pkg/config"
+	search "github.com/owncloud/ocis/extensions/search/pkg/config"
 	settings "github.com/owncloud/ocis/extensions/settings/pkg/config"
 	sharing "github.com/owncloud/ocis/extensions/sharing/pkg/config"
 	storagemetadata "github.com/owncloud/ocis/extensions/storage-metadata/pkg/config"
@@ -100,4 +101,5 @@ type Config struct {
 	Store             *store.Config           `yaml:"store"`
 	Thumbnails        *thumbnails.Config      `yaml:"thumbnails"`
 	WebDAV            *webdav.Config          `yaml:"webdav"`
+	Search            *search.Config          `yaml:"search"`
 }
diff --git a/ocis-pkg/config/defaultconfig.go b/ocis-pkg/config/defaultconfig.go
index bd48781dda2..8798918bfce 100644
--- a/ocis-pkg/config/defaultconfig.go
+++ b/ocis-pkg/config/defaultconfig.go
@@ -20,6 +20,7 @@ import (
 	ocdav "github.com/owncloud/ocis/extensions/ocdav/pkg/config/defaults"
 	ocs "github.com/owncloud/ocis/extensions/ocs/pkg/config/defaults"
 	proxy "github.com/owncloud/ocis/extensions/proxy/pkg/config/defaults"
+	search "github.com/owncloud/ocis/extensions/search/pkg/config/defaults"
 	settings "github.com/owncloud/ocis/extensions/settings/pkg/config/defaults"
 	sharing "github.com/owncloud/ocis/extensions/sharing/pkg/config/defaults"
 	storagemetadata "github.com/owncloud/ocis/extensions/storage-metadata/pkg/config/defaults"
@@ -58,6 +59,7 @@ func DefaultConfig() *Config {
 		OCDav:             ocdav.DefaultConfig(),
 		OCS:               ocs.DefaultConfig(),
 		Proxy:             proxy.DefaultConfig(),
+		Search:            search.FullDefaultConfig(),
 		Settings:          settings.DefaultConfig(),
 		Sharing:           sharing.DefaultConfig(),
 		StorageMetadata:   storagemetadata.DefaultConfig(),
diff --git a/ocis/pkg/command/search.go b/ocis/pkg/command/search.go
new file mode 100644
index 00000000000..61ef308842d
--- /dev/null
+++ b/ocis/pkg/command/search.go
@@ -0,0 +1,26 @@
+package command
+
+import (
+	"github.com/owncloud/ocis/extensions/search/pkg/command"
+	"github.com/owncloud/ocis/ocis-pkg/config"
+	"github.com/owncloud/ocis/ocis-pkg/config/parser"
+	"github.com/owncloud/ocis/ocis/pkg/register"
+	"github.com/urfave/cli/v2"
+)
+
+// SearchCommand is the entry point for the search command.
+func SearchCommand(cfg *config.Config) *cli.Command {
+	return &cli.Command{
+		Name:     cfg.Search.Service.Name,
+		Usage:    subcommandDescription(cfg.Search.Service.Name),
+		Category: "extensions",
+		Before: func(ctx *cli.Context) error {
+			return parser.ParseConfig(cfg)
+		},
+		Subcommands: command.GetCommands(cfg.Search),
+	}
+}
+
+func init() {
+	register.AddCommand(SearchCommand)
+}
diff --git a/ocis/pkg/runtime/service/service.go b/ocis/pkg/runtime/service/service.go
index 19a1cf02e50..99f947155b3 100644
--- a/ocis/pkg/runtime/service/service.go
+++ b/ocis/pkg/runtime/service/service.go
@@ -37,6 +37,7 @@ import (
 	ocdav "github.com/owncloud/ocis/extensions/ocdav/pkg/command"
 	ocs "github.com/owncloud/ocis/extensions/ocs/pkg/command"
 	proxy "github.com/owncloud/ocis/extensions/proxy/pkg/command"
+	search "github.com/owncloud/ocis/extensions/search/pkg/command"
 	settings "github.com/owncloud/ocis/extensions/settings/pkg/command"
 	sharing "github.com/owncloud/ocis/extensions/sharing/pkg/command"
 	storagemetadata "github.com/owncloud/ocis/extensions/storage-metadata/pkg/command"
@@ -131,6 +132,7 @@ func NewService(options ...Option) (*Service, error) {
 	s.ServicesRegistry[opts.Config.StoragePublicLink.Service.Name] = storagepublic.NewStoragePublicLink
 	s.ServicesRegistry[opts.Config.AppProvider.Service.Name] = appprovider.NewAppProvider
 	s.ServicesRegistry[opts.Config.Notifications.Service.Name] = notifications.NewSutureService
+	s.ServicesRegistry[opts.Config.Search.Service.Name] = search.NewSutureService
 
 	// populate delayed services
 	s.Delayed[opts.Config.Sharing.Service.Name] = sharing.NewSharing
diff --git a/protogen/gen/ocis/messages/accounts/v0/accounts.pb.go b/protogen/gen/ocis/messages/accounts/v0/accounts.pb.go
index ced523b4aed..3937c7abad9 100644
--- a/protogen/gen/ocis/messages/accounts/v0/accounts.pb.go
+++ b/protogen/gen/ocis/messages/accounts/v0/accounts.pb.go
@@ -1,7 +1,7 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.27.1
-// 	protoc        v3.17.3
+// 	protoc-gen-go v1.28.0
+// 	protoc        (unknown)
 // source: ocis/messages/accounts/v0/accounts.proto
 
 package v0
diff --git a/protogen/gen/ocis/messages/search/v0/search.pb.go b/protogen/gen/ocis/messages/search/v0/search.pb.go
new file mode 100644
index 00000000000..1f3acae8c2d
--- /dev/null
+++ b/protogen/gen/ocis/messages/search/v0/search.pb.go
@@ -0,0 +1,471 @@
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.28.0
+// 	protoc        (unknown)
+// source: ocis/messages/search/v0/search.proto
+
+package v0
+
+import (
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	timestamppb "google.golang.org/protobuf/types/known/timestamppb"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+type ResourceID struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	StorageId string `protobuf:"bytes,1,opt,name=storage_id,json=storageId,proto3" json:"storage_id,omitempty"`
+	OpaqueId  string `protobuf:"bytes,2,opt,name=opaque_id,json=opaqueId,proto3" json:"opaque_id,omitempty"`
+}
+
+func (x *ResourceID) Reset() {
+	*x = ResourceID{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_ocis_messages_search_v0_search_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ResourceID) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ResourceID) ProtoMessage() {}
+
+func (x *ResourceID) ProtoReflect() protoreflect.Message {
+	mi := &file_ocis_messages_search_v0_search_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ResourceID.ProtoReflect.Descriptor instead.
+func (*ResourceID) Descriptor() ([]byte, []int) {
+	return file_ocis_messages_search_v0_search_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *ResourceID) GetStorageId() string {
+	if x != nil {
+		return x.StorageId
+	}
+	return ""
+}
+
+func (x *ResourceID) GetOpaqueId() string {
+	if x != nil {
+		return x.OpaqueId
+	}
+	return ""
+}
+
+type Reference struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	ResourceId *ResourceID `protobuf:"bytes,1,opt,name=resource_id,json=resourceId,proto3" json:"resource_id,omitempty"`
+	Path       string      `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"`
+}
+
+func (x *Reference) Reset() {
+	*x = Reference{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_ocis_messages_search_v0_search_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *Reference) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Reference) ProtoMessage() {}
+
+func (x *Reference) ProtoReflect() protoreflect.Message {
+	mi := &file_ocis_messages_search_v0_search_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Reference.ProtoReflect.Descriptor instead.
+func (*Reference) Descriptor() ([]byte, []int) {
+	return file_ocis_messages_search_v0_search_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *Reference) GetResourceId() *ResourceID {
+	if x != nil {
+		return x.ResourceId
+	}
+	return nil
+}
+
+func (x *Reference) GetPath() string {
+	if x != nil {
+		return x.Path
+	}
+	return ""
+}
+
+type Entity struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	Ref              *Reference             `protobuf:"bytes,1,opt,name=ref,proto3" json:"ref,omitempty"`
+	Id               *ResourceID            `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"`
+	Name             string                 `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"`
+	Etag             string                 `protobuf:"bytes,4,opt,name=etag,proto3" json:"etag,omitempty"`
+	Size             uint64                 `protobuf:"varint,5,opt,name=size,proto3" json:"size,omitempty"`
+	LastModifiedTime *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=last_modified_time,json=lastModifiedTime,proto3" json:"last_modified_time,omitempty"`
+	MimeType         string                 `protobuf:"bytes,7,opt,name=mime_type,json=mimeType,proto3" json:"mime_type,omitempty"`
+	Permissions      string                 `protobuf:"bytes,8,opt,name=permissions,proto3" json:"permissions,omitempty"`
+	Type             uint64                 `protobuf:"varint,9,opt,name=type,proto3" json:"type,omitempty"`
+	Deleted          bool                   `protobuf:"varint,10,opt,name=deleted,proto3" json:"deleted,omitempty"`
+}
+
+func (x *Entity) Reset() {
+	*x = Entity{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_ocis_messages_search_v0_search_proto_msgTypes[2]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *Entity) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Entity) ProtoMessage() {}
+
+func (x *Entity) ProtoReflect() protoreflect.Message {
+	mi := &file_ocis_messages_search_v0_search_proto_msgTypes[2]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Entity.ProtoReflect.Descriptor instead.
+func (*Entity) Descriptor() ([]byte, []int) {
+	return file_ocis_messages_search_v0_search_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *Entity) GetRef() *Reference {
+	if x != nil {
+		return x.Ref
+	}
+	return nil
+}
+
+func (x *Entity) GetId() *ResourceID {
+	if x != nil {
+		return x.Id
+	}
+	return nil
+}
+
+func (x *Entity) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *Entity) GetEtag() string {
+	if x != nil {
+		return x.Etag
+	}
+	return ""
+}
+
+func (x *Entity) GetSize() uint64 {
+	if x != nil {
+		return x.Size
+	}
+	return 0
+}
+
+func (x *Entity) GetLastModifiedTime() *timestamppb.Timestamp {
+	if x != nil {
+		return x.LastModifiedTime
+	}
+	return nil
+}
+
+func (x *Entity) GetMimeType() string {
+	if x != nil {
+		return x.MimeType
+	}
+	return ""
+}
+
+func (x *Entity) GetPermissions() string {
+	if x != nil {
+		return x.Permissions
+	}
+	return ""
+}
+
+func (x *Entity) GetType() uint64 {
+	if x != nil {
+		return x.Type
+	}
+	return 0
+}
+
+func (x *Entity) GetDeleted() bool {
+	if x != nil {
+		return x.Deleted
+	}
+	return false
+}
+
+type Match struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// the matched entity
+	Entity *Entity `protobuf:"bytes,1,opt,name=entity,proto3" json:"entity,omitempty"`
+	// the match score
+	Score float32 `protobuf:"fixed32,2,opt,name=score,proto3" json:"score,omitempty"`
+}
+
+func (x *Match) Reset() {
+	*x = Match{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_ocis_messages_search_v0_search_proto_msgTypes[3]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *Match) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Match) ProtoMessage() {}
+
+func (x *Match) ProtoReflect() protoreflect.Message {
+	mi := &file_ocis_messages_search_v0_search_proto_msgTypes[3]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Match.ProtoReflect.Descriptor instead.
+func (*Match) Descriptor() ([]byte, []int) {
+	return file_ocis_messages_search_v0_search_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *Match) GetEntity() *Entity {
+	if x != nil {
+		return x.Entity
+	}
+	return nil
+}
+
+func (x *Match) GetScore() float32 {
+	if x != nil {
+		return x.Score
+	}
+	return 0
+}
+
+var File_ocis_messages_search_v0_search_proto protoreflect.FileDescriptor
+
+var file_ocis_messages_search_v0_search_proto_rawDesc = []byte{
+	0x0a, 0x24, 0x6f, 0x63, 0x69, 0x73, 0x2f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2f,
+	0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2f, 0x76, 0x30, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68,
+	0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x17, 0x6f, 0x63, 0x69, 0x73, 0x2e, 0x6d, 0x65, 0x73,
+	0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x1a,
+	0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66,
+	0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+	0x22, 0x48, 0x0a, 0x0a, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x44, 0x12, 0x1d,
+	0x0a, 0x0a, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01,
+	0x28, 0x09, 0x52, 0x09, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x49, 0x64, 0x12, 0x1b, 0x0a,
+	0x09, 0x6f, 0x70, 0x61, 0x71, 0x75, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x08, 0x6f, 0x70, 0x61, 0x71, 0x75, 0x65, 0x49, 0x64, 0x22, 0x65, 0x0a, 0x09, 0x52, 0x65,
+	0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x44, 0x0a, 0x0b, 0x72, 0x65, 0x73, 0x6f, 0x75,
+	0x72, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x6f,
+	0x63, 0x69, 0x73, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61,
+	0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49,
+	0x44, 0x52, 0x0a, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x64, 0x12, 0x12, 0x0a,
+	0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74,
+	0x68, 0x22, 0xe6, 0x02, 0x0a, 0x06, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x34, 0x0a, 0x03,
+	0x72, 0x65, 0x66, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x6f, 0x63, 0x69, 0x73,
+	0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68,
+	0x2e, 0x76, 0x30, 0x2e, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x03, 0x72,
+	0x65, 0x66, 0x12, 0x33, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x23,
+	0x2e, 0x6f, 0x63, 0x69, 0x73, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73,
+	0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63,
+	0x65, 0x49, 0x44, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18,
+	0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x65,
+	0x74, 0x61, 0x67, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x65, 0x74, 0x61, 0x67, 0x12,
+	0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x04, 0x52, 0x04, 0x73,
+	0x69, 0x7a, 0x65, 0x12, 0x48, 0x0a, 0x12, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x6d, 0x6f, 0x64, 0x69,
+	0x66, 0x69, 0x65, 0x64, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32,
+	0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75,
+	0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x10, 0x6c, 0x61, 0x73,
+	0x74, 0x4d, 0x6f, 0x64, 0x69, 0x66, 0x69, 0x65, 0x64, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x1b, 0x0a,
+	0x09, 0x6d, 0x69, 0x6d, 0x65, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x08, 0x6d, 0x69, 0x6d, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x70, 0x65,
+	0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x0b, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x12, 0x0a, 0x04,
+	0x74, 0x79, 0x70, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x04, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65,
+	0x12, 0x18, 0x0a, 0x07, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28,
+	0x08, 0x52, 0x07, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x22, 0x56, 0x0a, 0x05, 0x4d, 0x61,
+	0x74, 0x63, 0x68, 0x12, 0x37, 0x0a, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x01, 0x20,
+	0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6f, 0x63, 0x69, 0x73, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61,
+	0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x45, 0x6e,
+	0x74, 0x69, 0x74, 0x79, 0x52, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x14, 0x0a, 0x05,
+	0x73, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x02, 0x52, 0x05, 0x73, 0x63, 0x6f,
+	0x72, 0x65, 0x42, 0x3f, 0x5a, 0x3d, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d,
+	0x2f, 0x6f, 0x77, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x6f, 0x63, 0x69, 0x73, 0x2f, 0x70,
+	0x72, 0x6f, 0x74, 0x6f, 0x67, 0x65, 0x6e, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x6f, 0x63, 0x69, 0x73,
+	0x2f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68,
+	0x2f, 0x76, 0x30, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_ocis_messages_search_v0_search_proto_rawDescOnce sync.Once
+	file_ocis_messages_search_v0_search_proto_rawDescData = file_ocis_messages_search_v0_search_proto_rawDesc
+)
+
+func file_ocis_messages_search_v0_search_proto_rawDescGZIP() []byte {
+	file_ocis_messages_search_v0_search_proto_rawDescOnce.Do(func() {
+		file_ocis_messages_search_v0_search_proto_rawDescData = protoimpl.X.CompressGZIP(file_ocis_messages_search_v0_search_proto_rawDescData)
+	})
+	return file_ocis_messages_search_v0_search_proto_rawDescData
+}
+
+var file_ocis_messages_search_v0_search_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
+var file_ocis_messages_search_v0_search_proto_goTypes = []interface{}{
+	(*ResourceID)(nil),            // 0: ocis.messages.search.v0.ResourceID
+	(*Reference)(nil),             // 1: ocis.messages.search.v0.Reference
+	(*Entity)(nil),                // 2: ocis.messages.search.v0.Entity
+	(*Match)(nil),                 // 3: ocis.messages.search.v0.Match
+	(*timestamppb.Timestamp)(nil), // 4: google.protobuf.Timestamp
+}
+var file_ocis_messages_search_v0_search_proto_depIdxs = []int32{
+	0, // 0: ocis.messages.search.v0.Reference.resource_id:type_name -> ocis.messages.search.v0.ResourceID
+	1, // 1: ocis.messages.search.v0.Entity.ref:type_name -> ocis.messages.search.v0.Reference
+	0, // 2: ocis.messages.search.v0.Entity.id:type_name -> ocis.messages.search.v0.ResourceID
+	4, // 3: ocis.messages.search.v0.Entity.last_modified_time:type_name -> google.protobuf.Timestamp
+	2, // 4: ocis.messages.search.v0.Match.entity:type_name -> ocis.messages.search.v0.Entity
+	5, // [5:5] is the sub-list for method output_type
+	5, // [5:5] is the sub-list for method input_type
+	5, // [5:5] is the sub-list for extension type_name
+	5, // [5:5] is the sub-list for extension extendee
+	0, // [0:5] is the sub-list for field type_name
+}
+
+func init() { file_ocis_messages_search_v0_search_proto_init() }
+func file_ocis_messages_search_v0_search_proto_init() {
+	if File_ocis_messages_search_v0_search_proto != nil {
+		return
+	}
+	if !protoimpl.UnsafeEnabled {
+		file_ocis_messages_search_v0_search_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ResourceID); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_ocis_messages_search_v0_search_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Reference); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_ocis_messages_search_v0_search_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Entity); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_ocis_messages_search_v0_search_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Match); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_ocis_messages_search_v0_search_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   4,
+			NumExtensions: 0,
+			NumServices:   0,
+		},
+		GoTypes:           file_ocis_messages_search_v0_search_proto_goTypes,
+		DependencyIndexes: file_ocis_messages_search_v0_search_proto_depIdxs,
+		MessageInfos:      file_ocis_messages_search_v0_search_proto_msgTypes,
+	}.Build()
+	File_ocis_messages_search_v0_search_proto = out.File
+	file_ocis_messages_search_v0_search_proto_rawDesc = nil
+	file_ocis_messages_search_v0_search_proto_goTypes = nil
+	file_ocis_messages_search_v0_search_proto_depIdxs = nil
+}
diff --git a/protogen/gen/ocis/messages/search/v0/search.pb.micro.go b/protogen/gen/ocis/messages/search/v0/search.pb.micro.go
new file mode 100644
index 00000000000..37fd9b52b91
--- /dev/null
+++ b/protogen/gen/ocis/messages/search/v0/search.pb.micro.go
@@ -0,0 +1,16 @@
+// Code generated by protoc-gen-micro. DO NOT EDIT.
+// source: ocis/messages/search/v0/search.proto
+
+package v0
+
+import (
+	fmt "fmt"
+	proto "google.golang.org/protobuf/proto"
+	_ "google.golang.org/protobuf/types/known/timestamppb"
+	math "math"
+)
+
+// Reference imports to suppress errors if they are not otherwise used.
+var _ = proto.Marshal
+var _ = fmt.Errorf
+var _ = math.Inf
diff --git a/protogen/gen/ocis/messages/search/v0/search.pb.web.go b/protogen/gen/ocis/messages/search/v0/search.pb.web.go
new file mode 100644
index 00000000000..7055e834977
--- /dev/null
+++ b/protogen/gen/ocis/messages/search/v0/search.pb.web.go
@@ -0,0 +1,155 @@
+// Code generated by protoc-gen-microweb. DO NOT EDIT.
+// source: v0.proto
+
+package v0
+
+import (
+	"bytes"
+	"encoding/json"
+
+	"github.com/golang/protobuf/jsonpb"
+)
+
+// ResourceIDJSONMarshaler describes the default jsonpb.Marshaler used by all
+// instances of ResourceID. This struct is safe to replace or modify but
+// should not be done so concurrently.
+var ResourceIDJSONMarshaler = new(jsonpb.Marshaler)
+
+// MarshalJSON satisfies the encoding/json Marshaler interface. This method
+// uses the more correct jsonpb package to correctly marshal the message.
+func (m *ResourceID) MarshalJSON() ([]byte, error) {
+	if m == nil {
+		return json.Marshal(nil)
+	}
+
+	buf := &bytes.Buffer{}
+
+	if err := ResourceIDJSONMarshaler.Marshal(buf, m); err != nil {
+		return nil, err
+	}
+
+	return buf.Bytes(), nil
+}
+
+var _ json.Marshaler = (*ResourceID)(nil)
+
+// ResourceIDJSONUnmarshaler describes the default jsonpb.Unmarshaler used by all
+// instances of ResourceID. This struct is safe to replace or modify but
+// should not be done so concurrently.
+var ResourceIDJSONUnmarshaler = new(jsonpb.Unmarshaler)
+
+// UnmarshalJSON satisfies the encoding/json Unmarshaler interface. This method
+// uses the more correct jsonpb package to correctly unmarshal the message.
+func (m *ResourceID) UnmarshalJSON(b []byte) error {
+	return ResourceIDJSONUnmarshaler.Unmarshal(bytes.NewReader(b), m)
+}
+
+var _ json.Unmarshaler = (*ResourceID)(nil)
+
+// ReferenceJSONMarshaler describes the default jsonpb.Marshaler used by all
+// instances of Reference. This struct is safe to replace or modify but
+// should not be done so concurrently.
+var ReferenceJSONMarshaler = new(jsonpb.Marshaler)
+
+// MarshalJSON satisfies the encoding/json Marshaler interface. This method
+// uses the more correct jsonpb package to correctly marshal the message.
+func (m *Reference) MarshalJSON() ([]byte, error) {
+	if m == nil {
+		return json.Marshal(nil)
+	}
+
+	buf := &bytes.Buffer{}
+
+	if err := ReferenceJSONMarshaler.Marshal(buf, m); err != nil {
+		return nil, err
+	}
+
+	return buf.Bytes(), nil
+}
+
+var _ json.Marshaler = (*Reference)(nil)
+
+// ReferenceJSONUnmarshaler describes the default jsonpb.Unmarshaler used by all
+// instances of Reference. This struct is safe to replace or modify but
+// should not be done so concurrently.
+var ReferenceJSONUnmarshaler = new(jsonpb.Unmarshaler)
+
+// UnmarshalJSON satisfies the encoding/json Unmarshaler interface. This method
+// uses the more correct jsonpb package to correctly unmarshal the message.
+func (m *Reference) UnmarshalJSON(b []byte) error {
+	return ReferenceJSONUnmarshaler.Unmarshal(bytes.NewReader(b), m)
+}
+
+var _ json.Unmarshaler = (*Reference)(nil)
+
+// EntityJSONMarshaler describes the default jsonpb.Marshaler used by all
+// instances of Entity. This struct is safe to replace or modify but
+// should not be done so concurrently.
+var EntityJSONMarshaler = new(jsonpb.Marshaler)
+
+// MarshalJSON satisfies the encoding/json Marshaler interface. This method
+// uses the more correct jsonpb package to correctly marshal the message.
+func (m *Entity) MarshalJSON() ([]byte, error) {
+	if m == nil {
+		return json.Marshal(nil)
+	}
+
+	buf := &bytes.Buffer{}
+
+	if err := EntityJSONMarshaler.Marshal(buf, m); err != nil {
+		return nil, err
+	}
+
+	return buf.Bytes(), nil
+}
+
+var _ json.Marshaler = (*Entity)(nil)
+
+// EntityJSONUnmarshaler describes the default jsonpb.Unmarshaler used by all
+// instances of Entity. This struct is safe to replace or modify but
+// should not be done so concurrently.
+var EntityJSONUnmarshaler = new(jsonpb.Unmarshaler)
+
+// UnmarshalJSON satisfies the encoding/json Unmarshaler interface. This method
+// uses the more correct jsonpb package to correctly unmarshal the message.
+func (m *Entity) UnmarshalJSON(b []byte) error {
+	return EntityJSONUnmarshaler.Unmarshal(bytes.NewReader(b), m)
+}
+
+var _ json.Unmarshaler = (*Entity)(nil)
+
+// MatchJSONMarshaler describes the default jsonpb.Marshaler used by all
+// instances of Match. This struct is safe to replace or modify but
+// should not be done so concurrently.
+var MatchJSONMarshaler = new(jsonpb.Marshaler)
+
+// MarshalJSON satisfies the encoding/json Marshaler interface. This method
+// uses the more correct jsonpb package to correctly marshal the message.
+func (m *Match) MarshalJSON() ([]byte, error) {
+	if m == nil {
+		return json.Marshal(nil)
+	}
+
+	buf := &bytes.Buffer{}
+
+	if err := MatchJSONMarshaler.Marshal(buf, m); err != nil {
+		return nil, err
+	}
+
+	return buf.Bytes(), nil
+}
+
+var _ json.Marshaler = (*Match)(nil)
+
+// MatchJSONUnmarshaler describes the default jsonpb.Unmarshaler used by all
+// instances of Match. This struct is safe to replace or modify but
+// should not be done so concurrently.
+var MatchJSONUnmarshaler = new(jsonpb.Unmarshaler)
+
+// UnmarshalJSON satisfies the encoding/json Unmarshaler interface. This method
+// uses the more correct jsonpb package to correctly unmarshal the message.
+func (m *Match) UnmarshalJSON(b []byte) error {
+	return MatchJSONUnmarshaler.Unmarshal(bytes.NewReader(b), m)
+}
+
+var _ json.Unmarshaler = (*Match)(nil)
diff --git a/protogen/gen/ocis/messages/search/v0/search.swagger.json b/protogen/gen/ocis/messages/search/v0/search.swagger.json
new file mode 100644
index 00000000000..dd7fdc35883
--- /dev/null
+++ b/protogen/gen/ocis/messages/search/v0/search.swagger.json
@@ -0,0 +1,43 @@
+{
+  "swagger": "2.0",
+  "info": {
+    "title": "ocis/messages/search/v0/search.proto",
+    "version": "version not set"
+  },
+  "consumes": [
+    "application/json"
+  ],
+  "produces": [
+    "application/json"
+  ],
+  "paths": {},
+  "definitions": {
+    "protobufAny": {
+      "type": "object",
+      "properties": {
+        "@type": {
+          "type": "string"
+        }
+      },
+      "additionalProperties": {}
+    },
+    "rpcStatus": {
+      "type": "object",
+      "properties": {
+        "code": {
+          "type": "integer",
+          "format": "int32"
+        },
+        "message": {
+          "type": "string"
+        },
+        "details": {
+          "type": "array",
+          "items": {
+            "$ref": "#/definitions/protobufAny"
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/protogen/gen/ocis/messages/settings/v0/settings.pb.go b/protogen/gen/ocis/messages/settings/v0/settings.pb.go
index c4522665c36..c03cacf632e 100644
--- a/protogen/gen/ocis/messages/settings/v0/settings.pb.go
+++ b/protogen/gen/ocis/messages/settings/v0/settings.pb.go
@@ -1,7 +1,7 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.27.1
-// 	protoc        v3.17.3
+// 	protoc-gen-go v1.28.0
+// 	protoc        (unknown)
 // source: ocis/messages/settings/v0/settings.proto
 
 package v0
diff --git a/protogen/gen/ocis/messages/store/v0/store.pb.go b/protogen/gen/ocis/messages/store/v0/store.pb.go
index 1471dda61c1..0783db6ec36 100644
--- a/protogen/gen/ocis/messages/store/v0/store.pb.go
+++ b/protogen/gen/ocis/messages/store/v0/store.pb.go
@@ -1,7 +1,7 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.27.1
-// 	protoc        v3.17.3
+// 	protoc-gen-go v1.28.0
+// 	protoc        (unknown)
 // source: ocis/messages/store/v0/store.proto
 
 package v0
diff --git a/protogen/gen/ocis/messages/thumbnails/v0/thumbnails.pb.go b/protogen/gen/ocis/messages/thumbnails/v0/thumbnails.pb.go
index 25721838c88..d6198d3d7d5 100644
--- a/protogen/gen/ocis/messages/thumbnails/v0/thumbnails.pb.go
+++ b/protogen/gen/ocis/messages/thumbnails/v0/thumbnails.pb.go
@@ -1,7 +1,7 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.27.1
-// 	protoc        v3.17.3
+// 	protoc-gen-go v1.28.0
+// 	protoc        (unknown)
 // source: ocis/messages/thumbnails/v0/thumbnails.proto
 
 package v0
diff --git a/protogen/gen/ocis/services/accounts/v0/accounts.pb.go b/protogen/gen/ocis/services/accounts/v0/accounts.pb.go
index bc4f5c54d7a..e5ff1ca4ca3 100644
--- a/protogen/gen/ocis/services/accounts/v0/accounts.pb.go
+++ b/protogen/gen/ocis/services/accounts/v0/accounts.pb.go
@@ -1,7 +1,7 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.27.1
-// 	protoc        v3.17.3
+// 	protoc-gen-go v1.28.0
+// 	protoc        (unknown)
 // source: ocis/services/accounts/v0/accounts.proto
 
 package v0
diff --git a/protogen/gen/ocis/services/search/v0/search.pb.go b/protogen/gen/ocis/services/search/v0/search.pb.go
new file mode 100644
index 00000000000..236bfe95a01
--- /dev/null
+++ b/protogen/gen/ocis/services/search/v0/search.pb.go
@@ -0,0 +1,621 @@
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.28.0
+// 	protoc        (unknown)
+// source: ocis/services/search/v0/search.proto
+
+package v0
+
+import (
+	_ "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2/options"
+	v0 "github.com/owncloud/ocis/protogen/gen/ocis/messages/search/v0"
+	_ "google.golang.org/genproto/googleapis/api/annotations"
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	_ "google.golang.org/protobuf/types/known/fieldmaskpb"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+type SearchRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Optional. The maximum number of entries to return in the response
+	PageSize int32 `protobuf:"varint,1,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
+	// Optional. A pagination token returned from a previous call to `Get`
+	// that indicates from where search should continue
+	PageToken string `protobuf:"bytes,2,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"`
+	Query     string `protobuf:"bytes,3,opt,name=query,proto3" json:"query,omitempty"`
+}
+
+func (x *SearchRequest) Reset() {
+	*x = SearchRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_ocis_services_search_v0_search_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *SearchRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*SearchRequest) ProtoMessage() {}
+
+func (x *SearchRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_ocis_services_search_v0_search_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use SearchRequest.ProtoReflect.Descriptor instead.
+func (*SearchRequest) Descriptor() ([]byte, []int) {
+	return file_ocis_services_search_v0_search_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *SearchRequest) GetPageSize() int32 {
+	if x != nil {
+		return x.PageSize
+	}
+	return 0
+}
+
+func (x *SearchRequest) GetPageToken() string {
+	if x != nil {
+		return x.PageToken
+	}
+	return ""
+}
+
+func (x *SearchRequest) GetQuery() string {
+	if x != nil {
+		return x.Query
+	}
+	return ""
+}
+
+type SearchResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	Matches []*v0.Match `protobuf:"bytes,1,rep,name=matches,proto3" json:"matches,omitempty"`
+	// Token to retrieve the next page of results, or empty if there are no
+	// more results in the list
+	NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"`
+}
+
+func (x *SearchResponse) Reset() {
+	*x = SearchResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_ocis_services_search_v0_search_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *SearchResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*SearchResponse) ProtoMessage() {}
+
+func (x *SearchResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_ocis_services_search_v0_search_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use SearchResponse.ProtoReflect.Descriptor instead.
+func (*SearchResponse) Descriptor() ([]byte, []int) {
+	return file_ocis_services_search_v0_search_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *SearchResponse) GetMatches() []*v0.Match {
+	if x != nil {
+		return x.Matches
+	}
+	return nil
+}
+
+func (x *SearchResponse) GetNextPageToken() string {
+	if x != nil {
+		return x.NextPageToken
+	}
+	return ""
+}
+
+type SearchIndexRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Optional. The maximum number of entries to return in the response
+	PageSize int32 `protobuf:"varint,1,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
+	// Optional. A pagination token returned from a previous call to `Get`
+	// that indicates from where search should continue
+	PageToken string        `protobuf:"bytes,2,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"`
+	Query     string        `protobuf:"bytes,3,opt,name=query,proto3" json:"query,omitempty"`
+	Ref       *v0.Reference `protobuf:"bytes,4,opt,name=ref,proto3" json:"ref,omitempty"`
+}
+
+func (x *SearchIndexRequest) Reset() {
+	*x = SearchIndexRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_ocis_services_search_v0_search_proto_msgTypes[2]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *SearchIndexRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*SearchIndexRequest) ProtoMessage() {}
+
+func (x *SearchIndexRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_ocis_services_search_v0_search_proto_msgTypes[2]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use SearchIndexRequest.ProtoReflect.Descriptor instead.
+func (*SearchIndexRequest) Descriptor() ([]byte, []int) {
+	return file_ocis_services_search_v0_search_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *SearchIndexRequest) GetPageSize() int32 {
+	if x != nil {
+		return x.PageSize
+	}
+	return 0
+}
+
+func (x *SearchIndexRequest) GetPageToken() string {
+	if x != nil {
+		return x.PageToken
+	}
+	return ""
+}
+
+func (x *SearchIndexRequest) GetQuery() string {
+	if x != nil {
+		return x.Query
+	}
+	return ""
+}
+
+func (x *SearchIndexRequest) GetRef() *v0.Reference {
+	if x != nil {
+		return x.Ref
+	}
+	return nil
+}
+
+type SearchIndexResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	Matches []*v0.Match `protobuf:"bytes,1,rep,name=matches,proto3" json:"matches,omitempty"`
+	// Token to retrieve the next page of results, or empty if there are no
+	// more results in the list
+	NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"`
+}
+
+func (x *SearchIndexResponse) Reset() {
+	*x = SearchIndexResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_ocis_services_search_v0_search_proto_msgTypes[3]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *SearchIndexResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*SearchIndexResponse) ProtoMessage() {}
+
+func (x *SearchIndexResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_ocis_services_search_v0_search_proto_msgTypes[3]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use SearchIndexResponse.ProtoReflect.Descriptor instead.
+func (*SearchIndexResponse) Descriptor() ([]byte, []int) {
+	return file_ocis_services_search_v0_search_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *SearchIndexResponse) GetMatches() []*v0.Match {
+	if x != nil {
+		return x.Matches
+	}
+	return nil
+}
+
+func (x *SearchIndexResponse) GetNextPageToken() string {
+	if x != nil {
+		return x.NextPageToken
+	}
+	return ""
+}
+
+type IndexSpaceRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	SpaceId string `protobuf:"bytes,1,opt,name=space_id,json=spaceId,proto3" json:"space_id,omitempty"`
+	UserId  string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
+}
+
+func (x *IndexSpaceRequest) Reset() {
+	*x = IndexSpaceRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_ocis_services_search_v0_search_proto_msgTypes[4]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *IndexSpaceRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*IndexSpaceRequest) ProtoMessage() {}
+
+func (x *IndexSpaceRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_ocis_services_search_v0_search_proto_msgTypes[4]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use IndexSpaceRequest.ProtoReflect.Descriptor instead.
+func (*IndexSpaceRequest) Descriptor() ([]byte, []int) {
+	return file_ocis_services_search_v0_search_proto_rawDescGZIP(), []int{4}
+}
+
+func (x *IndexSpaceRequest) GetSpaceId() string {
+	if x != nil {
+		return x.SpaceId
+	}
+	return ""
+}
+
+func (x *IndexSpaceRequest) GetUserId() string {
+	if x != nil {
+		return x.UserId
+	}
+	return ""
+}
+
+type IndexSpaceResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+}
+
+func (x *IndexSpaceResponse) Reset() {
+	*x = IndexSpaceResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_ocis_services_search_v0_search_proto_msgTypes[5]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *IndexSpaceResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*IndexSpaceResponse) ProtoMessage() {}
+
+func (x *IndexSpaceResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_ocis_services_search_v0_search_proto_msgTypes[5]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use IndexSpaceResponse.ProtoReflect.Descriptor instead.
+func (*IndexSpaceResponse) Descriptor() ([]byte, []int) {
+	return file_ocis_services_search_v0_search_proto_rawDescGZIP(), []int{5}
+}
+
+var File_ocis_services_search_v0_search_proto protoreflect.FileDescriptor
+
+var file_ocis_services_search_v0_search_proto_rawDesc = []byte{
+	0x0a, 0x24, 0x6f, 0x63, 0x69, 0x73, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2f,
+	0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2f, 0x76, 0x30, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68,
+	0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x17, 0x6f, 0x63, 0x69, 0x73, 0x2e, 0x73, 0x65, 0x72,
+	0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x1a,
+	0x24, 0x6f, 0x63, 0x69, 0x73, 0x2f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2f, 0x73,
+	0x65, 0x61, 0x72, 0x63, 0x68, 0x2f, 0x76, 0x30, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e,
+	0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x2d, 0x67, 0x65,
+	0x6e, 0x2d, 0x6f, 0x70, 0x65, 0x6e, 0x61, 0x70, 0x69, 0x76, 0x32, 0x2f, 0x6f, 0x70, 0x74, 0x69,
+	0x6f, 0x6e, 0x73, 0x2f, 0x61, 0x6e, 0x6e, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x2e,
+	0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x61, 0x70,
+	0x69, 0x2f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x5f, 0x62, 0x65, 0x68, 0x61, 0x76, 0x69, 0x6f, 0x72,
+	0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1c, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x61,
+	0x70, 0x69, 0x2f, 0x61, 0x6e, 0x6e, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x2e, 0x70,
+	0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x20, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f,
+	0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x5f, 0x6d, 0x61, 0x73, 0x6b,
+	0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x6d, 0x0a, 0x0d, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68,
+	0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x21, 0x0a, 0x09, 0x70, 0x61, 0x67, 0x65, 0x5f,
+	0x73, 0x69, 0x7a, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01,
+	0x52, 0x08, 0x70, 0x61, 0x67, 0x65, 0x53, 0x69, 0x7a, 0x65, 0x12, 0x23, 0x0a, 0x0a, 0x70, 0x61,
+	0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x04,
+	0xe2, 0x41, 0x01, 0x01, 0x52, 0x09, 0x70, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12,
+	0x14, 0x0a, 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05,
+	0x71, 0x75, 0x65, 0x72, 0x79, 0x22, 0x72, 0x0a, 0x0e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52,
+	0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x38, 0x0a, 0x07, 0x6d, 0x61, 0x74, 0x63, 0x68,
+	0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6f, 0x63, 0x69, 0x73, 0x2e,
+	0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e,
+	0x76, 0x30, 0x2e, 0x4d, 0x61, 0x74, 0x63, 0x68, 0x52, 0x07, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x65,
+	0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74,
+	0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74,
+	0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0xae, 0x01, 0x0a, 0x12, 0x53, 0x65,
+	0x61, 0x72, 0x63, 0x68, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
+	0x12, 0x21, 0x0a, 0x09, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x01, 0x20,
+	0x01, 0x28, 0x05, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x08, 0x70, 0x61, 0x67, 0x65, 0x53,
+	0x69, 0x7a, 0x65, 0x12, 0x23, 0x0a, 0x0a, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65,
+	0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x09, 0x70,
+	0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x71, 0x75, 0x65, 0x72,
+	0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x12, 0x3a,
+	0x0a, 0x03, 0x72, 0x65, 0x66, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x6f, 0x63,
+	0x69, 0x73, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72,
+	0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x42,
+	0x04, 0xe2, 0x41, 0x01, 0x01, 0x52, 0x03, 0x72, 0x65, 0x66, 0x22, 0x77, 0x0a, 0x13, 0x53, 0x65,
+	0x61, 0x72, 0x63, 0x68, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
+	0x65, 0x12, 0x38, 0x0a, 0x07, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03,
+	0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6f, 0x63, 0x69, 0x73, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67,
+	0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x4d, 0x61, 0x74,
+	0x63, 0x68, 0x52, 0x07, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x65, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e,
+	0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02,
+	0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f,
+	0x6b, 0x65, 0x6e, 0x22, 0x47, 0x0a, 0x11, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x70, 0x61, 0x63,
+	0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x73, 0x70, 0x61, 0x63,
+	0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x70, 0x61, 0x63,
+	0x65, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02,
+	0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0x14, 0x0a, 0x12,
+	0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x70, 0x61, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
+	0x73, 0x65, 0x32, 0x9c, 0x02, 0x0a, 0x0e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x50, 0x72, 0x6f,
+	0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x7b, 0x0a, 0x06, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x12,
+	0x26, 0x2e, 0x6f, 0x63, 0x69, 0x73, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e,
+	0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68,
+	0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x6f, 0x63, 0x69, 0x73, 0x2e, 0x73,
+	0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76,
+	0x30, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
+	0x22, 0x20, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x1a, 0x22, 0x15, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76,
+	0x30, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x3a,
+	0x01, 0x2a, 0x12, 0x8c, 0x01, 0x0a, 0x0a, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x70, 0x61, 0x63,
+	0x65, 0x12, 0x2a, 0x2e, 0x6f, 0x63, 0x69, 0x73, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65,
+	0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x49, 0x6e, 0x64, 0x65,
+	0x78, 0x53, 0x70, 0x61, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2b, 0x2e,
+	0x6f, 0x63, 0x69, 0x73, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65,
+	0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x70, 0x61,
+	0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x25, 0x82, 0xd3, 0xe4, 0x93,
+	0x02, 0x1f, 0x22, 0x1a, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x30, 0x2f, 0x73, 0x65, 0x61, 0x72,
+	0x63, 0x68, 0x2f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x2d, 0x73, 0x70, 0x61, 0x63, 0x65, 0x3a, 0x01,
+	0x2a, 0x32, 0x9d, 0x01, 0x0a, 0x0d, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x50, 0x72, 0x6f, 0x76, 0x69,
+	0x64, 0x65, 0x72, 0x12, 0x8b, 0x01, 0x0a, 0x06, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x12, 0x2b,
+	0x2e, 0x6f, 0x63, 0x69, 0x73, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73,
+	0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x49,
+	0x6e, 0x64, 0x65, 0x78, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x6f, 0x63,
+	0x69, 0x73, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72,
+	0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x49, 0x6e, 0x64, 0x65,
+	0x78, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x26, 0x82, 0xd3, 0xe4, 0x93, 0x02,
+	0x20, 0x22, 0x1b, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x30, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63,
+	0x68, 0x2f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x3a, 0x01,
+	0x2a, 0x42, 0xde, 0x02, 0x5a, 0x3c, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d,
+	0x2f, 0x6f, 0x77, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x6f, 0x63, 0x69, 0x73, 0x2f, 0x70,
+	0x72, 0x6f, 0x74, 0x6f, 0x67, 0x65, 0x6e, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x6f, 0x63, 0x69, 0x73,
+	0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2f,
+	0x76, 0x30, 0x92, 0x41, 0x9c, 0x02, 0x12, 0xb4, 0x01, 0x0a, 0x1e, 0x6f, 0x77, 0x6e, 0x43, 0x6c,
+	0x6f, 0x75, 0x64, 0x20, 0x49, 0x6e, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x65, 0x20, 0x53, 0x63, 0x61,
+	0x6c, 0x65, 0x20, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x22, 0x47, 0x0a, 0x0d, 0x6f, 0x77, 0x6e,
+	0x43, 0x6c, 0x6f, 0x75, 0x64, 0x20, 0x47, 0x6d, 0x62, 0x48, 0x12, 0x20, 0x68, 0x74, 0x74, 0x70,
+	0x73, 0x3a, 0x2f, 0x2f, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f,
+	0x77, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x6f, 0x63, 0x69, 0x73, 0x1a, 0x14, 0x73, 0x75,
+	0x70, 0x70, 0x6f, 0x72, 0x74, 0x40, 0x6f, 0x77, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x63,
+	0x6f, 0x6d, 0x2a, 0x42, 0x0a, 0x0a, 0x41, 0x70, 0x61, 0x63, 0x68, 0x65, 0x2d, 0x32, 0x2e, 0x30,
+	0x12, 0x34, 0x68, 0x74, 0x74, 0x70, 0x73, 0x3a, 0x2f, 0x2f, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62,
+	0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x77, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x6f, 0x63,
+	0x69, 0x73, 0x2f, 0x62, 0x6c, 0x6f, 0x62, 0x2f, 0x6d, 0x61, 0x73, 0x74, 0x65, 0x72, 0x2f, 0x4c,
+	0x49, 0x43, 0x45, 0x4e, 0x53, 0x45, 0x32, 0x05, 0x31, 0x2e, 0x30, 0x2e, 0x30, 0x2a, 0x02, 0x01,
+	0x02, 0x32, 0x10, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2f, 0x6a,
+	0x73, 0x6f, 0x6e, 0x3a, 0x10, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e,
+	0x2f, 0x6a, 0x73, 0x6f, 0x6e, 0x72, 0x3b, 0x0a, 0x10, 0x44, 0x65, 0x76, 0x65, 0x6c, 0x6f, 0x70,
+	0x65, 0x72, 0x20, 0x4d, 0x61, 0x6e, 0x75, 0x61, 0x6c, 0x12, 0x27, 0x68, 0x74, 0x74, 0x70, 0x73,
+	0x3a, 0x2f, 0x2f, 0x6f, 0x77, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x64, 0x65, 0x76, 0x2f,
+	0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63,
+	0x68, 0x2f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_ocis_services_search_v0_search_proto_rawDescOnce sync.Once
+	file_ocis_services_search_v0_search_proto_rawDescData = file_ocis_services_search_v0_search_proto_rawDesc
+)
+
+func file_ocis_services_search_v0_search_proto_rawDescGZIP() []byte {
+	file_ocis_services_search_v0_search_proto_rawDescOnce.Do(func() {
+		file_ocis_services_search_v0_search_proto_rawDescData = protoimpl.X.CompressGZIP(file_ocis_services_search_v0_search_proto_rawDescData)
+	})
+	return file_ocis_services_search_v0_search_proto_rawDescData
+}
+
+var file_ocis_services_search_v0_search_proto_msgTypes = make([]protoimpl.MessageInfo, 6)
+var file_ocis_services_search_v0_search_proto_goTypes = []interface{}{
+	(*SearchRequest)(nil),       // 0: ocis.services.search.v0.SearchRequest
+	(*SearchResponse)(nil),      // 1: ocis.services.search.v0.SearchResponse
+	(*SearchIndexRequest)(nil),  // 2: ocis.services.search.v0.SearchIndexRequest
+	(*SearchIndexResponse)(nil), // 3: ocis.services.search.v0.SearchIndexResponse
+	(*IndexSpaceRequest)(nil),   // 4: ocis.services.search.v0.IndexSpaceRequest
+	(*IndexSpaceResponse)(nil),  // 5: ocis.services.search.v0.IndexSpaceResponse
+	(*v0.Match)(nil),            // 6: ocis.messages.search.v0.Match
+	(*v0.Reference)(nil),        // 7: ocis.messages.search.v0.Reference
+}
+var file_ocis_services_search_v0_search_proto_depIdxs = []int32{
+	6, // 0: ocis.services.search.v0.SearchResponse.matches:type_name -> ocis.messages.search.v0.Match
+	7, // 1: ocis.services.search.v0.SearchIndexRequest.ref:type_name -> ocis.messages.search.v0.Reference
+	6, // 2: ocis.services.search.v0.SearchIndexResponse.matches:type_name -> ocis.messages.search.v0.Match
+	0, // 3: ocis.services.search.v0.SearchProvider.Search:input_type -> ocis.services.search.v0.SearchRequest
+	4, // 4: ocis.services.search.v0.SearchProvider.IndexSpace:input_type -> ocis.services.search.v0.IndexSpaceRequest
+	2, // 5: ocis.services.search.v0.IndexProvider.Search:input_type -> ocis.services.search.v0.SearchIndexRequest
+	1, // 6: ocis.services.search.v0.SearchProvider.Search:output_type -> ocis.services.search.v0.SearchResponse
+	5, // 7: ocis.services.search.v0.SearchProvider.IndexSpace:output_type -> ocis.services.search.v0.IndexSpaceResponse
+	3, // 8: ocis.services.search.v0.IndexProvider.Search:output_type -> ocis.services.search.v0.SearchIndexResponse
+	6, // [6:9] is the sub-list for method output_type
+	3, // [3:6] is the sub-list for method input_type
+	3, // [3:3] is the sub-list for extension type_name
+	3, // [3:3] is the sub-list for extension extendee
+	0, // [0:3] is the sub-list for field type_name
+}
+
+func init() { file_ocis_services_search_v0_search_proto_init() }
+func file_ocis_services_search_v0_search_proto_init() {
+	if File_ocis_services_search_v0_search_proto != nil {
+		return
+	}
+	if !protoimpl.UnsafeEnabled {
+		file_ocis_services_search_v0_search_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*SearchRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_ocis_services_search_v0_search_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*SearchResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_ocis_services_search_v0_search_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*SearchIndexRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_ocis_services_search_v0_search_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*SearchIndexResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_ocis_services_search_v0_search_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*IndexSpaceRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_ocis_services_search_v0_search_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*IndexSpaceResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_ocis_services_search_v0_search_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   6,
+			NumExtensions: 0,
+			NumServices:   2,
+		},
+		GoTypes:           file_ocis_services_search_v0_search_proto_goTypes,
+		DependencyIndexes: file_ocis_services_search_v0_search_proto_depIdxs,
+		MessageInfos:      file_ocis_services_search_v0_search_proto_msgTypes,
+	}.Build()
+	File_ocis_services_search_v0_search_proto = out.File
+	file_ocis_services_search_v0_search_proto_rawDesc = nil
+	file_ocis_services_search_v0_search_proto_goTypes = nil
+	file_ocis_services_search_v0_search_proto_depIdxs = nil
+}
diff --git a/protogen/gen/ocis/services/search/v0/search.pb.micro.go b/protogen/gen/ocis/services/search/v0/search.pb.micro.go
new file mode 100644
index 00000000000..06e5ea9aedb
--- /dev/null
+++ b/protogen/gen/ocis/services/search/v0/search.pb.micro.go
@@ -0,0 +1,211 @@
+// Code generated by protoc-gen-micro. DO NOT EDIT.
+// source: ocis/services/search/v0/search.proto
+
+package v0
+
+import (
+	fmt "fmt"
+	_ "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2/options"
+	_ "github.com/owncloud/ocis/protogen/gen/ocis/messages/search/v0"
+	_ "google.golang.org/genproto/googleapis/api/annotations"
+	proto "google.golang.org/protobuf/proto"
+	_ "google.golang.org/protobuf/types/known/fieldmaskpb"
+	math "math"
+)
+
+import (
+	context "context"
+	api "go-micro.dev/v4/api"
+	client "go-micro.dev/v4/client"
+	server "go-micro.dev/v4/server"
+)
+
+// Reference imports to suppress errors if they are not otherwise used.
+var _ = proto.Marshal
+var _ = fmt.Errorf
+var _ = math.Inf
+
+// Reference imports to suppress errors if they are not otherwise used.
+var _ api.Endpoint
+var _ context.Context
+var _ client.Option
+var _ server.Option
+
+// Api Endpoints for SearchProvider service
+
+func NewSearchProviderEndpoints() []*api.Endpoint {
+	return []*api.Endpoint{
+		{
+			Name:    "SearchProvider.Search",
+			Path:    []string{"/api/v0/search/search"},
+			Method:  []string{"POST"},
+			Body:    "*",
+			Handler: "rpc",
+		},
+		{
+			Name:    "SearchProvider.IndexSpace",
+			Path:    []string{"/api/v0/search/index-space"},
+			Method:  []string{"POST"},
+			Body:    "*",
+			Handler: "rpc",
+		},
+	}
+}
+
+// Client API for SearchProvider service
+
+type SearchProviderService interface {
+	Search(ctx context.Context, in *SearchRequest, opts ...client.CallOption) (*SearchResponse, error)
+	IndexSpace(ctx context.Context, in *IndexSpaceRequest, opts ...client.CallOption) (*IndexSpaceResponse, error)
+}
+
+type searchProviderService struct {
+	c    client.Client
+	name string
+}
+
+func NewSearchProviderService(name string, c client.Client) SearchProviderService {
+	return &searchProviderService{
+		c:    c,
+		name: name,
+	}
+}
+
+func (c *searchProviderService) Search(ctx context.Context, in *SearchRequest, opts ...client.CallOption) (*SearchResponse, error) {
+	req := c.c.NewRequest(c.name, "SearchProvider.Search", in)
+	out := new(SearchResponse)
+	err := c.c.Call(ctx, req, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *searchProviderService) IndexSpace(ctx context.Context, in *IndexSpaceRequest, opts ...client.CallOption) (*IndexSpaceResponse, error) {
+	req := c.c.NewRequest(c.name, "SearchProvider.IndexSpace", in)
+	out := new(IndexSpaceResponse)
+	err := c.c.Call(ctx, req, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+// Server API for SearchProvider service
+
+type SearchProviderHandler interface {
+	Search(context.Context, *SearchRequest, *SearchResponse) error
+	IndexSpace(context.Context, *IndexSpaceRequest, *IndexSpaceResponse) error
+}
+
+func RegisterSearchProviderHandler(s server.Server, hdlr SearchProviderHandler, opts ...server.HandlerOption) error {
+	type searchProvider interface {
+		Search(ctx context.Context, in *SearchRequest, out *SearchResponse) error
+		IndexSpace(ctx context.Context, in *IndexSpaceRequest, out *IndexSpaceResponse) error
+	}
+	type SearchProvider struct {
+		searchProvider
+	}
+	h := &searchProviderHandler{hdlr}
+	opts = append(opts, api.WithEndpoint(&api.Endpoint{
+		Name:    "SearchProvider.Search",
+		Path:    []string{"/api/v0/search/search"},
+		Method:  []string{"POST"},
+		Body:    "*",
+		Handler: "rpc",
+	}))
+	opts = append(opts, api.WithEndpoint(&api.Endpoint{
+		Name:    "SearchProvider.IndexSpace",
+		Path:    []string{"/api/v0/search/index-space"},
+		Method:  []string{"POST"},
+		Body:    "*",
+		Handler: "rpc",
+	}))
+	return s.Handle(s.NewHandler(&SearchProvider{h}, opts...))
+}
+
+type searchProviderHandler struct {
+	SearchProviderHandler
+}
+
+func (h *searchProviderHandler) Search(ctx context.Context, in *SearchRequest, out *SearchResponse) error {
+	return h.SearchProviderHandler.Search(ctx, in, out)
+}
+
+func (h *searchProviderHandler) IndexSpace(ctx context.Context, in *IndexSpaceRequest, out *IndexSpaceResponse) error {
+	return h.SearchProviderHandler.IndexSpace(ctx, in, out)
+}
+
+// Api Endpoints for IndexProvider service
+
+func NewIndexProviderEndpoints() []*api.Endpoint {
+	return []*api.Endpoint{
+		{
+			Name:    "IndexProvider.Search",
+			Path:    []string{"/api/v0/search/index/search"},
+			Method:  []string{"POST"},
+			Body:    "*",
+			Handler: "rpc",
+		},
+	}
+}
+
+// Client API for IndexProvider service
+
+type IndexProviderService interface {
+	Search(ctx context.Context, in *SearchIndexRequest, opts ...client.CallOption) (*SearchIndexResponse, error)
+}
+
+type indexProviderService struct {
+	c    client.Client
+	name string
+}
+
+func NewIndexProviderService(name string, c client.Client) IndexProviderService {
+	return &indexProviderService{
+		c:    c,
+		name: name,
+	}
+}
+
+func (c *indexProviderService) Search(ctx context.Context, in *SearchIndexRequest, opts ...client.CallOption) (*SearchIndexResponse, error) {
+	req := c.c.NewRequest(c.name, "IndexProvider.Search", in)
+	out := new(SearchIndexResponse)
+	err := c.c.Call(ctx, req, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+// Server API for IndexProvider service
+
+type IndexProviderHandler interface {
+	Search(context.Context, *SearchIndexRequest, *SearchIndexResponse) error
+}
+
+func RegisterIndexProviderHandler(s server.Server, hdlr IndexProviderHandler, opts ...server.HandlerOption) error {
+	type indexProvider interface {
+		Search(ctx context.Context, in *SearchIndexRequest, out *SearchIndexResponse) error
+	}
+	type IndexProvider struct {
+		indexProvider
+	}
+	h := &indexProviderHandler{hdlr}
+	opts = append(opts, api.WithEndpoint(&api.Endpoint{
+		Name:    "IndexProvider.Search",
+		Path:    []string{"/api/v0/search/index/search"},
+		Method:  []string{"POST"},
+		Body:    "*",
+		Handler: "rpc",
+	}))
+	return s.Handle(s.NewHandler(&IndexProvider{h}, opts...))
+}
+
+type indexProviderHandler struct {
+	IndexProviderHandler
+}
+
+func (h *indexProviderHandler) Search(ctx context.Context, in *SearchIndexRequest, out *SearchIndexResponse) error {
+	return h.IndexProviderHandler.Search(ctx, in, out)
+}
diff --git a/protogen/gen/ocis/services/search/v0/search.pb.web.go b/protogen/gen/ocis/services/search/v0/search.pb.web.go
new file mode 100644
index 00000000000..427033152b2
--- /dev/null
+++ b/protogen/gen/ocis/services/search/v0/search.pb.web.go
@@ -0,0 +1,333 @@
+// Code generated by protoc-gen-microweb. DO NOT EDIT.
+// source: v0.proto
+
+package v0
+
+import (
+	"bytes"
+	"encoding/json"
+	"net/http"
+
+	"github.com/go-chi/chi/v5"
+	"github.com/go-chi/render"
+	"github.com/golang/protobuf/jsonpb"
+)
+
+type webSearchProviderHandler struct {
+	r chi.Router
+	h SearchProviderHandler
+}
+
+func (h *webSearchProviderHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	h.r.ServeHTTP(w, r)
+}
+
+func (h *webSearchProviderHandler) Search(w http.ResponseWriter, r *http.Request) {
+	req := &SearchRequest{}
+	resp := &SearchResponse{}
+
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, err.Error(), http.StatusPreconditionFailed)
+		return
+	}
+
+	if err := h.h.Search(
+		r.Context(),
+		req,
+		resp,
+	); err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+
+	render.Status(r, http.StatusCreated)
+	render.JSON(w, r, resp)
+}
+
+func (h *webSearchProviderHandler) IndexSpace(w http.ResponseWriter, r *http.Request) {
+	req := &IndexSpaceRequest{}
+	resp := &IndexSpaceResponse{}
+
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, err.Error(), http.StatusPreconditionFailed)
+		return
+	}
+
+	if err := h.h.IndexSpace(
+		r.Context(),
+		req,
+		resp,
+	); err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+
+	render.Status(r, http.StatusCreated)
+	render.JSON(w, r, resp)
+}
+
+func RegisterSearchProviderWeb(r chi.Router, i SearchProviderHandler, middlewares ...func(http.Handler) http.Handler) {
+	handler := &webSearchProviderHandler{
+		r: r,
+		h: i,
+	}
+
+	r.MethodFunc("POST", "/api/v0/search/search", handler.Search)
+	r.MethodFunc("POST", "/api/v0/search/index-space", handler.IndexSpace)
+}
+
+type webIndexProviderHandler struct {
+	r chi.Router
+	h IndexProviderHandler
+}
+
+func (h *webIndexProviderHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	h.r.ServeHTTP(w, r)
+}
+
+func (h *webIndexProviderHandler) Search(w http.ResponseWriter, r *http.Request) {
+	req := &SearchIndexRequest{}
+	resp := &SearchIndexResponse{}
+
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, err.Error(), http.StatusPreconditionFailed)
+		return
+	}
+
+	if err := h.h.Search(
+		r.Context(),
+		req,
+		resp,
+	); err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+
+	render.Status(r, http.StatusCreated)
+	render.JSON(w, r, resp)
+}
+
+func RegisterIndexProviderWeb(r chi.Router, i IndexProviderHandler, middlewares ...func(http.Handler) http.Handler) {
+	handler := &webIndexProviderHandler{
+		r: r,
+		h: i,
+	}
+
+	r.MethodFunc("POST", "/api/v0/search/index/search", handler.Search)
+}
+
+// SearchRequestJSONMarshaler describes the default jsonpb.Marshaler used by all
+// instances of SearchRequest. This struct is safe to replace or modify but
+// should not be done so concurrently.
+var SearchRequestJSONMarshaler = new(jsonpb.Marshaler)
+
+// MarshalJSON satisfies the encoding/json Marshaler interface. This method
+// uses the more correct jsonpb package to correctly marshal the message.
+func (m *SearchRequest) MarshalJSON() ([]byte, error) {
+	if m == nil {
+		return json.Marshal(nil)
+	}
+
+	buf := &bytes.Buffer{}
+
+	if err := SearchRequestJSONMarshaler.Marshal(buf, m); err != nil {
+		return nil, err
+	}
+
+	return buf.Bytes(), nil
+}
+
+var _ json.Marshaler = (*SearchRequest)(nil)
+
+// SearchRequestJSONUnmarshaler describes the default jsonpb.Unmarshaler used by all
+// instances of SearchRequest. This struct is safe to replace or modify but
+// should not be done so concurrently.
+var SearchRequestJSONUnmarshaler = new(jsonpb.Unmarshaler)
+
+// UnmarshalJSON satisfies the encoding/json Unmarshaler interface. This method
+// uses the more correct jsonpb package to correctly unmarshal the message.
+func (m *SearchRequest) UnmarshalJSON(b []byte) error {
+	return SearchRequestJSONUnmarshaler.Unmarshal(bytes.NewReader(b), m)
+}
+
+var _ json.Unmarshaler = (*SearchRequest)(nil)
+
+// SearchResponseJSONMarshaler describes the default jsonpb.Marshaler used by all
+// instances of SearchResponse. This struct is safe to replace or modify but
+// should not be done so concurrently.
+var SearchResponseJSONMarshaler = new(jsonpb.Marshaler)
+
+// MarshalJSON satisfies the encoding/json Marshaler interface. This method
+// uses the more correct jsonpb package to correctly marshal the message.
+func (m *SearchResponse) MarshalJSON() ([]byte, error) {
+	if m == nil {
+		return json.Marshal(nil)
+	}
+
+	buf := &bytes.Buffer{}
+
+	if err := SearchResponseJSONMarshaler.Marshal(buf, m); err != nil {
+		return nil, err
+	}
+
+	return buf.Bytes(), nil
+}
+
+var _ json.Marshaler = (*SearchResponse)(nil)
+
+// SearchResponseJSONUnmarshaler describes the default jsonpb.Unmarshaler used by all
+// instances of SearchResponse. This struct is safe to replace or modify but
+// should not be done so concurrently.
+var SearchResponseJSONUnmarshaler = new(jsonpb.Unmarshaler)
+
+// UnmarshalJSON satisfies the encoding/json Unmarshaler interface. This method
+// uses the more correct jsonpb package to correctly unmarshal the message.
+func (m *SearchResponse) UnmarshalJSON(b []byte) error {
+	return SearchResponseJSONUnmarshaler.Unmarshal(bytes.NewReader(b), m)
+}
+
+var _ json.Unmarshaler = (*SearchResponse)(nil)
+
+// SearchIndexRequestJSONMarshaler describes the default jsonpb.Marshaler used by all
+// instances of SearchIndexRequest. This struct is safe to replace or modify but
+// should not be done so concurrently.
+var SearchIndexRequestJSONMarshaler = new(jsonpb.Marshaler)
+
+// MarshalJSON satisfies the encoding/json Marshaler interface. This method
+// uses the more correct jsonpb package to correctly marshal the message.
+func (m *SearchIndexRequest) MarshalJSON() ([]byte, error) {
+	if m == nil {
+		return json.Marshal(nil)
+	}
+
+	buf := &bytes.Buffer{}
+
+	if err := SearchIndexRequestJSONMarshaler.Marshal(buf, m); err != nil {
+		return nil, err
+	}
+
+	return buf.Bytes(), nil
+}
+
+var _ json.Marshaler = (*SearchIndexRequest)(nil)
+
+// SearchIndexRequestJSONUnmarshaler describes the default jsonpb.Unmarshaler used by all
+// instances of SearchIndexRequest. This struct is safe to replace or modify but
+// should not be done so concurrently.
+var SearchIndexRequestJSONUnmarshaler = new(jsonpb.Unmarshaler)
+
+// UnmarshalJSON satisfies the encoding/json Unmarshaler interface. This method
+// uses the more correct jsonpb package to correctly unmarshal the message.
+func (m *SearchIndexRequest) UnmarshalJSON(b []byte) error {
+	return SearchIndexRequestJSONUnmarshaler.Unmarshal(bytes.NewReader(b), m)
+}
+
+var _ json.Unmarshaler = (*SearchIndexRequest)(nil)
+
+// SearchIndexResponseJSONMarshaler describes the default jsonpb.Marshaler used by all
+// instances of SearchIndexResponse. This struct is safe to replace or modify but
+// should not be done so concurrently.
+var SearchIndexResponseJSONMarshaler = new(jsonpb.Marshaler)
+
+// MarshalJSON satisfies the encoding/json Marshaler interface. This method
+// uses the more correct jsonpb package to correctly marshal the message.
+func (m *SearchIndexResponse) MarshalJSON() ([]byte, error) {
+	if m == nil {
+		return json.Marshal(nil)
+	}
+
+	buf := &bytes.Buffer{}
+
+	if err := SearchIndexResponseJSONMarshaler.Marshal(buf, m); err != nil {
+		return nil, err
+	}
+
+	return buf.Bytes(), nil
+}
+
+var _ json.Marshaler = (*SearchIndexResponse)(nil)
+
+// SearchIndexResponseJSONUnmarshaler describes the default jsonpb.Unmarshaler used by all
+// instances of SearchIndexResponse. This struct is safe to replace or modify but
+// should not be done so concurrently.
+var SearchIndexResponseJSONUnmarshaler = new(jsonpb.Unmarshaler)
+
+// UnmarshalJSON satisfies the encoding/json Unmarshaler interface. This method
+// uses the more correct jsonpb package to correctly unmarshal the message.
+func (m *SearchIndexResponse) UnmarshalJSON(b []byte) error {
+	return SearchIndexResponseJSONUnmarshaler.Unmarshal(bytes.NewReader(b), m)
+}
+
+var _ json.Unmarshaler = (*SearchIndexResponse)(nil)
+
+// IndexSpaceRequestJSONMarshaler describes the default jsonpb.Marshaler used by all
+// instances of IndexSpaceRequest. This struct is safe to replace or modify but
+// should not be done so concurrently.
+var IndexSpaceRequestJSONMarshaler = new(jsonpb.Marshaler)
+
+// MarshalJSON satisfies the encoding/json Marshaler interface. This method
+// uses the more correct jsonpb package to correctly marshal the message.
+func (m *IndexSpaceRequest) MarshalJSON() ([]byte, error) {
+	if m == nil {
+		return json.Marshal(nil)
+	}
+
+	buf := &bytes.Buffer{}
+
+	if err := IndexSpaceRequestJSONMarshaler.Marshal(buf, m); err != nil {
+		return nil, err
+	}
+
+	return buf.Bytes(), nil
+}
+
+var _ json.Marshaler = (*IndexSpaceRequest)(nil)
+
+// IndexSpaceRequestJSONUnmarshaler describes the default jsonpb.Unmarshaler used by all
+// instances of IndexSpaceRequest. This struct is safe to replace or modify but
+// should not be done so concurrently.
+var IndexSpaceRequestJSONUnmarshaler = new(jsonpb.Unmarshaler)
+
+// UnmarshalJSON satisfies the encoding/json Unmarshaler interface. This method
+// uses the more correct jsonpb package to correctly unmarshal the message.
+func (m *IndexSpaceRequest) UnmarshalJSON(b []byte) error {
+	return IndexSpaceRequestJSONUnmarshaler.Unmarshal(bytes.NewReader(b), m)
+}
+
+var _ json.Unmarshaler = (*IndexSpaceRequest)(nil)
+
+// IndexSpaceResponseJSONMarshaler describes the default jsonpb.Marshaler used by all
+// instances of IndexSpaceResponse. This struct is safe to replace or modify but
+// should not be done so concurrently.
+var IndexSpaceResponseJSONMarshaler = new(jsonpb.Marshaler)
+
+// MarshalJSON satisfies the encoding/json Marshaler interface. This method
+// uses the more correct jsonpb package to correctly marshal the message.
+func (m *IndexSpaceResponse) MarshalJSON() ([]byte, error) {
+	if m == nil {
+		return json.Marshal(nil)
+	}
+
+	buf := &bytes.Buffer{}
+
+	if err := IndexSpaceResponseJSONMarshaler.Marshal(buf, m); err != nil {
+		return nil, err
+	}
+
+	return buf.Bytes(), nil
+}
+
+var _ json.Marshaler = (*IndexSpaceResponse)(nil)
+
+// IndexSpaceResponseJSONUnmarshaler describes the default jsonpb.Unmarshaler used by all
+// instances of IndexSpaceResponse. This struct is safe to replace or modify but
+// should not be done so concurrently.
+var IndexSpaceResponseJSONUnmarshaler = new(jsonpb.Unmarshaler)
+
+// UnmarshalJSON satisfies the encoding/json Unmarshaler interface. This method
+// uses the more correct jsonpb package to correctly unmarshal the message.
+func (m *IndexSpaceResponse) UnmarshalJSON(b []byte) error {
+	return IndexSpaceResponseJSONUnmarshaler.Unmarshal(bytes.NewReader(b), m)
+}
+
+var _ json.Unmarshaler = (*IndexSpaceResponse)(nil)
diff --git a/protogen/gen/ocis/services/search/v0/search.swagger.json b/protogen/gen/ocis/services/search/v0/search.swagger.json
new file mode 100644
index 00000000000..55b025f1216
--- /dev/null
+++ b/protogen/gen/ocis/services/search/v0/search.swagger.json
@@ -0,0 +1,320 @@
+{
+  "swagger": "2.0",
+  "info": {
+    "title": "ownCloud Infinite Scale search",
+    "version": "1.0.0",
+    "contact": {
+      "name": "ownCloud GmbH",
+      "url": "https://github.com/owncloud/ocis",
+      "email": "support@owncloud.com"
+    },
+    "license": {
+      "name": "Apache-2.0",
+      "url": "https://github.com/owncloud/ocis/blob/master/LICENSE"
+    }
+  },
+  "tags": [
+    {
+      "name": "SearchProvider"
+    },
+    {
+      "name": "IndexProvider"
+    }
+  ],
+  "schemes": [
+    "http",
+    "https"
+  ],
+  "consumes": [
+    "application/json"
+  ],
+  "produces": [
+    "application/json"
+  ],
+  "paths": {
+    "/api/v0/search/index-space": {
+      "post": {
+        "operationId": "SearchProvider_IndexSpace",
+        "responses": {
+          "200": {
+            "description": "A successful response.",
+            "schema": {
+              "$ref": "#/definitions/v0IndexSpaceResponse"
+            }
+          },
+          "default": {
+            "description": "An unexpected error response.",
+            "schema": {
+              "$ref": "#/definitions/rpcStatus"
+            }
+          }
+        },
+        "parameters": [
+          {
+            "name": "body",
+            "in": "body",
+            "required": true,
+            "schema": {
+              "$ref": "#/definitions/v0IndexSpaceRequest"
+            }
+          }
+        ],
+        "tags": [
+          "SearchProvider"
+        ]
+      }
+    },
+    "/api/v0/search/index/search": {
+      "post": {
+        "operationId": "IndexProvider_Search",
+        "responses": {
+          "200": {
+            "description": "A successful response.",
+            "schema": {
+              "$ref": "#/definitions/v0SearchIndexResponse"
+            }
+          },
+          "default": {
+            "description": "An unexpected error response.",
+            "schema": {
+              "$ref": "#/definitions/rpcStatus"
+            }
+          }
+        },
+        "parameters": [
+          {
+            "name": "body",
+            "in": "body",
+            "required": true,
+            "schema": {
+              "$ref": "#/definitions/v0SearchIndexRequest"
+            }
+          }
+        ],
+        "tags": [
+          "IndexProvider"
+        ]
+      }
+    },
+    "/api/v0/search/search": {
+      "post": {
+        "operationId": "SearchProvider_Search",
+        "responses": {
+          "200": {
+            "description": "A successful response.",
+            "schema": {
+              "$ref": "#/definitions/v0SearchResponse"
+            }
+          },
+          "default": {
+            "description": "An unexpected error response.",
+            "schema": {
+              "$ref": "#/definitions/rpcStatus"
+            }
+          }
+        },
+        "parameters": [
+          {
+            "name": "body",
+            "in": "body",
+            "required": true,
+            "schema": {
+              "$ref": "#/definitions/v0SearchRequest"
+            }
+          }
+        ],
+        "tags": [
+          "SearchProvider"
+        ]
+      }
+    }
+  },
+  "definitions": {
+    "protobufAny": {
+      "type": "object",
+      "properties": {
+        "@type": {
+          "type": "string"
+        }
+      },
+      "additionalProperties": {}
+    },
+    "rpcStatus": {
+      "type": "object",
+      "properties": {
+        "code": {
+          "type": "integer",
+          "format": "int32"
+        },
+        "message": {
+          "type": "string"
+        },
+        "details": {
+          "type": "array",
+          "items": {
+            "$ref": "#/definitions/protobufAny"
+          }
+        }
+      }
+    },
+    "v0Entity": {
+      "type": "object",
+      "properties": {
+        "ref": {
+          "$ref": "#/definitions/v0Reference"
+        },
+        "id": {
+          "$ref": "#/definitions/v0ResourceID"
+        },
+        "name": {
+          "type": "string"
+        },
+        "etag": {
+          "type": "string"
+        },
+        "size": {
+          "type": "string",
+          "format": "uint64"
+        },
+        "lastModifiedTime": {
+          "type": "string",
+          "format": "date-time"
+        },
+        "mimeType": {
+          "type": "string"
+        },
+        "permissions": {
+          "type": "string"
+        },
+        "type": {
+          "type": "string",
+          "format": "uint64"
+        },
+        "deleted": {
+          "type": "boolean"
+        }
+      }
+    },
+    "v0IndexSpaceRequest": {
+      "type": "object",
+      "properties": {
+        "spaceId": {
+          "type": "string"
+        },
+        "userId": {
+          "type": "string"
+        }
+      }
+    },
+    "v0IndexSpaceResponse": {
+      "type": "object"
+    },
+    "v0Match": {
+      "type": "object",
+      "properties": {
+        "entity": {
+          "$ref": "#/definitions/v0Entity",
+          "title": "the matched entity"
+        },
+        "score": {
+          "type": "number",
+          "format": "float",
+          "title": "the match score"
+        }
+      }
+    },
+    "v0Reference": {
+      "type": "object",
+      "properties": {
+        "resourceId": {
+          "$ref": "#/definitions/v0ResourceID"
+        },
+        "path": {
+          "type": "string"
+        }
+      }
+    },
+    "v0ResourceID": {
+      "type": "object",
+      "properties": {
+        "storageId": {
+          "type": "string"
+        },
+        "opaqueId": {
+          "type": "string"
+        }
+      }
+    },
+    "v0SearchIndexRequest": {
+      "type": "object",
+      "properties": {
+        "pageSize": {
+          "type": "integer",
+          "format": "int32",
+          "title": "Optional. The maximum number of entries to return in the response"
+        },
+        "pageToken": {
+          "type": "string",
+          "title": "Optional. A pagination token returned from a previous call to `Get`\nthat indicates from where search should continue"
+        },
+        "query": {
+          "type": "string"
+        },
+        "ref": {
+          "$ref": "#/definitions/v0Reference"
+        }
+      }
+    },
+    "v0SearchIndexResponse": {
+      "type": "object",
+      "properties": {
+        "matches": {
+          "type": "array",
+          "items": {
+            "$ref": "#/definitions/v0Match"
+          }
+        },
+        "nextPageToken": {
+          "type": "string",
+          "title": "Token to retrieve the next page of results, or empty if there are no\nmore results in the list"
+        }
+      }
+    },
+    "v0SearchRequest": {
+      "type": "object",
+      "properties": {
+        "pageSize": {
+          "type": "integer",
+          "format": "int32",
+          "title": "Optional. The maximum number of entries to return in the response"
+        },
+        "pageToken": {
+          "type": "string",
+          "title": "Optional. A pagination token returned from a previous call to `Get`\nthat indicates from where search should continue"
+        },
+        "query": {
+          "type": "string"
+        }
+      }
+    },
+    "v0SearchResponse": {
+      "type": "object",
+      "properties": {
+        "matches": {
+          "type": "array",
+          "items": {
+            "$ref": "#/definitions/v0Match"
+          }
+        },
+        "nextPageToken": {
+          "type": "string",
+          "title": "Token to retrieve the next page of results, or empty if there are no\nmore results in the list"
+        }
+      }
+    }
+  },
+  "externalDocs": {
+    "description": "Developer Manual",
+    "url": "https://owncloud.dev/extensions/search/"
+  }
+}
diff --git a/protogen/gen/ocis/services/settings/v0/settings.pb.go b/protogen/gen/ocis/services/settings/v0/settings.pb.go
index 5eeeb956652..52afe123ca1 100644
--- a/protogen/gen/ocis/services/settings/v0/settings.pb.go
+++ b/protogen/gen/ocis/services/settings/v0/settings.pb.go
@@ -1,7 +1,7 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.27.1
-// 	protoc        v3.17.3
+// 	protoc-gen-go v1.28.0
+// 	protoc        (unknown)
 // source: ocis/services/settings/v0/settings.proto
 
 package v0
diff --git a/protogen/gen/ocis/services/store/v0/store.pb.go b/protogen/gen/ocis/services/store/v0/store.pb.go
index 5dd2fc43648..7d5b055470d 100644
--- a/protogen/gen/ocis/services/store/v0/store.pb.go
+++ b/protogen/gen/ocis/services/store/v0/store.pb.go
@@ -1,7 +1,7 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.27.1
-// 	protoc        v3.17.3
+// 	protoc-gen-go v1.28.0
+// 	protoc        (unknown)
 // source: ocis/services/store/v0/store.proto
 
 package v0
diff --git a/protogen/gen/ocis/services/thumbnails/v0/thumbnails.pb.go b/protogen/gen/ocis/services/thumbnails/v0/thumbnails.pb.go
index cf949fb4563..03daa47a76b 100644
--- a/protogen/gen/ocis/services/thumbnails/v0/thumbnails.pb.go
+++ b/protogen/gen/ocis/services/thumbnails/v0/thumbnails.pb.go
@@ -1,7 +1,7 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.27.1
-// 	protoc        v3.17.3
+// 	protoc-gen-go v1.28.0
+// 	protoc        (unknown)
 // source: ocis/services/thumbnails/v0/thumbnails.proto
 
 package v0
diff --git a/protogen/proto/ocis/messages/search/v0/search.proto b/protogen/proto/ocis/messages/search/v0/search.proto
new file mode 100644
index 00000000000..b3729e47e5d
--- /dev/null
+++ b/protogen/proto/ocis/messages/search/v0/search.proto
@@ -0,0 +1,37 @@
+syntax = "proto3";
+
+package ocis.messages.search.v0;
+
+import "google/protobuf/timestamp.proto";
+
+option go_package = "github.com/owncloud/ocis/protogen/gen/ocis/messages/search/v0";
+
+message ResourceID {
+	string storage_id = 1;
+	string opaque_id = 2;
+}
+
+message Reference {
+	ResourceID resource_id = 1;
+	string path = 2;
+}
+
+message Entity {
+	Reference ref = 1;
+	ResourceID id = 2;
+	string name = 3;
+	string etag = 4;
+	uint64 size = 5;
+    google.protobuf.Timestamp last_modified_time = 6;
+	string mime_type = 7;
+	string permissions = 8;
+	uint64 type = 9;
+	bool deleted = 10;
+}
+
+message Match {
+	// the matched entity
+	Entity entity = 1;
+	// the match score
+	float score = 2;
+}
\ No newline at end of file
diff --git a/protogen/proto/ocis/services/search/v0/search.proto b/protogen/proto/ocis/services/search/v0/search.proto
new file mode 100644
index 00000000000..7d78dae35a4
--- /dev/null
+++ b/protogen/proto/ocis/services/search/v0/search.proto
@@ -0,0 +1,107 @@
+syntax = "proto3";
+
+package ocis.services.search.v0;
+	
+option go_package = "github.com/owncloud/ocis/protogen/gen/ocis/service/search/v0";
+
+import "ocis/messages/search/v0/search.proto";
+import "protoc-gen-openapiv2/options/annotations.proto";
+import "google/api/field_behavior.proto";
+import "google/api/annotations.proto";
+import "google/protobuf/field_mask.proto";
+
+option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = {
+  info: {
+    title: "ownCloud Infinite Scale search";
+    version: "1.0.0";
+    contact: {
+      name: "ownCloud GmbH";
+      url: "https://github.com/owncloud/ocis";
+      email: "support@owncloud.com";
+    };
+    license: {
+      name: "Apache-2.0";
+      url: "https://github.com/owncloud/ocis/blob/master/LICENSE";
+    };
+  };
+  schemes: HTTP;
+  schemes: HTTPS;
+  consumes: "application/json";
+  produces: "application/json";
+  external_docs: {
+    description: "Developer Manual";
+    url: "https://owncloud.dev/extensions/search/";
+  };
+};
+
+service SearchProvider {
+	rpc Search(SearchRequest) returns (SearchResponse) {
+    option (google.api.http) = {
+        post: "/api/v0/search/search",
+        body: "*"
+    };
+  };
+  rpc IndexSpace(IndexSpaceRequest) returns (IndexSpaceResponse) {
+    option (google.api.http) = {
+        post: "/api/v0/search/index-space",
+        body: "*"
+    };
+  }
+}
+
+service IndexProvider {
+	rpc Search(SearchIndexRequest) returns (SearchIndexResponse) {
+    option (google.api.http) = {
+        post: "/api/v0/search/index/search",
+        body: "*"
+    };
+  };
+	// rpc Remove(RemoveRequest) returns (RemoveResponse) {};
+}
+
+message SearchRequest {
+  // Optional. The maximum number of entries to return in the response
+  int32 page_size = 1 [(google.api.field_behavior) = OPTIONAL];
+
+  // Optional. A pagination token returned from a previous call to `Get`
+  // that indicates from where search should continue
+  string page_token = 2 [(google.api.field_behavior) = OPTIONAL];
+
+  string query = 3;
+}
+
+message SearchResponse {
+  repeated ocis.messages.search.v0.Match matches = 1;
+
+  // Token to retrieve the next page of results, or empty if there are no
+  // more results in the list
+  string next_page_token = 2;
+}
+
+message SearchIndexRequest {
+  // Optional. The maximum number of entries to return in the response
+  int32 page_size = 1 [(google.api.field_behavior) = OPTIONAL];
+
+  // Optional. A pagination token returned from a previous call to `Get`
+  // that indicates from where search should continue
+  string page_token = 2 [(google.api.field_behavior) = OPTIONAL];
+
+	string query = 3;
+  ocis.messages.search.v0.Reference ref = 4 [(google.api.field_behavior) = OPTIONAL];
+}
+
+message SearchIndexResponse {
+    repeated ocis.messages.search.v0.Match matches = 1;
+
+    // Token to retrieve the next page of results, or empty if there are no
+    // more results in the list
+    string next_page_token = 2;
+}
+
+message IndexSpaceRequest {
+  string space_id = 1;
+  string user_id = 2;
+}
+
+message IndexSpaceResponse {
+}
\ No newline at end of file


Test patch:
diff --git a/extensions/proxy/pkg/proxy/proxy_test.go b/extensions/proxy/pkg/proxy/proxy_test.go
index a618ffc1b50..2805ada186e 100644
--- a/extensions/proxy/pkg/proxy/proxy_test.go
+++ b/extensions/proxy/pkg/proxy/proxy_test.go
@@ -1,15 +1,19 @@
 package proxy
 
 import (
+	"fmt"
+	"net/http"
+	"net/http/httptest"
 	"net/url"
 	"testing"
 
+	"github.com/owncloud/ocis/extensions/proxy/pkg/config"
 	"github.com/owncloud/ocis/extensions/proxy/pkg/config/defaults"
 )
 
 type matchertest struct {
-	endpoint, target string
-	matches          bool
+	method, endpoint, target string
+	matches                  bool
 }
 
 func TestPrefixRouteMatcher(t *testing.T) {
@@ -99,3 +103,35 @@ func TestSingleJoiningSlash(t *testing.T) {
 		}
 	}
 }
+
+func TestDirectorSelectionDirector(t *testing.T) {
+
+	svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		fmt.Fprintf(w, "ok")
+	}))
+	defer svr.Close()
+
+	p := NewMultiHostReverseProxy(Config(&config.Config{
+		PolicySelector: &config.PolicySelector{
+			Static: &config.StaticSelectorConf{
+				Policy: "default",
+			},
+		},
+	}))
+	p.AddHost("default", &url.URL{Host: "ocdav"}, config.Route{Type: config.PrefixRoute, Method: "", Endpoint: "/dav", Backend: "ocdav"})
+	p.AddHost("default", &url.URL{Host: "ocis-webdav"}, config.Route{Type: config.PrefixRoute, Method: "REPORT", Endpoint: "/dav", Backend: "ocis-webdav"})
+
+	table := []matchertest{
+		{method: "PROPFIND", endpoint: "/dav/files/demo/", target: "ocdav"},
+		{method: "REPORT", endpoint: "/dav/files/demo/", target: "ocis-webdav"},
+	}
+
+	for _, test := range table {
+		r := httptest.NewRequest(test.method, "/dav/files/demo/", nil)
+		p.directorSelectionDirector(r)
+		if r.URL.Host != test.target {
+			t.Errorf("TestDirectorSelectionDirector got host %s expected %s", r.Host, test.target)
+
+		}
+	}
+}
diff --git a/extensions/search/pkg/search/index/index_suite_test.go b/extensions/search/pkg/search/index/index_suite_test.go
new file mode 100644
index 00000000000..09099db1a4c
--- /dev/null
+++ b/extensions/search/pkg/search/index/index_suite_test.go
@@ -0,0 +1,13 @@
+package index_test
+
+import (
+	"testing"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+)
+
+func TestIndex(t *testing.T) {
+	RegisterFailHandler(Fail)
+	RunSpecs(t, "Index Suite")
+}
diff --git a/extensions/search/pkg/search/index/index_test.go b/extensions/search/pkg/search/index/index_test.go
new file mode 100644
index 00000000000..8624e55a81a
--- /dev/null
+++ b/extensions/search/pkg/search/index/index_test.go
@@ -0,0 +1,371 @@
+package index_test
+
+import (
+	"context"
+
+	"github.com/blevesearch/bleve/v2"
+	sprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
+	typesv1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
+	"github.com/owncloud/ocis/extensions/search/pkg/search/index"
+	searchmsg "github.com/owncloud/ocis/protogen/gen/ocis/messages/search/v0"
+	searchsvc "github.com/owncloud/ocis/protogen/gen/ocis/services/search/v0"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+)
+
+var _ = Describe("Index", func() {
+	var (
+		i          *index.Index
+		bleveIndex bleve.Index
+		ctx        context.Context
+
+		rootId = &sprovider.ResourceId{
+			StorageId: "storageid",
+			OpaqueId:  "rootopaqueid",
+		}
+		ref = &sprovider.Reference{
+			ResourceId: rootId,
+			Path:       "./foo.pdf",
+		}
+		ri = &sprovider.ResourceInfo{
+			Id: &sprovider.ResourceId{
+				StorageId: "storageid",
+				OpaqueId:  "opaqueid",
+			},
+			ParentId: &sprovider.ResourceId{
+				StorageId: "storageid",
+				OpaqueId:  "someopaqueid",
+			},
+			Path:     "foo.pdf",
+			Size:     12345,
+			Type:     sprovider.ResourceType_RESOURCE_TYPE_FILE,
+			MimeType: "application/pdf",
+			Mtime:    &typesv1beta1.Timestamp{Seconds: 4000},
+		}
+		parentRef = &sprovider.Reference{
+			ResourceId: rootId,
+			Path:       "./sudbir",
+		}
+		parentRi = &sprovider.ResourceInfo{
+			Id: &sprovider.ResourceId{
+				StorageId: "storageid",
+				OpaqueId:  "parentopaqueid",
+			},
+			Path:  "subdir",
+			Size:  12345,
+			Type:  sprovider.ResourceType_RESOURCE_TYPE_CONTAINER,
+			Mtime: &typesv1beta1.Timestamp{Seconds: 4000},
+		}
+		childRef = &sprovider.Reference{
+			ResourceId: rootId,
+			Path:       "./sudbir/child.pdf",
+		}
+		childRi = &sprovider.ResourceInfo{
+			Id: &sprovider.ResourceId{
+				StorageId: "storageid",
+				OpaqueId:  "childopaqueid",
+			},
+			ParentId: &sprovider.ResourceId{
+				StorageId: "storageid",
+				OpaqueId:  "parentopaqueid",
+			},
+			Path:  "child.pdf",
+			Size:  12345,
+			Type:  sprovider.ResourceType_RESOURCE_TYPE_FILE,
+			Mtime: &typesv1beta1.Timestamp{Seconds: 4000},
+		}
+
+		assertDocCount = func(rootId *sprovider.ResourceId, query string, expectedCount int) {
+			res, err := i.Search(ctx, &searchsvc.SearchIndexRequest{
+				Query: query,
+				Ref: &searchmsg.Reference{
+					ResourceId: &searchmsg.ResourceID{
+						StorageId: rootId.StorageId,
+						OpaqueId:  rootId.OpaqueId,
+					},
+				},
+			})
+			ExpectWithOffset(1, err).ToNot(HaveOccurred())
+			ExpectWithOffset(1, len(res.Matches)).To(Equal(expectedCount))
+		}
+	)
+
+	BeforeEach(func() {
+		var err error
+		bleveIndex, err = bleve.NewMemOnly(index.BuildMapping())
+		Expect(err).ToNot(HaveOccurred())
+
+		i, err = index.New(bleveIndex)
+		Expect(err).ToNot(HaveOccurred())
+	})
+
+	Describe("New", func() {
+		It("returns a new index instance", func() {
+			i, err := index.New(bleveIndex)
+			Expect(err).ToNot(HaveOccurred())
+			Expect(i).ToNot(BeNil())
+		})
+	})
+
+	Describe("NewPersisted", func() {
+		It("returns a new index instance", func() {
+			i, err := index.NewPersisted("")
+			Expect(err).ToNot(HaveOccurred())
+			Expect(i).ToNot(BeNil())
+		})
+	})
+
+	Describe("Search", func() {
+		Context("with a file in the root of the space", func() {
+			BeforeEach(func() {
+				err := i.Add(ref, ri)
+				Expect(err).ToNot(HaveOccurred())
+			})
+
+			It("scopes the search to the specified space", func() {
+				res, err := i.Search(ctx, &searchsvc.SearchIndexRequest{
+					Ref: &searchmsg.Reference{
+						ResourceId: &searchmsg.ResourceID{
+							StorageId: "differentstorageid",
+							OpaqueId:  "differentopaqueid",
+						},
+					},
+					Query: "foo.pdf",
+				})
+				Expect(err).ToNot(HaveOccurred())
+				Expect(res).ToNot(BeNil())
+				Expect(len(res.Matches)).To(Equal(0))
+			})
+
+			It("limits the search to the relevant fields", func() {
+				res, err := i.Search(ctx, &searchsvc.SearchIndexRequest{
+					Ref: &searchmsg.Reference{
+						ResourceId: &searchmsg.ResourceID{
+							StorageId: ref.ResourceId.StorageId,
+							OpaqueId:  ref.ResourceId.OpaqueId,
+						},
+					},
+					Query: "*" + ref.ResourceId.OpaqueId + "*",
+				})
+				Expect(err).ToNot(HaveOccurred())
+				Expect(res).ToNot(BeNil())
+				Expect(len(res.Matches)).To(Equal(0))
+			})
+
+			It("returns all desired fields", func() {
+				res, err := i.Search(ctx, &searchsvc.SearchIndexRequest{
+					Ref: &searchmsg.Reference{
+						ResourceId: &searchmsg.ResourceID{
+							StorageId: ref.ResourceId.StorageId,
+							OpaqueId:  ref.ResourceId.OpaqueId,
+						},
+					},
+					Query: "foo.pdf",
+				})
+				Expect(err).ToNot(HaveOccurred())
+				Expect(res).ToNot(BeNil())
+				Expect(len(res.Matches)).To(Equal(1))
+				match := res.Matches[0]
+				Expect(match.Entity.Ref.ResourceId.OpaqueId).To(Equal(ref.ResourceId.OpaqueId))
+				Expect(match.Entity.Ref.Path).To(Equal(ref.Path))
+				Expect(match.Entity.Id.OpaqueId).To(Equal(ri.Id.OpaqueId))
+				Expect(match.Entity.Name).To(Equal(ri.Path))
+				Expect(match.Entity.Size).To(Equal(ri.Size))
+				Expect(match.Entity.Type).To(Equal(uint64(ri.Type)))
+				Expect(match.Entity.MimeType).To(Equal(ri.MimeType))
+				Expect(match.Entity.Deleted).To(BeFalse())
+				Expect(uint64(match.Entity.LastModifiedTime.AsTime().Unix())).To(Equal(ri.Mtime.Seconds))
+			})
+
+			It("finds files by name, prefix or substring match", func() {
+				queries := []string{"foo.pdf", "foo*", "*oo.p*"}
+				for _, query := range queries {
+					res, err := i.Search(ctx, &searchsvc.SearchIndexRequest{
+						Ref: &searchmsg.Reference{
+							ResourceId: &searchmsg.ResourceID{
+								StorageId: ref.ResourceId.StorageId,
+								OpaqueId:  ref.ResourceId.OpaqueId,
+							},
+						},
+						Query: query,
+					})
+					Expect(err).ToNot(HaveOccurred())
+					Expect(res).ToNot(BeNil())
+					Expect(len(res.Matches)).To(Equal(1), "query returned no result: "+query)
+					Expect(res.Matches[0].Entity.Ref.ResourceId.OpaqueId).To(Equal(ref.ResourceId.OpaqueId))
+					Expect(res.Matches[0].Entity.Ref.Path).To(Equal(ref.Path))
+					Expect(res.Matches[0].Entity.Id.OpaqueId).To(Equal(ri.Id.OpaqueId))
+					Expect(res.Matches[0].Entity.Name).To(Equal(ri.Path))
+					Expect(res.Matches[0].Entity.Size).To(Equal(ri.Size))
+				}
+			})
+
+			Context("and an additional file in a subdirectory", func() {
+				var (
+					nestedRef *sprovider.Reference
+					nestedRI  *sprovider.ResourceInfo
+				)
+
+				BeforeEach(func() {
+					nestedRef = &sprovider.Reference{
+						ResourceId: &sprovider.ResourceId{
+							StorageId: "storageid",
+							OpaqueId:  "rootopaqueid",
+						},
+						Path: "./nested/nestedpdf.pdf",
+					}
+					nestedRI = &sprovider.ResourceInfo{
+						Id: &sprovider.ResourceId{
+							StorageId: "storageid",
+							OpaqueId:  "nestedopaqueid",
+						},
+						Path: "nestedpdf.pdf",
+						Size: 12345,
+					}
+					err := i.Add(nestedRef, nestedRI)
+					Expect(err).ToNot(HaveOccurred())
+				})
+
+				It("finds files living deeper in the tree by filename, prefix or substring match", func() {
+					queries := []string{"nestedpdf.pdf", "nested*", "*tedpdf.*"}
+					for _, query := range queries {
+						res, err := i.Search(ctx, &searchsvc.SearchIndexRequest{
+							Ref: &searchmsg.Reference{
+								ResourceId: &searchmsg.ResourceID{
+									StorageId: ref.ResourceId.StorageId,
+									OpaqueId:  ref.ResourceId.OpaqueId,
+								},
+							},
+							Query: query,
+						})
+						Expect(err).ToNot(HaveOccurred())
+						Expect(res).ToNot(BeNil())
+						Expect(len(res.Matches)).To(Equal(1), "query returned no result: "+query)
+					}
+				})
+
+				It("does not find the higher levels when limiting the searched directory", func() {
+					res, err := i.Search(ctx, &searchsvc.SearchIndexRequest{
+						Ref: &searchmsg.Reference{
+							ResourceId: &searchmsg.ResourceID{
+								StorageId: ref.ResourceId.StorageId,
+								OpaqueId:  ref.ResourceId.OpaqueId,
+							},
+							Path: "./nested/",
+						},
+						Query: "foo.pdf",
+					})
+					Expect(err).ToNot(HaveOccurred())
+					Expect(res).ToNot(BeNil())
+					Expect(len(res.Matches)).To(Equal(0))
+				})
+			})
+		})
+	})
+
+	Describe("Add", func() {
+		It("adds a resourceInfo to the index", func() {
+			err := i.Add(ref, ri)
+			Expect(err).ToNot(HaveOccurred())
+
+			count, err := bleveIndex.DocCount()
+			Expect(err).ToNot(HaveOccurred())
+			Expect(count).To(Equal(uint64(1)))
+
+			query := bleve.NewMatchQuery("foo.pdf")
+			res, err := bleveIndex.Search(bleve.NewSearchRequest(query))
+			Expect(err).ToNot(HaveOccurred())
+			Expect(res.Hits.Len()).To(Equal(1))
+		})
+
+		It("updates an existing resource in the index", func() {
+			err := i.Add(ref, ri)
+			Expect(err).ToNot(HaveOccurred())
+			count, _ := bleveIndex.DocCount()
+			Expect(count).To(Equal(uint64(1)))
+
+			err = i.Add(ref, ri)
+			Expect(err).ToNot(HaveOccurred())
+			count, _ = bleveIndex.DocCount()
+			Expect(count).To(Equal(uint64(1)))
+		})
+	})
+
+	Describe("Delete", func() {
+		It("marks a resource as deleted", func() {
+			err := i.Add(parentRef, parentRi)
+			Expect(err).ToNot(HaveOccurred())
+			assertDocCount(rootId, "subdir", 1)
+
+			err = i.Delete(parentRi.Id)
+			Expect(err).ToNot(HaveOccurred())
+
+			assertDocCount(rootId, "subdir", 0)
+		})
+
+		It("also marks child resources as deleted", func() {
+			err := i.Add(parentRef, parentRi)
+			Expect(err).ToNot(HaveOccurred())
+			err = i.Add(childRef, childRi)
+			Expect(err).ToNot(HaveOccurred())
+
+			assertDocCount(rootId, "subdir", 1)
+			assertDocCount(rootId, "child.pdf", 1)
+
+			err = i.Delete(parentRi.Id)
+			Expect(err).ToNot(HaveOccurred())
+
+			assertDocCount(rootId, "subdir", 0)
+			assertDocCount(rootId, "child.pdf", 0)
+		})
+	})
+
+	Describe("Restore", func() {
+		It("also marks child resources as restored", func() {
+			err := i.Add(parentRef, parentRi)
+			Expect(err).ToNot(HaveOccurred())
+			err = i.Add(childRef, childRi)
+			Expect(err).ToNot(HaveOccurred())
+			err = i.Delete(parentRi.Id)
+			Expect(err).ToNot(HaveOccurred())
+
+			assertDocCount(rootId, "subdir", 0)
+			assertDocCount(rootId, "child.pdf", 0)
+
+			err = i.Restore(parentRi.Id)
+			Expect(err).ToNot(HaveOccurred())
+
+			assertDocCount(rootId, "subdir", 1)
+			assertDocCount(rootId, "child.pdf", 1)
+		})
+	})
+
+	Describe("Move", func() {
+		It("moves the parent and its child resources", func() {
+			err := i.Add(parentRef, parentRi)
+			Expect(err).ToNot(HaveOccurred())
+			err = i.Add(childRef, childRi)
+			Expect(err).ToNot(HaveOccurred())
+
+			parentRi.Path = "newname"
+			err = i.Move(parentRi)
+			Expect(err).ToNot(HaveOccurred())
+
+			assertDocCount(rootId, "subdir", 0)
+
+			res, err := i.Search(ctx, &searchsvc.SearchIndexRequest{
+				Query: "child.pdf",
+				Ref: &searchmsg.Reference{
+					ResourceId: &searchmsg.ResourceID{
+						StorageId: rootId.StorageId,
+						OpaqueId:  rootId.OpaqueId,
+					},
+				},
+			})
+			Expect(err).ToNot(HaveOccurred())
+			Expect(len(res.Matches)).To(Equal(1))
+			Expect(res.Matches[0].Entity.Ref.Path).To(Equal("./newname/child.pdf"))
+		})
+	})
+})
diff --git a/extensions/search/pkg/search/provider/provider_suite_test.go b/extensions/search/pkg/search/provider/provider_suite_test.go
new file mode 100644
index 00000000000..9187db0278c
--- /dev/null
+++ b/extensions/search/pkg/search/provider/provider_suite_test.go
@@ -0,0 +1,13 @@
+package provider_test
+
+import (
+	"testing"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+)
+
+func TestProvider(t *testing.T) {
+	RegisterFailHandler(Fail)
+	RunSpecs(t, "Provider Suite")
+}
diff --git a/extensions/search/pkg/search/provider/searchprovider_test.go b/extensions/search/pkg/search/provider/searchprovider_test.go
new file mode 100644
index 00000000000..f6ea0de3952
--- /dev/null
+++ b/extensions/search/pkg/search/provider/searchprovider_test.go
@@ -0,0 +1,400 @@
+package provider_test
+
+import (
+	"context"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	"github.com/stretchr/testify/mock"
+
+	gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
+	userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
+	sprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
+	typesv1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
+	"github.com/cs3org/reva/v2/pkg/events"
+	"github.com/cs3org/reva/v2/pkg/rgrpc/status"
+	cs3mocks "github.com/cs3org/reva/v2/tests/cs3mocks/mocks"
+	"github.com/owncloud/ocis/extensions/search/pkg/search/mocks"
+	provider "github.com/owncloud/ocis/extensions/search/pkg/search/provider"
+	"github.com/owncloud/ocis/ocis-pkg/log"
+	searchmsg "github.com/owncloud/ocis/protogen/gen/ocis/messages/search/v0"
+	searchsvc "github.com/owncloud/ocis/protogen/gen/ocis/services/search/v0"
+)
+
+var _ = Describe("Searchprovider", func() {
+	var (
+		p           *provider.Provider
+		gwClient    *cs3mocks.GatewayAPIClient
+		indexClient *mocks.IndexClient
+
+		ctx        context.Context
+		eventsChan chan interface{}
+
+		logger = log.NewLogger()
+		user   = &userv1beta1.User{
+			Id: &userv1beta1.UserId{
+				OpaqueId: "user",
+			},
+		}
+		otherUser = &userv1beta1.User{
+			Id: &userv1beta1.UserId{
+				OpaqueId: "otheruser",
+			},
+		}
+		personalSpace = &sprovider.StorageSpace{
+			Opaque: &typesv1beta1.Opaque{
+				Map: map[string]*typesv1beta1.OpaqueEntry{
+					"path": {
+						Decoder: "plain",
+						Value:   []byte("/foo"),
+					},
+				},
+			},
+			Id:   &sprovider.StorageSpaceId{OpaqueId: "personalspace"},
+			Root: &sprovider.ResourceId{OpaqueId: "personalspaceroot"},
+			Name: "personalspace",
+		}
+
+		ref = &sprovider.Reference{
+			ResourceId: &sprovider.ResourceId{
+				StorageId: "storageid",
+				OpaqueId:  "rootopaqueid",
+			},
+			Path: "./foo.pdf",
+		}
+		ri = &sprovider.ResourceInfo{
+			Id: &sprovider.ResourceId{
+				StorageId: "storageid",
+				OpaqueId:  "opaqueid",
+			},
+			Path: "foo.pdf",
+			Size: 12345,
+		}
+	)
+
+	BeforeEach(func() {
+		ctx = context.Background()
+		eventsChan = make(chan interface{})
+		gwClient = &cs3mocks.GatewayAPIClient{}
+		indexClient = &mocks.IndexClient{}
+
+		p = provider.New(gwClient, indexClient, "", eventsChan, logger)
+
+		gwClient.On("Authenticate", mock.Anything, mock.Anything).Return(&gateway.AuthenticateResponse{
+			Status: status.NewOK(ctx),
+			Token:  "authtoken",
+		}, nil)
+		gwClient.On("Stat", mock.Anything, mock.Anything).Return(&sprovider.StatResponse{
+			Status: status.NewOK(context.Background()),
+			Info:   ri,
+		}, nil)
+		indexClient.On("DocCount").Return(uint64(1), nil)
+	})
+
+	Describe("New", func() {
+		It("returns a new instance", func() {
+			p := provider.New(gwClient, indexClient, "", eventsChan, logger)
+			Expect(p).ToNot(BeNil())
+		})
+	})
+
+	Describe("events", func() {
+		It("trigger an index update when a file has been uploaded", func() {
+			called := false
+			indexClient.On("Add", mock.Anything, mock.MatchedBy(func(riToIndex *sprovider.ResourceInfo) bool {
+				return riToIndex.Id.OpaqueId == ri.Id.OpaqueId
+			})).Return(nil).Run(func(args mock.Arguments) {
+				called = true
+			})
+			eventsChan <- events.FileUploaded{
+				Ref:       ref,
+				Executant: user.Id,
+			}
+
+			Eventually(func() bool {
+				return called
+			}).Should(BeTrue())
+		})
+
+		It("removes an entry from the index when the file has been deleted", func() {
+			called := false
+
+			gwClient.On("Stat", mock.Anything, mock.Anything).Return(&sprovider.StatResponse{
+				Status: status.NewNotFound(context.Background(), ""),
+			}, nil)
+			indexClient.On("Delete", mock.MatchedBy(func(id *sprovider.ResourceId) bool {
+				return id.OpaqueId == ri.Id.OpaqueId
+			})).Return(nil).Run(func(args mock.Arguments) {
+				called = true
+			})
+			eventsChan <- events.ItemTrashed{
+				Ref:       ref,
+				ID:        ri.Id,
+				Executant: user.Id,
+			}
+
+			Eventually(func() bool {
+				return called
+			}).Should(BeTrue())
+		})
+
+		It("indexes items when they are being restored", func() {
+			called := false
+			indexClient.On("Restore", mock.MatchedBy(func(id *sprovider.ResourceId) bool {
+				return id.OpaqueId == ri.Id.OpaqueId
+			})).Return(nil).Run(func(args mock.Arguments) {
+				called = true
+			})
+			eventsChan <- events.ItemRestored{
+				Ref:       ref,
+				Executant: user.Id,
+			}
+
+			Eventually(func() bool {
+				return called
+			}).Should(BeTrue())
+		})
+
+		It("indexes items when a version has been restored", func() {
+			called := false
+			indexClient.On("Add", mock.Anything, mock.MatchedBy(func(riToIndex *sprovider.ResourceInfo) bool {
+				return riToIndex.Id.OpaqueId == ri.Id.OpaqueId
+			})).Return(nil).Run(func(args mock.Arguments) {
+				called = true
+			})
+			eventsChan <- events.FileVersionRestored{
+				Ref:       ref,
+				Executant: user.Id,
+			}
+
+			Eventually(func() bool {
+				return called
+			}).Should(BeTrue())
+		})
+
+		It("indexes items when they are being moved", func() {
+			called := false
+			indexClient.On("Move", mock.Anything, mock.MatchedBy(func(riToIndex *sprovider.ResourceInfo) bool {
+				return riToIndex.Id.OpaqueId == ri.Id.OpaqueId
+			})).Return(nil).Run(func(args mock.Arguments) {
+				called = true
+			})
+			eventsChan <- events.ItemMoved{
+				Ref:       ref,
+				Executant: user.Id,
+			}
+
+			Eventually(func() bool {
+				return called
+			}).Should(BeTrue())
+		})
+	})
+
+	Describe("IndexSpace", func() {
+		It("walks the space and indexes all files", func() {
+			gwClient.On("GetUserByClaim", mock.Anything, mock.Anything).Return(&userv1beta1.GetUserByClaimResponse{
+				Status: status.NewOK(context.Background()),
+				User:   user,
+			}, nil)
+			indexClient.On("Add", mock.Anything, mock.MatchedBy(func(riToIndex *sprovider.ResourceInfo) bool {
+				return riToIndex.Id.OpaqueId == ri.Id.OpaqueId
+			})).Return(nil)
+
+			res, err := p.IndexSpace(ctx, &searchsvc.IndexSpaceRequest{
+				SpaceId: "storageid",
+				UserId:  "user",
+			})
+			Expect(err).ToNot(HaveOccurred())
+			Expect(res).ToNot(BeNil())
+		})
+	})
+
+	Describe("Search", func() {
+		It("fails when an empty query is given", func() {
+			res, err := p.Search(ctx, &searchsvc.SearchRequest{
+				Query: "",
+			})
+			Expect(err).To(HaveOccurred())
+			Expect(res).To(BeNil())
+		})
+
+		Context("with a personal space", func() {
+			BeforeEach(func() {
+				gwClient.On("ListStorageSpaces", mock.Anything, mock.MatchedBy(func(req *sprovider.ListStorageSpacesRequest) bool {
+					p := string(req.Opaque.Map["path"].Value)
+					return p == "/"
+				})).Return(&sprovider.ListStorageSpacesResponse{
+					Status:        status.NewOK(ctx),
+					StorageSpaces: []*sprovider.StorageSpace{personalSpace},
+				}, nil)
+				indexClient.On("Search", mock.Anything, mock.Anything).Return(&searchsvc.SearchIndexResponse{
+					Matches: []*searchmsg.Match{
+						{
+							Entity: &searchmsg.Entity{
+								Ref: &searchmsg.Reference{
+									ResourceId: &searchmsg.ResourceID{
+										StorageId: personalSpace.Root.StorageId,
+										OpaqueId:  personalSpace.Root.OpaqueId,
+									},
+									Path: "./path/to/Foo.pdf",
+								},
+								Id: &searchmsg.ResourceID{
+									StorageId: personalSpace.Root.StorageId,
+									OpaqueId:  "foo-id",
+								},
+								Name: "Foo.pdf",
+							},
+						},
+					},
+				}, nil)
+			})
+
+			It("searches the personal user space", func() {
+				res, err := p.Search(ctx, &searchsvc.SearchRequest{
+					Query: "foo",
+				})
+				Expect(err).ToNot(HaveOccurred())
+				Expect(res).ToNot(BeNil())
+				Expect(len(res.Matches)).To(Equal(1))
+				match := res.Matches[0]
+				Expect(match.Entity.Id.OpaqueId).To(Equal("foo-id"))
+				Expect(match.Entity.Name).To(Equal("Foo.pdf"))
+				Expect(match.Entity.Ref.ResourceId.OpaqueId).To(Equal(personalSpace.Root.OpaqueId))
+				Expect(match.Entity.Ref.Path).To(Equal("./path/to/Foo.pdf"))
+
+				indexClient.AssertCalled(GinkgoT(), "Search", mock.Anything, mock.MatchedBy(func(req *searchsvc.SearchIndexRequest) bool {
+					return req.Query == "foo" && req.Ref.ResourceId.OpaqueId == personalSpace.Root.OpaqueId && req.Ref.Path == ""
+				}))
+			})
+		})
+
+		Context("with received shares", func() {
+			var (
+				grantSpace *sprovider.StorageSpace
+			)
+
+			BeforeEach(func() {
+				grantSpace = &sprovider.StorageSpace{
+					SpaceType: "grant",
+					Owner:     otherUser,
+					Id:        &sprovider.StorageSpaceId{OpaqueId: "otherspaceroot!otherspacegrant"},
+					Root:      &sprovider.ResourceId{StorageId: "otherspaceroot", OpaqueId: "otherspacegrant"},
+					Name:      "grantspace",
+				}
+				gwClient.On("GetPath", mock.Anything, mock.Anything).Return(&sprovider.GetPathResponse{
+					Status: status.NewOK(ctx),
+					Path:   "/grant/path",
+				}, nil)
+			})
+
+			It("searches the received spaces (grants)", func() {
+				gwClient.On("ListStorageSpaces", mock.Anything, mock.MatchedBy(func(req *sprovider.ListStorageSpacesRequest) bool {
+					p := string(req.Opaque.Map["path"].Value)
+					return p == "/"
+				})).Return(&sprovider.ListStorageSpacesResponse{
+					Status:        status.NewOK(ctx),
+					StorageSpaces: []*sprovider.StorageSpace{grantSpace},
+				}, nil)
+				indexClient.On("Search", mock.Anything, mock.Anything).Return(&searchsvc.SearchIndexResponse{
+					Matches: []*searchmsg.Match{
+						{
+							Entity: &searchmsg.Entity{
+								Ref: &searchmsg.Reference{
+									ResourceId: &searchmsg.ResourceID{
+										StorageId: grantSpace.Root.StorageId,
+										OpaqueId:  grantSpace.Root.OpaqueId,
+									},
+									Path: "./grant/path/to/Shared.pdf",
+								},
+								Id: &searchmsg.ResourceID{
+									StorageId: grantSpace.Root.StorageId,
+									OpaqueId:  "grant-shared-id",
+								},
+								Name: "Shared.pdf",
+							},
+						},
+					},
+				}, nil)
+
+				res, err := p.Search(ctx, &searchsvc.SearchRequest{
+					Query: "foo",
+				})
+				Expect(err).ToNot(HaveOccurred())
+				Expect(res).ToNot(BeNil())
+				Expect(len(res.Matches)).To(Equal(1))
+				match := res.Matches[0]
+				Expect(match.Entity.Id.OpaqueId).To(Equal("grant-shared-id"))
+				Expect(match.Entity.Name).To(Equal("Shared.pdf"))
+				Expect(match.Entity.Ref.ResourceId.OpaqueId).To(Equal(grantSpace.Root.OpaqueId))
+				Expect(match.Entity.Ref.Path).To(Equal("./to/Shared.pdf"))
+
+				indexClient.AssertCalled(GinkgoT(), "Search", mock.Anything, mock.MatchedBy(func(req *searchsvc.SearchIndexRequest) bool {
+					return req.Query == "foo" && req.Ref.ResourceId.OpaqueId == grantSpace.Root.OpaqueId && req.Ref.Path == "./grant/path"
+				}))
+			})
+
+			It("finds matches in both the personal space AND the grant", func() {
+				gwClient.On("ListStorageSpaces", mock.Anything, mock.MatchedBy(func(req *sprovider.ListStorageSpacesRequest) bool {
+					p := string(req.Opaque.Map["path"].Value)
+					return p == "/"
+				})).Return(&sprovider.ListStorageSpacesResponse{
+					Status:        status.NewOK(ctx),
+					StorageSpaces: []*sprovider.StorageSpace{personalSpace, grantSpace},
+				}, nil)
+				indexClient.On("Search", mock.Anything, mock.MatchedBy(func(req *searchsvc.SearchIndexRequest) bool {
+					return req.Ref.ResourceId.OpaqueId == grantSpace.Root.OpaqueId
+				})).Return(&searchsvc.SearchIndexResponse{
+					Matches: []*searchmsg.Match{
+						{
+							Entity: &searchmsg.Entity{
+								Ref: &searchmsg.Reference{
+									ResourceId: &searchmsg.ResourceID{
+										StorageId: grantSpace.Root.StorageId,
+										OpaqueId:  grantSpace.Root.OpaqueId,
+									},
+									Path: "./grant/path/to/Shared.pdf",
+								},
+								Id: &searchmsg.ResourceID{
+									StorageId: grantSpace.Root.StorageId,
+									OpaqueId:  "grant-shared-id",
+								},
+								Name: "Shared.pdf",
+							},
+						},
+					},
+				}, nil)
+				indexClient.On("Search", mock.Anything, mock.MatchedBy(func(req *searchsvc.SearchIndexRequest) bool {
+					return req.Ref.ResourceId.OpaqueId == personalSpace.Root.OpaqueId
+				})).Return(&searchsvc.SearchIndexResponse{
+					Matches: []*searchmsg.Match{
+						{
+							Entity: &searchmsg.Entity{
+								Ref: &searchmsg.Reference{
+									ResourceId: &searchmsg.ResourceID{
+										StorageId: personalSpace.Root.StorageId,
+										OpaqueId:  personalSpace.Root.OpaqueId,
+									},
+									Path: "./path/to/Foo.pdf",
+								},
+								Id: &searchmsg.ResourceID{
+									StorageId: personalSpace.Root.StorageId,
+									OpaqueId:  "foo-id",
+								},
+								Name: "Foo.pdf",
+							},
+						},
+					},
+				}, nil)
+
+				res, err := p.Search(ctx, &searchsvc.SearchRequest{
+					Query: "foo",
+				})
+				Expect(err).ToNot(HaveOccurred())
+				Expect(res).ToNot(BeNil())
+				Expect(len(res.Matches)).To(Equal(2))
+				ids := []string{res.Matches[0].Entity.Id.OpaqueId, res.Matches[1].Entity.Id.OpaqueId}
+				Expect(ids).To(ConsistOf("foo-id", "grant-shared-id"))
+			})
+		})
+	})
+})
diff --git a/extensions/search/pkg/search/search_suite_test.go b/extensions/search/pkg/search/search_suite_test.go
new file mode 100644
index 00000000000..9b2060ec0da
--- /dev/null
+++ b/extensions/search/pkg/search/search_suite_test.go
@@ -0,0 +1,13 @@
+package search_test
+
+import (
+	"testing"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+)
+
+func TestSearch(t *testing.T) {
+	RegisterFailHandler(Fail)
+	RunSpecs(t, "Search Suite")
+}
diff --git a/tests/acceptance/expected-failures-graphAPI-on-OCIS-storage.md b/tests/acceptance/expected-failures-graphAPI-on-OCIS-storage.md
index f513a93f0a1..24c99648129 100644
--- a/tests/acceptance/expected-failures-graphAPI-on-OCIS-storage.md
+++ b/tests/acceptance/expected-failures-graphAPI-on-OCIS-storage.md
@@ -1016,6 +1016,15 @@ _ocdav: api compatibility, return correct status code_
 - [apiWebdavOperations/search.feature:265](https://github.com/owncloud/core/blob/master/tests/acceptance/features/apiWebdavOperations/search.feature#L265)
 - [apiWebdavOperations/search.feature:270](https://github.com/owncloud/core/blob/master/tests/acceptance/features/apiWebdavOperations/search.feature#L270)
 
+#### [Support for favorites](https://github.com/owncloud/ocis/issues/1228)
+
+- [apiFavorites/favorites.feature:115](https://github.com/owncloud/core/blob/master/tests/acceptance/features/apiFavorites/favorites.feature#L115)
+- [apiFavorites/favorites.feature:116](https://github.com/owncloud/core/blob/master/tests/acceptance/features/apiFavorites/favorites.feature#L116)
+- [apiFavorites/favorites.feature:141](https://github.com/owncloud/core/blob/master/tests/acceptance/features/apiFavorites/favorites.feature#L141)
+- [apiFavorites/favorites.feature:142](https://github.com/owncloud/core/blob/master/tests/acceptance/features/apiFavorites/favorites.feature#L142)
+- [apiFavorites/favorites.feature:267](https://github.com/owncloud/core/blob/master/tests/acceptance/features/apiFavorites/favorites.feature#L267)
+- [apiFavorites/favorites.feature:268](https://github.com/owncloud/core/blob/master/tests/acceptance/features/apiFavorites/favorites.feature#L268)
+
 And other missing implementation of favorites
 
 - [apiFavorites/favorites.feature:162](https://github.com/owncloud/core/blob/master/tests/acceptance/features/apiFavorites/favorites.feature#L162)