auth: groups and memberships (#97)
* group membership unpolished
* fixed index.html
* fixed undefined variable errors
* Implemented adding a user to a group.
* fixed variable names, moved permission check into handler, separated fanctionality for adding ownership or membership for user
* minor changes: Gave variables consistent names
* separated tables for owners and members. some clean code fixes. added group description into group page.
* added to creat child group. minor fixes
* added yaml files
* added cue file
* moved groupOwnership check separatly. redo conditionals into oneline. separated status string check.
* added mempership into app.go infraAppConfigs
* changed svg icon. fixed indentation
* svg icon fix
* added transaction
* minor owner add fix
* added multiple db rollbacks
---------
Co-authored-by: Giorgi Lekveishvili <lekva@gl-mbp-m1-max.local>
diff --git a/core/auth/memberships/main.go b/core/auth/memberships/main.go
new file mode 100644
index 0000000..3bb0948
--- /dev/null
+++ b/core/auth/memberships/main.go
@@ -0,0 +1,537 @@
+package main
+
+import (
+ "database/sql"
+ "embed"
+ "flag"
+ "fmt"
+ "html/template"
+ "log"
+ "net/http"
+ "strings"
+
+ "github.com/ncruces/go-sqlite3"
+ _ "github.com/ncruces/go-sqlite3/driver"
+ _ "github.com/ncruces/go-sqlite3/embed"
+)
+
+var port = flag.Int("port", 8080, "ort to listen on")
+var dbPath = flag.String("db-path", "memberships.db", "Path to SQLite file")
+
+//go:embed index.html
+var indexHTML string
+
+//go:embed group.html
+var groupHTML string
+
+//go:embed static
+var staticResources embed.FS
+
+type Store interface {
+ CreateGroup(owner string, group Group) error
+ AddChildGroup(parent, child string) error
+ GetGroupsOwnedBy(user string) ([]Group, error)
+ GetMembershipGroups(user string) ([]Group, error)
+ IsGroupOwner(user, group string) (bool, error)
+ AddGroupMember(user, group string) error
+ AddGroupOwner(user, group string) error
+ GetGroupOwners(group string) ([]string, error)
+ GetGroupMembers(group string) ([]string, error)
+ GetGroupDescription(group string) (string, error)
+ GetAvailableGroupsAsChild(group string) ([]string, error)
+}
+
+type Server struct {
+ store Store
+}
+
+type Group struct {
+ Name string
+ Description string
+}
+
+type SQLiteStore struct {
+ db *sql.DB
+}
+
+func NewSQLiteStore(path string) (*SQLiteStore, error) {
+ db, err := sql.Open("sqlite3", path)
+ if err != nil {
+ return nil, err
+ }
+ _, err = db.Exec(`
+ CREATE TABLE IF NOT EXISTS groups (
+ name TEXT PRIMARY KEY,
+ description TEXT
+ );
+
+ CREATE TABLE IF NOT EXISTS owners (
+ username TEXT,
+ group_name TEXT,
+ FOREIGN KEY(group_name) REFERENCES groups(name)
+ );
+
+ CREATE TABLE IF NOT EXISTS group_to_group (
+ parent_group TEXT,
+ child_group TEXT,
+ FOREIGN KEY(parent_group) REFERENCES groups(name),
+ FOREIGN KEY(child_group) REFERENCES groups(name)
+ );
+
+ CREATE TABLE IF NOT EXISTS user_to_group (
+ username TEXT,
+ group_name TEXT,
+ FOREIGN KEY(group_name) REFERENCES groups(name)
+ );`)
+ if err != nil {
+ return nil, err
+ }
+ return &SQLiteStore{db: db}, nil
+}
+
+func (s *SQLiteStore) queryGroups(query string, args ...interface{}) ([]Group, error) {
+ groups := make([]Group, 0)
+ rows, err := s.db.Query(query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var group Group
+ if err := rows.Scan(&group.Name, &group.Description); err != nil {
+ return nil, err
+ }
+ groups = append(groups, group)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return groups, nil
+}
+
+func (s *SQLiteStore) GetGroupsOwnedBy(user string) ([]Group, error) {
+ query := `
+ SELECT groups.name, groups.description
+ FROM groups
+ JOIN owners ON groups.name = owners.group_name
+ WHERE owners.username = ?`
+ return s.queryGroups(query, user)
+}
+
+func (s *SQLiteStore) GetMembershipGroups(user string) ([]Group, error) {
+ query := `
+ SELECT groups.name, groups.description
+ FROM groups
+ JOIN user_to_group ON groups.name = user_to_group.group_name
+ WHERE user_to_group.username = ?`
+ return s.queryGroups(query, user)
+}
+
+func (s *SQLiteStore) CreateGroup(owner string, group Group) error {
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ query := `INSERT INTO groups (name, description) VALUES (?, ?)`
+ if _, err := tx.Exec(query, group.Name, group.Description); err != nil {
+ sqliteErr, ok := err.(*sqlite3.Error)
+ if ok && sqliteErr.ExtendedCode() == 1555 {
+ return fmt.Errorf("Group with the name %s already exists", group.Name)
+ }
+ return err
+ }
+ query = `INSERT INTO owners (username, group_name) VALUES (?, ?)`
+ if _, err := tx.Exec(query, owner, group.Name); err != nil {
+ return err
+ }
+ if err := tx.Commit(); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (s *SQLiteStore) IsGroupOwner(user, group string) (bool, error) {
+ query := `
+ SELECT EXISTS (
+ SELECT 1
+ FROM owners
+ WHERE username = ? AND group_name = ?
+ )`
+ var exists bool
+ if err := s.db.QueryRow(query, user, group).Scan(&exists); err != nil {
+ return false, err
+ }
+ return exists, nil
+}
+
+func (s *SQLiteStore) userGroupPairExists(tx *sql.Tx, table, user, group string) (bool, error) {
+ query := fmt.Sprintf("SELECT EXISTS (SELECT 1 FROM %s WHERE username = ? AND group_name = ?)", table)
+ var exists bool
+ if err := tx.QueryRow(query, user, group).Scan(&exists); err != nil {
+ return false, err
+ }
+ return exists, nil
+}
+
+func (s *SQLiteStore) AddGroupMember(user, group string) error {
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ existsInUserToGroup, err := s.userGroupPairExists(tx, "user_to_group", user, group)
+ if err != nil {
+ return err
+ }
+ if existsInUserToGroup {
+ return fmt.Errorf("%s is already a member of group %s", user, group)
+ }
+ if _, err := tx.Exec(`INSERT INTO user_to_group (username, group_name) VALUES (?, ?)`, user, group); err != nil {
+ return err
+ }
+ if err := tx.Commit(); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (s *SQLiteStore) AddGroupOwner(user, group string) error {
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ existsInOwners, err := s.userGroupPairExists(tx, "owners", user, group)
+ if err != nil {
+ return err
+ }
+ if existsInOwners {
+ return fmt.Errorf("%s is already an owner of group %s", user, group)
+ }
+ if _, err = tx.Exec(`INSERT INTO owners (username, group_name) VALUES (?, ?)`, user, group); err != nil {
+ return err
+ }
+ if err := tx.Commit(); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (s *SQLiteStore) getUsersByGroup(table, group string) ([]string, error) {
+ query := fmt.Sprintf("SELECT username FROM %s WHERE group_name = ?", table)
+ rows, err := s.db.Query(query, group)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var users []string
+ for rows.Next() {
+ var username string
+ if err := rows.Scan(&username); err != nil {
+ return nil, err
+ }
+ users = append(users, username)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return users, nil
+}
+
+func (s *SQLiteStore) GetGroupOwners(group string) ([]string, error) {
+ return s.getUsersByGroup("owners", group)
+}
+
+func (s *SQLiteStore) GetGroupMembers(group string) ([]string, error) {
+ return s.getUsersByGroup("user_to_group", group)
+}
+
+func (s *SQLiteStore) GetGroupDescription(group string) (string, error) {
+ var description string
+ query := `SELECT description FROM groups WHERE name = ?`
+ if err := s.db.QueryRow(query, group).Scan(&description); err != nil {
+ return "", err
+ }
+ return description, nil
+}
+
+func (s *SQLiteStore) parentChildGroupPairExists(tx *sql.Tx, parent, child string) (bool, error) {
+ query := `SELECT EXISTS (SELECT 1 FROM group_to_group WHERE parent_group = ? AND child_group = ?)`
+ var exists bool
+ if err := tx.QueryRow(query, parent, child).Scan(&exists); err != nil {
+ return false, err
+ }
+ return exists, nil
+}
+
+func (s *SQLiteStore) AddChildGroup(parent, child string) error {
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ existsInGroupToGroup, err := s.parentChildGroupPairExists(tx, parent, child)
+ if err != nil {
+ return err
+ }
+ if existsInGroupToGroup {
+ return fmt.Errorf("child group name %s already exists in group %s", child, parent)
+ }
+ if _, err := tx.Exec(`INSERT INTO group_to_group (parent_group, child_group) VALUES (?, ?)`, parent, child); err != nil {
+ return err
+ }
+ if err := tx.Commit(); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (s *SQLiteStore) GetAvailableGroupsAsChild(group string) ([]string, error) {
+ // TODO(dtabidze): Might have to add further logic to filter available groups as children.
+ query := `
+ SELECT name FROM groups
+ WHERE name != ? AND name NOT IN (
+ SELECT child_group FROM group_to_group WHERE parent_group = ?
+ )
+ `
+ rows, err := s.db.Query(query, group, group)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var availableGroups []string
+ for rows.Next() {
+ var groupName string
+ if err := rows.Scan(&groupName); err != nil {
+ return nil, err
+ }
+ availableGroups = append(availableGroups, groupName)
+ }
+ return availableGroups, nil
+}
+
+func getLoggedInUser(r *http.Request) (string, error) {
+ // TODO(dtabidze): should make a request to get loggedin user
+ return "tabo", nil
+}
+
+type Status int
+
+const (
+ Owner Status = iota
+ Member
+)
+
+func convertStatus(status string) (Status, error) {
+ switch status {
+ case "Owner":
+ return Owner, nil
+ case "Member":
+ return Member, nil
+ default:
+ return Owner, fmt.Errorf("invalid status: %s", status)
+ }
+}
+
+func (s *Server) Start() {
+ http.Handle("/static/", http.FileServer(http.FS(staticResources)))
+ http.HandleFunc("/", s.homePageHandler)
+ http.HandleFunc("/group/", s.groupHandler)
+ http.HandleFunc("/create-group", s.createGroupHandler)
+ http.HandleFunc("/add-user", s.addUserHandler)
+ http.HandleFunc("/add-child-group", s.addChildGroupHandler)
+ log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), nil))
+}
+
+type GroupData struct {
+ Group Group
+ Membership string
+}
+
+func (s *Server) checkIsOwner(w http.ResponseWriter, user, group string) (bool, error) {
+ isOwner, err := s.store.IsGroupOwner(user, group)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return false, err
+ }
+ if !isOwner {
+ http.Error(w, fmt.Sprintf("You are not the owner of the group %s", group), http.StatusUnauthorized)
+ return false, nil
+ }
+ return true, nil
+}
+
+func (s *Server) homePageHandler(w http.ResponseWriter, r *http.Request) {
+ loggedInUser, err := getLoggedInUser(r)
+ if err != nil {
+ http.Error(w, "User Not Logged In", http.StatusUnauthorized)
+ return
+ }
+ ownerGroups, err := s.store.GetGroupsOwnedBy(loggedInUser)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ membershipGroups, err := s.store.GetMembershipGroups(loggedInUser)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ tmpl, err := template.New("index").Parse(indexHTML)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ data := struct {
+ OwnerGroups []Group
+ MembershipGroups []Group
+ }{
+ OwnerGroups: ownerGroups,
+ MembershipGroups: membershipGroups,
+ }
+ w.Header().Set("Content-Type", "text/html")
+ if err := tmpl.Execute(w, data); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+}
+
+func (s *Server) createGroupHandler(w http.ResponseWriter, r *http.Request) {
+ loggedInUser, err := getLoggedInUser(r)
+ if err != nil {
+ http.Error(w, "User Not Logged In", http.StatusUnauthorized)
+ return
+ }
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ var group Group
+ group.Name = r.PostFormValue("group-name")
+ group.Description = r.PostFormValue("description")
+ if err := s.store.CreateGroup(loggedInUser, group); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+}
+
+func (s *Server) groupHandler(w http.ResponseWriter, r *http.Request) {
+ groupName := strings.TrimPrefix(r.URL.Path, "/group/")
+ tmpl, err := template.New("group").Parse(groupHTML)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ owners, err := s.store.GetGroupOwners(groupName)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ members, err := s.store.GetGroupMembers(groupName)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ description, err := s.store.GetGroupDescription(groupName)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ availableGroups, err := s.store.GetAvailableGroupsAsChild(groupName)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ data := struct {
+ GroupName string
+ Description string
+ Owners []string
+ Members []string
+ AvailableGroups []string
+ }{
+ GroupName: groupName,
+ Description: description,
+ Owners: owners,
+ Members: members,
+ AvailableGroups: availableGroups,
+ }
+ if err := tmpl.Execute(w, data); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+}
+
+func (s *Server) addUserHandler(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ loggedInUser, err := getLoggedInUser(r)
+ if err != nil {
+ http.Error(w, "User Not Logged In", http.StatusUnauthorized)
+ return
+ }
+ groupName := r.FormValue("group")
+ username := r.FormValue("username")
+ status, err := convertStatus(r.FormValue("status"))
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ if _, err := s.checkIsOwner(w, loggedInUser, groupName); err != nil {
+ return
+ }
+ switch status {
+ case Owner:
+ err = s.store.AddGroupOwner(username, groupName)
+ case Member:
+ err = s.store.AddGroupMember(username, groupName)
+ default:
+ http.Error(w, "Invalid status", http.StatusBadRequest)
+ return
+ }
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ http.Redirect(w, r, "/group/"+groupName, http.StatusSeeOther)
+}
+
+func (s *Server) addChildGroupHandler(w http.ResponseWriter, r *http.Request) {
+ // TODO(dtabidze): In future we might need to make one group OWNER of another and not just a member.
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ loggedInUser, err := getLoggedInUser(r)
+ if err != nil {
+ http.Error(w, "User Not Logged In", http.StatusUnauthorized)
+ return
+ }
+ parentGroup := r.FormValue("parent-group")
+ childGroup := r.FormValue("child-group")
+ if _, err := s.checkIsOwner(w, loggedInUser, parentGroup); err != nil {
+ return
+ }
+ if err := s.store.AddChildGroup(parentGroup, childGroup); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ http.Redirect(w, r, "/group/"+parentGroup, http.StatusSeeOther)
+}
+
+func main() {
+ flag.Parse()
+ db, err := NewSQLiteStore(*dbPath)
+ if err != nil {
+ panic(err)
+ }
+ s := Server{store: db}
+ s.Start()
+}