// Copyright (c) 2020, Andreas Schneider // All rights reserved. // // Redistribution and use in source and binary forms, with or without // modification, are permitted provided that the following conditions are met: // * Redistributions of source code must retain the above copyright // notice, this list of conditions and the following disclaimer. // * Redistributions in binary form must reproduce the above copyright // notice, this list of conditions and the following disclaimer in the // documentation and/or other materials provided with the distribution. // * Neither the name of the nor the // names of its contributors may be used to endorse or promote products // derived from this software without specific prior written permission. // // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND // ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED // WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE // DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY // DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES // (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; // LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND // ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS // SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. package main import ( "encoding/json" "errors" "fmt" "strings" uuid "github.com/satori/go.uuid" "github.com/tidwall/buntdb" ) type UserStore interface { AddUser(user User) (err error) GetUser(username string) (user User, err error) GetUsers() ([]User, error) UpdateUser(user User) error RemoveUser(username string) (err error) } type ShareStore interface { CreateShare() (Share, error) UpdateShareAttributes(share Share) error RemoveShare(id uuid.UUID) error AddUserToShare(share Share, username string, role ShareRole) error RemoveUserFromShare(share Share, username string) error AddLogin(share Share, username string, login Login) error RemoveLogin(share Share, username string, loginName string) error GetShares() ([]Share, error) FindShareByLogin(username, loginName string) (LoginShare, error) FindSharesByUser(username string) ([]UserShare, error) } var _ UserStore = (*DBStore)(nil) var _ ShareStore = (*DBStore)(nil) type GlobalRole string const ( GlobalRoleUser GlobalRole = "user" GlobalRoleAdmin GlobalRole = "admin" ) type ShareRole string const ( ShareRoleReader ShareRole = "reader" ShareRoleWriter ShareRole = "writer" ShareRoleAdmin ShareRole = "admin" ) type User struct { Username string Password string Role GlobalRole } type Share struct { UUID uuid.UUID Name string Description string } type ShareUser struct { Username string Role ShareRole } type Login struct { LoginName string Password string ReadOnly bool } // View on a share from the perspective of a user of that share. type UserShare struct { Share Role ShareRole } // View on a share from the perspective of a specific login (with associated user). type LoginShare struct { Share ShareUser Login } const userPrefix = "user:" const sharePrefix = "share:" const loginSharePrefix = "loginshare:" const shareuserPrefix = "shareuser:" const shareloginPrefix = "sharelogin:" type DBStore struct { db *buntdb.DB } func (u User) key() string { return userPrefix + u.Username } func (s Share) key() string { return sharePrefix + s.UUID.String() } func (s Share) userKey(username string) string { return fmt.Sprintf("%s%s:%s", shareuserPrefix, s.UUID.String(), username) } func (s Share) loginKey(username, loginName string) string { return fmt.Sprintf("%s%s:%s:%s", shareloginPrefix, s.UUID.String(), username, loginName) } func NewDBStore(filename string) (*DBStore, error) { db, err := buntdb.Open(filename) if err != nil { return nil, err } return &DBStore{db}, nil } func (store *DBStore) Close() error { if err := store.db.Shrink(); err != nil { return err } return store.db.Close() } var ErrUserExists = errors.New("user already exists") var ErrUserNotFound = errors.New("user not found") var ErrInvalidUsername = errors.New("invalid username") func (u User) merge(updates User) (User, error) { merged := u if updates.Password != "" { merged.Password = updates.Password } if updates.Role != "" { merged.Role = updates.Role } return merged, nil } var ErrShareNotFound = errors.New("share not found") var ErrLoginNotFound = errors.New("login not found") var ErrLoginDuplicate = errors.New("login already exists") func (store *DBStore) AddUser(user User) (err error) { if strings.Contains(user.Username, ":") { return ErrInvalidUsername } if err = store.db.Update(func(tx *buntdb.Tx) error { userBytes, err := json.Marshal(user) if err != nil { return fmt.Errorf("cannot marshal user: %w", err) } if _, exists, err := tx.Set(user.key(), string(userBytes), nil); err != nil { return err } else if exists { return ErrUserExists } return nil }); err != nil { return err } return nil } func (store *DBStore) GetUser(username string) (user User, err error) { if strings.Contains(username, ":") { return user, ErrInvalidUsername } if err := store.db.View(func(tx *buntdb.Tx) error { user.Username = username if val, err := tx.Get(user.key()); err != nil && err != buntdb.ErrNotFound { return err } else if err == buntdb.ErrNotFound { return ErrUserNotFound } else if err := json.Unmarshal([]byte(val), &user); err != nil { return fmt.Errorf("cannot unmarshal user: %w", err) } return nil }); err != nil { return user, err } return user, nil } func (store *DBStore) GetUsers() (users []User, err error) { err = store.db.View(func(tx *buntdb.Tx) error { var processingError error if err := tx.AscendKeys(userPrefix+"*", func(key, value string) bool { var user User if err := json.Unmarshal([]byte(value), &user); err != nil { processingError = err return false } // Just in case ... user.Username = strings.TrimPrefix(key, userPrefix) users = append(users, user) return true }); err != nil { return err } return processingError }) return users, err } func (store *DBStore) UpdateUser(user User) error { if strings.Contains(user.Username, ":") { return ErrInvalidUsername } return store.db.Update(func(tx *buntdb.Tx) error { var existingUser User if val, err := tx.Get(user.key()); err != nil && err != buntdb.ErrNotFound { return err } else if err == buntdb.ErrNotFound { return ErrUserNotFound } else if err := json.Unmarshal([]byte(val), &existingUser); err != nil { return fmt.Errorf("cannot unmarshal user: %w", err) } mergedUser, err := existingUser.merge(user) if err != nil { return fmt.Errorf("cannot merge user: %w", err) } mergedUser.Username = user.Username userBytes, err := json.Marshal(mergedUser) if err != nil { return fmt.Errorf("cannot marshal user: %w", err) } _, _, err = tx.Set(mergedUser.key(), string(userBytes), nil) return err }) } func (store *DBStore) RemoveUser(username string) (err error) { if strings.Contains(username, ":") { return ErrInvalidUsername } return store.db.Update(func(tx *buntdb.Tx) error { user := User{Username: username} if _, err := tx.Delete(user.key()); err == buntdb.ErrNotFound { return ErrUserNotFound } else if err != nil { return err } return nil }) } func (store *DBStore) CreateShare() (Share, error) { share := Share{ UUID: uuid.NewV4(), } if err := store.db.Update(func(tx *buntdb.Tx) error { b, err := json.Marshal(share) if err != nil { return fmt.Errorf("cannot marshal share: %w", err) } if _, _, err := tx.Set(share.key(), string(b), nil); err != nil { return err } return nil }); err != nil { return Share{}, err } return share, nil } func (store *DBStore) UpdateShareAttributes(share Share) error { return store.db.Update(func(tx *buntdb.Tx) error { var existingShare Share if val, err := tx.Get(share.key()); err == buntdb.ErrNotFound { return ErrShareNotFound } else if err != nil { return err } else if err := json.Unmarshal([]byte(val), &existingShare); err != nil { return fmt.Errorf("cannot unmarshal share %s: %w", share.UUID, err) } existingShare.UUID = share.UUID existingShare.Name = share.Name existingShare.Description = share.Description if b, err := json.Marshal(existingShare); err != nil { return fmt.Errorf("cannot marshal share %s: %w", share.UUID, err) } else if _, _, err := tx.Set(existingShare.key(), string(b), nil); err != nil { return err } return nil }) } func (store *DBStore) RemoveShare(id uuid.UUID) error { return store.db.Update(func(tx *buntdb.Tx) error { share := Share{UUID: id} var userNames []string usersPrefix := share.userKey("") if err := tx.AscendKeys(usersPrefix+"*", func(key, value string) bool { userNames = append(userNames, strings.TrimPrefix(key, usersPrefix)) return true }); err != nil { return err } for _, username := range userNames { if err := store.removeUserFromShare(tx, share, username); err != nil { return fmt.Errorf("cannot remove user %q from share: %w", username, err) } } if _, err := tx.Delete(share.key()); err == buntdb.ErrNotFound { return ErrShareNotFound } else { return err } }) } func (store *DBStore) AddUserToShare(share Share, username string, role ShareRole) error { if strings.Contains(username, ":") { return ErrInvalidUsername } return store.db.Update(func(tx *buntdb.Tx) error { if _, err := tx.Get(share.key()); err != nil { return ErrShareNotFound } if _, _, err := tx.Set(share.userKey(username), string(role), nil); err != nil { return fmt.Errorf("cannot set user: %w", err) } return nil }) } func (store *DBStore) removeUserFromShare(tx *buntdb.Tx, share Share, username string) error { var logins []string loginsPrefix := share.loginKey(username, "") if err := tx.AscendKeys(loginsPrefix+"*", func(key, value string) bool { logins = append(logins, strings.TrimPrefix(key, loginsPrefix)) return true }); err != nil { return err } for _, loginName := range logins { shareIdString, err := tx.Delete(loginSharePrefix + username + ":" + loginName) if err != nil { return fmt.Errorf("cannot remove login ref: %w", err) } else if shareIdString != share.UUID.String() { return fmt.Errorf("inconsistent login ref %q", username+":"+loginName) } if _, err := tx.Delete(share.loginKey(username, loginName)); err != nil { return fmt.Errorf("cannot remove login %q: %w", loginName, err) } } if _, err := tx.Delete(share.userKey(username)); err != nil { return fmt.Errorf("cannot remove user: %w", err) } return nil } func (store *DBStore) RemoveUserFromShare(share Share, username string) error { return store.db.Update(func(tx *buntdb.Tx) error { return store.removeUserFromShare(tx, share, username) }) } func (store *DBStore) AddLogin(share Share, username string, login Login) error { if strings.Contains(username, ":") { return ErrInvalidUsername } return store.db.Update(func(tx *buntdb.Tx) error { // Validate model. Share should exist and the user should already be assigned. if _, err := tx.Get(share.key()); err != nil { return ErrShareNotFound } if _, err := tx.Get(share.userKey(username)); err != nil { return ErrUserNotFound } // Keep a direct reference from username:login to the share, so we can later on // easily validate login attempts. Also this is an easy approach to check if the // username:login pair is unique. Otherwise our login scheme wouldn't work the // way we expect. // If the existing value happens to be our shareId already, we assume we are just // updating the existing model. No harm there. if existingValue, exists, err := tx.Set(loginSharePrefix+username+":"+login.LoginName, share.UUID.String(), nil); err != nil { return fmt.Errorf("cannot set login reference: %w", err) } else if exists && existingValue != share.UUID.String() { return ErrLoginDuplicate } // Now simply update the current login information. b, err := json.Marshal(login) if err != nil { return fmt.Errorf("cannot marshal login: %w", err) } if _, _, err := tx.Set(share.loginKey(username, login.LoginName), string(b), nil); err != nil { return fmt.Errorf("cannot set login: %w", err) } return nil }) } func (store *DBStore) RemoveLogin(share Share, username string, loginName string) error { return store.db.Update(func(tx *buntdb.Tx) error { if _, err := tx.Get(share.key()); err != nil { return ErrShareNotFound } if shareId, err := tx.Delete(loginSharePrefix + username + ":" + loginName); err != nil { return ErrLoginNotFound } else if shareId != share.UUID.String() { return ErrLoginNotFound } if _, err := tx.Delete(share.loginKey(username, loginName)); err != nil { return ErrLoginNotFound } return nil }) } func (store *DBStore) GetShares() (shares []Share, err error) { err = store.db.View(func(tx *buntdb.Tx) error { var processingError error if err := tx.AscendKeys(sharePrefix+"*", func(key, value string) bool { idString := strings.TrimPrefix(key, sharePrefix) share, err := unmarshalShare(idString, value) if err != nil { processingError = err return false } shares = append(shares, share) return true }); err != nil { return err } return processingError }) return shares, err } func (store *DBStore) FindShareByLogin(username, loginName string) (loginShare LoginShare, err error) { err = store.db.View(func(tx *buntdb.Tx) error { shareIdString, err := tx.Get(loginSharePrefix + username + ":" + loginName) if err == buntdb.ErrNotFound { return ErrShareNotFound } else if err != nil { return err } sharePayload, err := tx.Get(sharePrefix + shareIdString) if err == buntdb.ErrNotFound { // TODO warn about inconsistency return ErrShareNotFound } else if err != nil { return err } share, err := unmarshalShare(shareIdString, sharePayload) if err != nil { return err } userRole, err := tx.Get(share.userKey(username)) if err == buntdb.ErrNotFound { // TODO warn about inconsistency return ErrShareNotFound } else if err != nil { return err } loginPayload, err := tx.Get(share.loginKey(username, loginName)) if err == buntdb.ErrNotFound { // TODO warn about inconsistency return ErrShareNotFound } else if err != nil { return err } loginShare.Share = share loginShare.ShareUser = ShareUser{ Username: username, Role: ShareRole(userRole), } if err := json.Unmarshal([]byte(loginPayload), &loginShare.Login); err != nil { return fmt.Errorf("cannot unmarshal login: %w", err) } return nil }) return } func (store *DBStore) FindSharesByUser(username string) ([]UserShare, error) { panic("implement me") } func unmarshalShare(idString, payload string) (Share, error) { var share Share if err := json.Unmarshal([]byte(payload), &share); err != nil { return Share{}, fmt.Errorf("cannot unmarshal share: %w", err) } // Just in case ... if id, err := uuid.FromString(idString); err != nil { return Share{}, fmt.Errorf("invalid uuid in db: %q: %w", idString, err) } else { share.UUID = id } return share, nil }