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 +}