From 4940a6ec022d28e929b1334660fe68e5687b4e95 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=B6rn=20Friedrich=20Dreyer?= <jfd@butonic.de>
Date: Fri, 13 Aug 2021 23:46:44 +0200
Subject: [PATCH] userprovider owncloudsql
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Signed-off-by: Jörn Friedrich Dreyer <jfd@butonic.de>
---
 .../unreleased/owncloudsql-userprovider.md    |   5 +
 pkg/user/manager/loader/loader.go             |   1 +
 .../manager/owncloudsql/accounts/accounts.go  | 225 +++++++++
 .../accounts/accounts_suite_test.go           |  31 ++
 .../owncloudsql/accounts/accounts_test.go     | 459 ++++++++++++++++++
 .../manager/owncloudsql/accounts/test.sqlite  | Bin 0 -> 90112 bytes
 pkg/user/manager/owncloudsql/owncloudsql.go   | 180 +++++++
 7 files changed, 901 insertions(+)
 create mode 100644 changelog/unreleased/owncloudsql-userprovider.md
 create mode 100644 pkg/user/manager/owncloudsql/accounts/accounts.go
 create mode 100644 pkg/user/manager/owncloudsql/accounts/accounts_suite_test.go
 create mode 100644 pkg/user/manager/owncloudsql/accounts/accounts_test.go
 create mode 100644 pkg/user/manager/owncloudsql/accounts/test.sqlite
 create mode 100644 pkg/user/manager/owncloudsql/owncloudsql.go

diff --git a/changelog/unreleased/owncloudsql-userprovider.md b/changelog/unreleased/owncloudsql-userprovider.md
new file mode 100644
index 0000000000..ad24f5b929
--- /dev/null
+++ b/changelog/unreleased/owncloudsql-userprovider.md
@@ -0,0 +1,5 @@
+Enhancement: Add owncloudsql driver for the userprovider
+
+We added a new backend for the userprovider that is backed by an owncloud 10 database. By default the `user_id` column is used as the reva user username and reva user opaque id. When setting `join_username=true` the reva user username is joined from the `oc_preferences` table (`appid='core' AND configkey='username'`) instead. When setting `join_ownclouduuid=true` the reva user opaqueid is joined from the `oc_preferences` table (`appid='core' AND configkey='ownclouduuid'`) instead. This allows more flexible migration strategies. It also supports a `enable_medial_search` config option when searching users that will enclose the query with `%`.
+
+https://github.com/cs3org/reva/pull/1994
diff --git a/pkg/user/manager/loader/loader.go b/pkg/user/manager/loader/loader.go
index c935085bcb..41e61a9a6f 100644
--- a/pkg/user/manager/loader/loader.go
+++ b/pkg/user/manager/loader/loader.go
@@ -23,5 +23,6 @@ import (
 	_ "github.com/cs3org/reva/pkg/user/manager/demo"
 	_ "github.com/cs3org/reva/pkg/user/manager/json"
 	_ "github.com/cs3org/reva/pkg/user/manager/ldap"
+	_ "github.com/cs3org/reva/pkg/user/manager/owncloudsql"
 	// Add your own here
 )
diff --git a/pkg/user/manager/owncloudsql/accounts/accounts.go b/pkg/user/manager/owncloudsql/accounts/accounts.go
new file mode 100644
index 0000000000..3aee8b5a17
--- /dev/null
+++ b/pkg/user/manager/owncloudsql/accounts/accounts.go
@@ -0,0 +1,225 @@
+// Copyright 2018-2021 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 accounts
+
+import (
+	"context"
+	"database/sql"
+	"strings"
+	"time"
+
+	"github.com/cs3org/reva/pkg/appctx"
+	"github.com/pkg/errors"
+)
+
+// Accounts represents oc10-style Accounts
+type Accounts struct {
+	driver                                     string
+	db                                         *sql.DB
+	joinUsername, joinUUID, enableMedialSearch bool
+	selectSQL                                  string
+}
+
+// NewMysql returns a new Cache instance connecting to a MySQL database
+func NewMysql(dsn string, joinUsername, joinUUID, enableMedialSearch bool) (*Accounts, error) {
+	sqldb, err := sql.Open("mysql", dsn)
+	if err != nil {
+		return nil, errors.Wrap(err, "error connecting to the database")
+	}
+	sqldb.SetConnMaxLifetime(time.Minute * 3)
+	sqldb.SetMaxOpenConns(10)
+	sqldb.SetMaxIdleConns(10)
+
+	err = sqldb.Ping()
+	if err != nil {
+		return nil, errors.Wrap(err, "error connecting to the database")
+	}
+
+	return New("mysql", sqldb, joinUsername, joinUUID, enableMedialSearch)
+}
+
+// New returns a new Cache instance connecting to the given sql.DB
+func New(driver string, sqldb *sql.DB, joinUsername, joinUUID, enableMedialSearch bool) (*Accounts, error) {
+
+	sel := "SELECT id, email, user_id, display_name, quota, last_login, backend, home, state"
+	from := `
+		FROM oc_accounts a
+		`
+	if joinUsername {
+		sel += ", p.configvalue AS username"
+		from += `LEFT JOIN oc_preferences p
+						ON a.user_id=p.userid
+						AND p.appid='core'
+						AND p.configkey='username'`
+	} else {
+		// fallback to user_id as username
+		sel += ", user_id AS username"
+	}
+	if joinUUID {
+		sel += ", p2.configvalue AS ownclouduuid"
+		from += `LEFT JOIN oc_preferences p2
+						ON a.user_id=p2.userid
+						AND p2.appid='core'
+						AND p2.configkey='ownclouduuid'`
+	} else {
+		// fallback to user_id as ownclouduuid
+		sel += ", user_id AS ownclouduuid"
+	}
+
+	return &Accounts{
+		driver:             driver,
+		db:                 sqldb,
+		joinUsername:       joinUsername,
+		joinUUID:           joinUUID,
+		enableMedialSearch: enableMedialSearch,
+		selectSQL:          sel + from,
+	}, nil
+}
+
+// Account stores information about accounts.
+type Account struct {
+	ID           uint64
+	Email        sql.NullString
+	UserID       string
+	DisplayName  sql.NullString
+	Quota        sql.NullString
+	LastLogin    int
+	Backend      string
+	Home         string
+	State        int8
+	Username     sql.NullString // optional comes from the oc_preferences
+	OwnCloudUUID sql.NullString // optional comes from the oc_preferences
+}
+
+func (as *Accounts) rowToAccount(ctx context.Context, row Scannable) (*Account, error) {
+	a := Account{}
+	if err := row.Scan(&a.ID, &a.Email, &a.UserID, &a.DisplayName, &a.Quota, &a.LastLogin, &a.Backend, &a.Home, &a.State, &a.Username, &a.OwnCloudUUID); err != nil {
+		appctx.GetLogger(ctx).Error().Err(err).Msg("could not scan row, skipping")
+		return nil, err
+	}
+
+	return &a, nil
+}
+
+// Scannable describes the interface providing a Scan method
+type Scannable interface {
+	Scan(...interface{}) error
+}
+
+// GetAccountByClaim fetches an account by mail, username or userid
+func (as *Accounts) GetAccountByClaim(ctx context.Context, claim, value string) (*Account, error) {
+	// TODO align supported claims with rest driver and the others, maybe refactor into common mapping
+	var row *sql.Row
+	var where string
+	switch claim {
+	case "mail":
+		where = "WHERE a.email=?"
+	// case "uid":
+	//	claim = m.c.Schema.UIDNumber
+	// case "gid":
+	//	claim = m.c.Schema.GIDNumber
+	case "username":
+		if as.joinUsername {
+			where = "WHERE p.configvalue=?"
+		} else {
+			// use user_id as username
+			where = "WHERE a.user_id=?"
+		}
+	case "userid":
+		if as.joinUUID {
+			where = "WHERE p2.configvalue=?"
+		} else {
+			// use user_id as uuid
+			where = "WHERE a.user_id=?"
+		}
+	default:
+		return nil, errors.New("owncloudsql: invalid field " + claim)
+	}
+
+	row = as.db.QueryRowContext(ctx, as.selectSQL+where, value)
+
+	return as.rowToAccount(ctx, row)
+}
+
+func sanitizeWildcards(q string) string {
+	return strings.ReplaceAll(strings.ReplaceAll(q, "%", `\%`), "_", `\_`)
+}
+
+// FindAccounts searches userid, displayname and email using the given query. The Wildcard caracters % and _ are escaped.
+func (as *Accounts) FindAccounts(ctx context.Context, query string) ([]Account, error) {
+	if as.enableMedialSearch {
+		query = "%" + sanitizeWildcards(query) + "%"
+	}
+	// TODO join oc_account_terms
+	where := "WHERE a.user_id LIKE ? OR a.display_name LIKE ? OR a.email LIKE ?"
+	args := []interface{}{query, query, query}
+
+	if as.joinUsername {
+		where += " OR p.configvalue LIKE ?"
+		args = append(args, query)
+	}
+	if as.joinUUID {
+		where += " OR p2.configvalue LIKE ?"
+		args = append(args, query)
+	}
+
+	rows, err := as.db.QueryContext(ctx, as.selectSQL+where, args...)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	accounts := []Account{}
+	for rows.Next() {
+		a := Account{}
+		if err := rows.Scan(&a.ID, &a.Email, &a.UserID, &a.DisplayName, &a.Quota, &a.LastLogin, &a.Backend, &a.Home, &a.State, &a.Username, &a.OwnCloudUUID); err != nil {
+			appctx.GetLogger(ctx).Error().Err(err).Msg("could not scan row, skipping")
+			continue
+		}
+		accounts = append(accounts, a)
+	}
+	if err = rows.Err(); err != nil {
+		return nil, err
+	}
+
+	return accounts, nil
+}
+
+// GetAccountGroups lasts the groups for an account
+func (as *Accounts) GetAccountGroups(ctx context.Context, uid string) ([]string, error) {
+	rows, err := as.db.QueryContext(ctx, "SELECT gid FROM oc_group_user WHERE uid=?", uid)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var group string
+	groups := []string{}
+	for rows.Next() {
+		if err := rows.Scan(&group); err != nil {
+			appctx.GetLogger(ctx).Error().Err(err).Msg("could not scan row, skipping")
+			continue
+		}
+		groups = append(groups, group)
+	}
+	if err = rows.Err(); err != nil {
+		return nil, err
+	}
+	return groups, nil
+}
diff --git a/pkg/user/manager/owncloudsql/accounts/accounts_suite_test.go b/pkg/user/manager/owncloudsql/accounts/accounts_suite_test.go
new file mode 100644
index 0000000000..8564f5a515
--- /dev/null
+++ b/pkg/user/manager/owncloudsql/accounts/accounts_suite_test.go
@@ -0,0 +1,31 @@
+// Copyright 2018-2021 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 accounts_test
+
+import (
+	"testing"
+
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+)
+
+func TestAccounts(t *testing.T) {
+	RegisterFailHandler(Fail)
+	RunSpecs(t, "Accounts Suite")
+}
diff --git a/pkg/user/manager/owncloudsql/accounts/accounts_test.go b/pkg/user/manager/owncloudsql/accounts/accounts_test.go
new file mode 100644
index 0000000000..61819b966f
--- /dev/null
+++ b/pkg/user/manager/owncloudsql/accounts/accounts_test.go
@@ -0,0 +1,459 @@
+// Copyright 2018-2021 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 accounts_test
+
+import (
+	"context"
+	"database/sql"
+	"io/ioutil"
+	"os"
+
+	_ "github.com/mattn/go-sqlite3"
+
+	"github.com/cs3org/reva/pkg/user/manager/owncloudsql/accounts"
+
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+)
+
+var _ = Describe("Accounts", func() {
+	var (
+		conn       *accounts.Accounts
+		testDbFile *os.File
+		sqldb      *sql.DB
+	)
+
+	BeforeEach(func() {
+		var err error
+		testDbFile, err = ioutil.TempFile("", "example")
+		Expect(err).ToNot(HaveOccurred())
+
+		dbData, err := ioutil.ReadFile("test.sqlite")
+		Expect(err).ToNot(HaveOccurred())
+
+		_, err = testDbFile.Write(dbData)
+		Expect(err).ToNot(HaveOccurred())
+		err = testDbFile.Close()
+		Expect(err).ToNot(HaveOccurred())
+
+		sqldb, err = sql.Open("sqlite3", testDbFile.Name())
+		Expect(err).ToNot(HaveOccurred())
+
+	})
+
+	AfterEach(func() {
+		os.Remove(testDbFile.Name())
+	})
+
+	Describe("GetAccountByClaim", func() {
+
+		Context("without any joins", func() {
+
+			BeforeEach(func() {
+				var err error
+				conn, err = accounts.New("sqlite3", sqldb, false, false, false)
+				Expect(err).ToNot(HaveOccurred())
+			})
+
+			It("gets existing account by userid", func() {
+				userID := "admin"
+				account, err := conn.GetAccountByClaim(context.Background(), "userid", userID)
+				Expect(err).ToNot(HaveOccurred())
+				Expect(account).ToNot(BeNil())
+				Expect(account.ID).To(Equal(uint64(1)))
+				Expect(account.Email.String).To(Equal("admin@example.org"))
+				Expect(account.UserID).To(Equal("admin"))
+				Expect(account.DisplayName.String).To(Equal("admin"))
+				Expect(account.Quota.String).To(Equal("100 GB"))
+				Expect(account.LastLogin).To(Equal(1619082575))
+				Expect(account.Backend).To(Equal(`OC\User\Database`))
+				Expect(account.Home).To(Equal("/mnt/data/files/admin"))
+				Expect(account.State).To(Equal(int8(1)))
+				Expect(account.Username.String).To(Equal("admin"))
+				Expect(account.OwnCloudUUID.String).To(Equal("admin"))
+			})
+
+			It("gets existing account by mail", func() {
+				value := "admin@example.org"
+				account, err := conn.GetAccountByClaim(context.Background(), "mail", value)
+				Expect(err).ToNot(HaveOccurred())
+				Expect(account).ToNot(BeNil())
+				Expect(account.ID).To(Equal(uint64(1)))
+				Expect(account.Email.String).To(Equal("admin@example.org"))
+				Expect(account.UserID).To(Equal("admin"))
+				Expect(account.DisplayName.String).To(Equal("admin"))
+				Expect(account.Quota.String).To(Equal("100 GB"))
+				Expect(account.LastLogin).To(Equal(1619082575))
+				Expect(account.Backend).To(Equal(`OC\User\Database`))
+				Expect(account.Home).To(Equal("/mnt/data/files/admin"))
+				Expect(account.State).To(Equal(int8(1)))
+				Expect(account.Username.String).To(Equal("admin"))
+				Expect(account.OwnCloudUUID.String).To(Equal("admin"))
+			})
+
+			It("falls back to user_id colum when getting by username", func() {
+				value := "admin"
+				account, err := conn.GetAccountByClaim(context.Background(), "username", value)
+				Expect(err).ToNot(HaveOccurred())
+				Expect(account).ToNot(BeNil())
+				Expect(account.ID).To(Equal(uint64(1)))
+				Expect(account.Email.String).To(Equal("admin@example.org"))
+				Expect(account.UserID).To(Equal("admin"))
+				Expect(account.DisplayName.String).To(Equal("admin"))
+				Expect(account.Quota.String).To(Equal("100 GB"))
+				Expect(account.LastLogin).To(Equal(1619082575))
+				Expect(account.Backend).To(Equal(`OC\User\Database`))
+				Expect(account.Home).To(Equal("/mnt/data/files/admin"))
+				Expect(account.State).To(Equal(int8(1)))
+				Expect(account.Username.String).To(Equal("admin"))
+				Expect(account.OwnCloudUUID.String).To(Equal("admin"))
+			})
+
+			It("errors on unsupported claim", func() {
+				_, err := conn.GetAccountByClaim(context.Background(), "invalid", "invalid")
+				Expect(err).To(HaveOccurred())
+			})
+		})
+
+		Context("with username joins", func() {
+
+			BeforeEach(func() {
+				var err error
+				conn, err = accounts.New("sqlite3", sqldb, true, false, false)
+				Expect(err).ToNot(HaveOccurred())
+			})
+
+			It("gets existing account by userid", func() {
+				userID := "admin"
+				account, err := conn.GetAccountByClaim(context.Background(), "userid", userID)
+				Expect(err).ToNot(HaveOccurred())
+				Expect(account).ToNot(BeNil())
+				Expect(account.ID).To(Equal(uint64(1)))
+				Expect(account.Email.String).To(Equal("admin@example.org"))
+				Expect(account.UserID).To(Equal("admin"))
+				Expect(account.DisplayName.String).To(Equal("admin"))
+				Expect(account.Quota.String).To(Equal("100 GB"))
+				Expect(account.LastLogin).To(Equal(1619082575))
+				Expect(account.Backend).To(Equal(`OC\User\Database`))
+				Expect(account.Home).To(Equal("/mnt/data/files/admin"))
+				Expect(account.State).To(Equal(int8(1)))
+				Expect(account.Username.String).To(Equal("Administrator"))
+				Expect(account.OwnCloudUUID.String).To(Equal("admin"))
+			})
+
+			It("gets existing account by mail", func() {
+				value := "admin@example.org"
+				account, err := conn.GetAccountByClaim(context.Background(), "mail", value)
+				Expect(err).ToNot(HaveOccurred())
+				Expect(account).ToNot(BeNil())
+				Expect(account.ID).To(Equal(uint64(1)))
+				Expect(account.Email.String).To(Equal("admin@example.org"))
+				Expect(account.UserID).To(Equal("admin"))
+				Expect(account.DisplayName.String).To(Equal("admin"))
+				Expect(account.Quota.String).To(Equal("100 GB"))
+				Expect(account.LastLogin).To(Equal(1619082575))
+				Expect(account.Backend).To(Equal(`OC\User\Database`))
+				Expect(account.Home).To(Equal("/mnt/data/files/admin"))
+				Expect(account.State).To(Equal(int8(1)))
+				Expect(account.Username.String).To(Equal("Administrator"))
+				Expect(account.OwnCloudUUID.String).To(Equal("admin"))
+			})
+
+			It("gets existing account by username", func() {
+				value := "Administrator"
+				account, err := conn.GetAccountByClaim(context.Background(), "username", value)
+				Expect(err).ToNot(HaveOccurred())
+				Expect(account).ToNot(BeNil())
+				Expect(account.ID).To(Equal(uint64(1)))
+				Expect(account.Email.String).To(Equal("admin@example.org"))
+				Expect(account.UserID).To(Equal("admin"))
+				Expect(account.DisplayName.String).To(Equal("admin"))
+				Expect(account.Quota.String).To(Equal("100 GB"))
+				Expect(account.LastLogin).To(Equal(1619082575))
+				Expect(account.Backend).To(Equal(`OC\User\Database`))
+				Expect(account.Home).To(Equal("/mnt/data/files/admin"))
+				Expect(account.State).To(Equal(int8(1)))
+				Expect(account.Username.String).To(Equal("Administrator"))
+				Expect(account.OwnCloudUUID.String).To(Equal("admin"))
+			})
+
+			It("errors on unsupported claim", func() {
+				_, err := conn.GetAccountByClaim(context.Background(), "invalid", "invalid")
+				Expect(err).To(HaveOccurred())
+			})
+		})
+
+		Context("with uuid joins", func() {
+
+			BeforeEach(func() {
+				var err error
+				conn, err = accounts.New("sqlite3", sqldb, false, true, false)
+				Expect(err).ToNot(HaveOccurred())
+			})
+
+			It("gets existing account by uuid", func() {
+				userID := "7015b5ec-7723-4560-bb96-85e18a947314"
+				account, err := conn.GetAccountByClaim(context.Background(), "userid", userID)
+				Expect(err).ToNot(HaveOccurred())
+				Expect(account).ToNot(BeNil())
+				Expect(account.ID).To(Equal(uint64(1)))
+				Expect(account.Email.String).To(Equal("admin@example.org"))
+				Expect(account.UserID).To(Equal("admin"))
+				Expect(account.DisplayName.String).To(Equal("admin"))
+				Expect(account.Quota.String).To(Equal("100 GB"))
+				Expect(account.LastLogin).To(Equal(1619082575))
+				Expect(account.Backend).To(Equal(`OC\User\Database`))
+				Expect(account.Home).To(Equal("/mnt/data/files/admin"))
+				Expect(account.State).To(Equal(int8(1)))
+				Expect(account.Username.String).To(Equal("admin"))
+				Expect(account.OwnCloudUUID.String).To(Equal("7015b5ec-7723-4560-bb96-85e18a947314"))
+			})
+
+			It("gets existing account by mail", func() {
+				value := "admin@example.org"
+				account, err := conn.GetAccountByClaim(context.Background(), "mail", value)
+				Expect(err).ToNot(HaveOccurred())
+				Expect(account).ToNot(BeNil())
+				Expect(account.ID).To(Equal(uint64(1)))
+				Expect(account.Email.String).To(Equal("admin@example.org"))
+				Expect(account.UserID).To(Equal("admin"))
+				Expect(account.DisplayName.String).To(Equal("admin"))
+				Expect(account.Quota.String).To(Equal("100 GB"))
+				Expect(account.LastLogin).To(Equal(1619082575))
+				Expect(account.Backend).To(Equal(`OC\User\Database`))
+				Expect(account.Home).To(Equal("/mnt/data/files/admin"))
+				Expect(account.State).To(Equal(int8(1)))
+				Expect(account.Username.String).To(Equal("admin"))
+				Expect(account.OwnCloudUUID.String).To(Equal("7015b5ec-7723-4560-bb96-85e18a947314"))
+			})
+
+			It("gets existing account by username", func() {
+				value := "admin"
+				account, err := conn.GetAccountByClaim(context.Background(), "username", value)
+				Expect(err).ToNot(HaveOccurred())
+				Expect(account).ToNot(BeNil())
+				Expect(account.ID).To(Equal(uint64(1)))
+				Expect(account.Email.String).To(Equal("admin@example.org"))
+				Expect(account.UserID).To(Equal("admin"))
+				Expect(account.DisplayName.String).To(Equal("admin"))
+				Expect(account.Quota.String).To(Equal("100 GB"))
+				Expect(account.LastLogin).To(Equal(1619082575))
+				Expect(account.Backend).To(Equal(`OC\User\Database`))
+				Expect(account.Home).To(Equal("/mnt/data/files/admin"))
+				Expect(account.State).To(Equal(int8(1)))
+				Expect(account.Username.String).To(Equal("admin"))
+				Expect(account.OwnCloudUUID.String).To(Equal("7015b5ec-7723-4560-bb96-85e18a947314"))
+			})
+
+			It("errors on unsupported claim", func() {
+				_, err := conn.GetAccountByClaim(context.Background(), "invalid", "invalid")
+				Expect(err).To(HaveOccurred())
+			})
+		})
+
+		Context("with username and uuid joins", func() {
+
+			BeforeEach(func() {
+				var err error
+				conn, err = accounts.New("sqlite3", sqldb, true, true, false)
+				Expect(err).ToNot(HaveOccurred())
+			})
+
+			It("gets existing account by uuid", func() {
+				userID := "7015b5ec-7723-4560-bb96-85e18a947314"
+				account, err := conn.GetAccountByClaim(context.Background(), "userid", userID)
+				Expect(err).ToNot(HaveOccurred())
+				Expect(account.ID).To(Equal(uint64(1)))
+				Expect(account.Email.String).To(Equal("admin@example.org"))
+				Expect(account.UserID).To(Equal("admin"))
+				Expect(account.DisplayName.String).To(Equal("admin"))
+				Expect(account.Quota.String).To(Equal("100 GB"))
+				Expect(account.LastLogin).To(Equal(1619082575))
+				Expect(account.Backend).To(Equal(`OC\User\Database`))
+				Expect(account.Home).To(Equal("/mnt/data/files/admin"))
+				Expect(account.State).To(Equal(int8(1)))
+				Expect(account.Username.String).To(Equal("Administrator"))
+				Expect(account.OwnCloudUUID.String).To(Equal("7015b5ec-7723-4560-bb96-85e18a947314"))
+			})
+
+			It("gets existing account by mail", func() {
+				value := "admin@example.org"
+				account, err := conn.GetAccountByClaim(context.Background(), "mail", value)
+				Expect(err).ToNot(HaveOccurred())
+				Expect(account).ToNot(BeNil())
+				Expect(account.ID).To(Equal(uint64(1)))
+				Expect(account.Email.String).To(Equal("admin@example.org"))
+				Expect(account.UserID).To(Equal("admin"))
+				Expect(account.DisplayName.String).To(Equal("admin"))
+				Expect(account.Quota.String).To(Equal("100 GB"))
+				Expect(account.LastLogin).To(Equal(1619082575))
+				Expect(account.Backend).To(Equal(`OC\User\Database`))
+				Expect(account.Home).To(Equal("/mnt/data/files/admin"))
+				Expect(account.State).To(Equal(int8(1)))
+				Expect(account.Username.String).To(Equal("Administrator"))
+				Expect(account.OwnCloudUUID.String).To(Equal("7015b5ec-7723-4560-bb96-85e18a947314"))
+			})
+
+			It("gets existing account by username", func() {
+				value := "Administrator"
+				account, err := conn.GetAccountByClaim(context.Background(), "username", value)
+				Expect(err).ToNot(HaveOccurred())
+				Expect(account).ToNot(BeNil())
+				Expect(account.ID).To(Equal(uint64(1)))
+				Expect(account.Email.String).To(Equal("admin@example.org"))
+				Expect(account.UserID).To(Equal("admin"))
+				Expect(account.DisplayName.String).To(Equal("admin"))
+				Expect(account.Quota.String).To(Equal("100 GB"))
+				Expect(account.LastLogin).To(Equal(1619082575))
+				Expect(account.Backend).To(Equal(`OC\User\Database`))
+				Expect(account.Home).To(Equal("/mnt/data/files/admin"))
+				Expect(account.State).To(Equal(int8(1)))
+				Expect(account.Username.String).To(Equal("Administrator"))
+				Expect(account.OwnCloudUUID.String).To(Equal("7015b5ec-7723-4560-bb96-85e18a947314"))
+			})
+
+			It("errors on unsupported claim", func() {
+				_, err := conn.GetAccountByClaim(context.Background(), "invalid", "invalid")
+				Expect(err).To(HaveOccurred())
+			})
+		})
+
+	})
+
+	Describe("FindAccounts", func() {
+
+		Context("with username and uuid joins", func() {
+
+			BeforeEach(func() {
+				var err error
+				conn, err = accounts.New("sqlite3", sqldb, true, true, false)
+				Expect(err).ToNot(HaveOccurred())
+			})
+
+			It("finds the existing admin account", func() {
+				accounts, err := conn.FindAccounts(context.Background(), "admin")
+				Expect(err).ToNot(HaveOccurred())
+				Expect(len(accounts)).To(Equal(1))
+				Expect(accounts[0]).ToNot(BeNil())
+				Expect(accounts[0].ID).To(Equal(uint64(1)))
+				Expect(accounts[0].Email.String).To(Equal("admin@example.org"))
+				Expect(accounts[0].UserID).To(Equal("admin"))
+				Expect(accounts[0].DisplayName.String).To(Equal("admin"))
+				Expect(accounts[0].Quota.String).To(Equal("100 GB"))
+				Expect(accounts[0].LastLogin).To(Equal(1619082575))
+				Expect(accounts[0].Backend).To(Equal(`OC\User\Database`))
+				Expect(accounts[0].Home).To(Equal("/mnt/data/files/admin"))
+				Expect(accounts[0].State).To(Equal(int8(1)))
+				Expect(accounts[0].Username.String).To(Equal("Administrator"))
+				Expect(accounts[0].OwnCloudUUID.String).To(Equal("7015b5ec-7723-4560-bb96-85e18a947314"))
+			})
+
+			It("handles query without results", func() {
+				accounts, err := conn.FindAccounts(context.Background(), "__notexisting__")
+				Expect(err).ToNot(HaveOccurred())
+				Expect(len(accounts)).To(Equal(0))
+			})
+		})
+
+		Context("with username joins", func() {
+
+			BeforeEach(func() {
+				var err error
+				conn, err = accounts.New("sqlite3", sqldb, true, false, false)
+				Expect(err).ToNot(HaveOccurred())
+			})
+
+			It("finds the existing admin account", func() {
+				accounts, err := conn.FindAccounts(context.Background(), "admin")
+				Expect(err).ToNot(HaveOccurred())
+				Expect(len(accounts)).To(Equal(1))
+				Expect(accounts[0]).ToNot(BeNil())
+				Expect(accounts[0].ID).To(Equal(uint64(1)))
+				Expect(accounts[0].Email.String).To(Equal("admin@example.org"))
+				Expect(accounts[0].UserID).To(Equal("admin"))
+				Expect(accounts[0].DisplayName.String).To(Equal("admin"))
+				Expect(accounts[0].Quota.String).To(Equal("100 GB"))
+				Expect(accounts[0].LastLogin).To(Equal(1619082575))
+				Expect(accounts[0].Backend).To(Equal(`OC\User\Database`))
+				Expect(accounts[0].Home).To(Equal("/mnt/data/files/admin"))
+				Expect(accounts[0].State).To(Equal(int8(1)))
+				Expect(accounts[0].Username.String).To(Equal("Administrator"))
+				Expect(accounts[0].OwnCloudUUID.String).To(Equal("admin"))
+			})
+
+			It("handles query without results", func() {
+				accounts, err := conn.FindAccounts(context.Background(), "__notexisting__")
+				Expect(err).ToNot(HaveOccurred())
+				Expect(len(accounts)).To(Equal(0))
+			})
+		})
+
+		Context("without any joins", func() {
+
+			BeforeEach(func() {
+				var err error
+				conn, err = accounts.New("sqlite3", sqldb, false, false, false)
+				Expect(err).ToNot(HaveOccurred())
+			})
+
+			It("finds the existing admin account", func() {
+				accounts, err := conn.FindAccounts(context.Background(), "admin")
+				Expect(err).ToNot(HaveOccurred())
+				Expect(len(accounts)).To(Equal(1))
+				Expect(accounts[0]).ToNot(BeNil())
+				Expect(accounts[0].ID).To(Equal(uint64(1)))
+				Expect(accounts[0].Email.String).To(Equal("admin@example.org"))
+				Expect(accounts[0].UserID).To(Equal("admin"))
+				Expect(accounts[0].DisplayName.String).To(Equal("admin"))
+				Expect(accounts[0].Quota.String).To(Equal("100 GB"))
+				Expect(accounts[0].LastLogin).To(Equal(1619082575))
+				Expect(accounts[0].Backend).To(Equal(`OC\User\Database`))
+				Expect(accounts[0].Home).To(Equal("/mnt/data/files/admin"))
+				Expect(accounts[0].State).To(Equal(int8(1)))
+				Expect(accounts[0].Username.String).To(Equal("admin"))
+				Expect(accounts[0].OwnCloudUUID.String).To(Equal("admin"))
+			})
+
+			It("handles query without results", func() {
+				accounts, err := conn.FindAccounts(context.Background(), "__notexisting__")
+				Expect(err).ToNot(HaveOccurred())
+				Expect(len(accounts)).To(Equal(0))
+			})
+		})
+	})
+
+	Describe("GetAccountGroups", func() {
+		BeforeEach(func() {
+			var err error
+			conn, err = accounts.New("sqlite3", sqldb, true, true, false)
+			Expect(err).ToNot(HaveOccurred())
+		})
+		It("get admin group for admin account", func() {
+			accounts, err := conn.GetAccountGroups(context.Background(), "admin")
+			Expect(err).ToNot(HaveOccurred())
+			Expect(len(accounts)).To(Equal(1))
+			Expect(accounts[0]).To(Equal("admin"))
+		})
+		It("handles not existing account", func() {
+			accounts, err := conn.GetAccountGroups(context.Background(), "__notexisting__")
+			Expect(err).ToNot(HaveOccurred())
+			Expect(len(accounts)).To(Equal(0))
+		})
+	})
+})
diff --git a/pkg/user/manager/owncloudsql/accounts/test.sqlite b/pkg/user/manager/owncloudsql/accounts/test.sqlite
new file mode 100644
index 0000000000000000000000000000000000000000..c68bb753774fc28eda95f71768f5d41d29830363
GIT binary patch
literal 90112
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

literal 0
HcmV?d00001

diff --git a/pkg/user/manager/owncloudsql/owncloudsql.go b/pkg/user/manager/owncloudsql/owncloudsql.go
new file mode 100644
index 0000000000..87eb5802f4
--- /dev/null
+++ b/pkg/user/manager/owncloudsql/owncloudsql.go
@@ -0,0 +1,180 @@
+// Copyright 2018-2021 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 owncloudsql
+
+import (
+	"context"
+	"database/sql"
+	"fmt"
+
+	userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
+	"github.com/cs3org/reva/pkg/appctx"
+	"github.com/cs3org/reva/pkg/errtypes"
+	"github.com/cs3org/reva/pkg/user"
+	"github.com/cs3org/reva/pkg/user/manager/owncloudsql/accounts"
+	"github.com/cs3org/reva/pkg/user/manager/registry"
+	"github.com/mitchellh/mapstructure"
+	"github.com/pkg/errors"
+
+	// Provides mysql drivers
+	_ "github.com/go-sql-driver/mysql"
+)
+
+func init() {
+	registry.Register("owncloudsql", NewMysql)
+}
+
+type manager struct {
+	c  *config
+	db *accounts.Accounts
+}
+
+type config struct {
+	DbUsername         string `mapstructure:"dbusername"`
+	DbPassword         string `mapstructure:"dbpassword"`
+	DbHost             string `mapstructure:"dbhost"`
+	DbPort             int    `mapstructure:"dbport"`
+	DbName             string `mapstructure:"dbname"`
+	Idp                string `mapstructure:"idp"`
+	Nobody             int64  `mapstructure:"nobody"`
+	JoinUsername       bool   `mapstructure:"join_username"`
+	JoinOwnCloudUUID   bool   `mapstructure:"join_ownclouduuid"`
+	EnableMedialSearch bool   `mapstructure:"enable_medial_search"`
+}
+
+// NewMysql returns a new user manager connection to an owncloud mysql database
+func NewMysql(m map[string]interface{}) (user.Manager, error) {
+	mgr := &manager{}
+	err := mgr.Configure(m)
+	if err != nil {
+		err = errors.Wrap(err, "error creating a new manager")
+		return nil, err
+	}
+
+	mgr.db, err = accounts.NewMysql(
+		fmt.Sprintf("%s:%s@tcp(%s:%d)/%s", mgr.c.DbUsername, mgr.c.DbPassword, mgr.c.DbHost, mgr.c.DbPort, mgr.c.DbName),
+		mgr.c.JoinUsername,
+		mgr.c.JoinOwnCloudUUID,
+		mgr.c.EnableMedialSearch,
+	)
+	if err != nil {
+		return nil, err
+	}
+
+	return mgr, nil
+}
+
+func (m *manager) Configure(ml map[string]interface{}) error {
+	c, err := parseConfig(ml)
+	if err != nil {
+		return err
+	}
+
+	if c.Nobody == 0 {
+		c.Nobody = 99
+	}
+
+	m.c = c
+	return nil
+}
+
+func parseConfig(m map[string]interface{}) (*config, error) {
+	c := &config{}
+	if err := mapstructure.Decode(m, &c); err != nil {
+		return nil, err
+	}
+	return c, nil
+}
+
+func (m *manager) GetUser(ctx context.Context, uid *userpb.UserId) (*userpb.User, error) {
+	// search via the user_id
+	a, err := m.db.GetAccountByClaim(ctx, "userid", uid.OpaqueId)
+	if err == sql.ErrNoRows {
+		return nil, errtypes.NotFound(uid.OpaqueId)
+	}
+	return m.convertToCS3User(ctx, a)
+}
+
+func (m *manager) GetUserByClaim(ctx context.Context, claim, value string) (*userpb.User, error) {
+	a, err := m.db.GetAccountByClaim(ctx, claim, value)
+	if err == sql.ErrNoRows {
+		return nil, errtypes.NotFound(claim + "=" + value)
+	} else if err != nil {
+		return nil, err
+	}
+	return m.convertToCS3User(ctx, a)
+}
+
+func (m *manager) FindUsers(ctx context.Context, query string) ([]*userpb.User, error) {
+
+	accounts, err := m.db.FindAccounts(ctx, query)
+	if err == sql.ErrNoRows {
+		return nil, errtypes.NotFound("no users found for " + query)
+	} else if err != nil {
+		return nil, err
+	}
+
+	users := []*userpb.User{}
+	for i := range accounts {
+		u, err := m.convertToCS3User(ctx, &accounts[i])
+		if err != nil {
+			appctx.GetLogger(ctx).Error().Err(err).Interface("account", accounts[i]).Msg("could not convert account, skipping")
+			continue
+		}
+		users = append(users, u)
+	}
+
+	return users, nil
+}
+
+func (m *manager) GetUserGroups(ctx context.Context, uid *userpb.UserId) ([]string, error) {
+	groups, err := m.db.GetAccountGroups(ctx, uid.OpaqueId)
+	if err == sql.ErrNoRows {
+		return nil, errtypes.NotFound("no groups found for uid " + uid.OpaqueId)
+	} else if err != nil {
+		return nil, err
+	}
+	return groups, nil
+}
+
+func (m *manager) convertToCS3User(ctx context.Context, a *accounts.Account) (*userpb.User, error) {
+	u := &userpb.User{
+		Id: &userpb.UserId{
+			Idp:      m.c.Idp,
+			OpaqueId: a.UserID, // TODO rea
+		},
+		Username:    a.Username.String,
+		Mail:        a.Email.String,
+		DisplayName: a.DisplayName.String,
+		//Groups:      groups,
+		GidNumber: m.c.Nobody,
+		UidNumber: m.c.Nobody,
+	}
+	if u.Username == "" {
+		u.Username = u.Id.OpaqueId
+	}
+	if u.DisplayName == "" {
+		u.DisplayName = u.Id.OpaqueId
+	}
+	var err error
+	if u.Groups, err = m.GetUserGroups(ctx, u.Id); err != nil {
+		return nil, err
+	}
+	return u, nil
+}