Memberships: API to add and retrieve user information
             Create user
             Add SSH key
             Ping clients on mutations

Change-Id: I02799b8b4bc88813afeb306cc122a7eaa4496c3c
diff --git a/Jenkinsfile b/Jenkinsfile
index a99c3eb..375983c 100644
--- a/Jenkinsfile
+++ b/Jenkinsfile
@@ -13,7 +13,7 @@
 		}
     }
     stages {
-        stage('/core/installer') {
+        stage('installer auth') {
             steps {
 				container('golang') {
                 	dir('core/installer') {
@@ -21,6 +21,11 @@
                 		sh 'go build cmd/*.go'
                 		sh 'go test ./...'
 					}
+                    dir('core/auth/memberships') {
+                        sh 'go mod tidy'
+                		sh 'go build *.go'
+                		sh 'go test ./...'
+					}
 				}
             }
         }
diff --git a/core/auth/memberships/Dockerfile b/core/auth/memberships/Dockerfile
index 37e975d..fad7422 100644
--- a/core/auth/memberships/Dockerfile
+++ b/core/auth/memberships/Dockerfile
@@ -1,4 +1,5 @@
-FROM gcr.io/distroless/static:nonroot
+# FROM gcr.io/distroless/static:nonroot
+FROM alpine:latest
 
 ARG TARGETARCH
 
diff --git a/core/auth/memberships/main.go b/core/auth/memberships/main.go
index 808d5a8..88729a2 100644
--- a/core/auth/memberships/main.go
+++ b/core/auth/memberships/main.go
@@ -12,6 +12,7 @@
 	"net/url"
 	"regexp"
 	"strings"
+	"sync"
 
 	"github.com/ncruces/go-sqlite3"
 	_ "github.com/ncruces/go-sqlite3/driver"
@@ -54,10 +55,17 @@
 	RemoveFromGroupToGroup(parent, child string) error
 	RemoveUserFromTable(username, groupName, tableName string) error
 	GetAllGroups() ([]Group, error)
+	GetAllUsers() ([]User, error)
+	GetUser(username string) (User, error)
+	AddSSHKeyForUser(username, sshKey string) error
+	RemoveSSHKeyForUser(username, sshKey string) error
+	CreateUser(user, email string) error
 }
 
 type Server struct {
-	store Store
+	store         Store
+	syncAddresses map[string]struct{}
+	mu            sync.Mutex
 }
 
 type Group struct {
@@ -65,6 +73,12 @@
 	Description string
 }
 
+type User struct {
+	Username      string   `json:"username"`
+	Email         string   `json:"email"`
+	SSHPublicKeys []string `json:"sshPublicKeys,omitempty"`
+}
+
 type SQLiteStore struct {
 	db *sql.DB
 }
@@ -105,6 +119,17 @@
 			group_name TEXT,
 			FOREIGN KEY(group_name) REFERENCES groups(name),
 			UNIQUE (username, group_name)
+		);
+		CREATE TABLE IF NOT EXISTS users (
+			username TEXT PRIMARY KEY,
+			email TEXT,
+			UNIQUE (email)
+		);
+		CREATE TABLE IF NOT EXISTS user_ssh_keys (
+			username TEXT,
+			ssh_key TEXT,
+			UNIQUE (ssh_key),
+			FOREIGN KEY(username) REFERENCES users(username)
 		);`)
 	if err != nil {
 		return nil, err
@@ -513,13 +538,110 @@
 	return s.queryGroups(query)
 }
 
+func (s *SQLiteStore) AddSSHKeyForUser(username, sshKey string) error {
+	_, err := s.db.Exec(`INSERT INTO user_ssh_keys (username, ssh_key) VALUES (?, ?)`, username, sshKey)
+	if err != nil {
+		sqliteErr, ok := err.(*sqlite3.Error)
+		if ok && sqliteErr.ExtendedCode() == ErrorUniqueConstraintViolation {
+			return fmt.Errorf("%s such SSH public key already exists", sshKey)
+		}
+		return err
+	}
+	return nil
+}
+
+func (s *SQLiteStore) RemoveSSHKeyForUser(username, sshKey string) error {
+	_, err := s.db.Exec(`DELETE FROM user_ssh_keys WHERE username = ? AND ssh_key = ?`, username, sshKey)
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+func (s *SQLiteStore) GetAllUsers() ([]User, error) {
+	rows, err := s.db.Query(`
+		SELECT users.username, users.email, GROUP_CONCAT(user_ssh_keys.ssh_key, ',')
+		FROM users
+		LEFT JOIN user_ssh_keys ON users.username = user_ssh_keys.username
+		GROUP BY users.username
+	`)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var userInfos []User
+	for rows.Next() {
+		var username, email string
+		var sshKeys sql.NullString
+		if err := rows.Scan(&username, &email, &sshKeys); err != nil {
+			return nil, err
+		}
+		user := User{
+			Username: username,
+			Email:    email,
+		}
+		if sshKeys.Valid {
+			user.SSHPublicKeys = strings.Split(sshKeys.String, ",")
+		}
+		userInfos = append(userInfos, user)
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return userInfos, nil
+}
+
+func (s *SQLiteStore) GetUser(username string) (User, error) {
+	var user User
+	user.Username = username
+	query := `
+		SELECT users.email, GROUP_CONCAT(user_ssh_keys.ssh_key, ',')
+		FROM users
+		LEFT JOIN user_ssh_keys ON users.username = user_ssh_keys.username
+		WHERE users.username = ?
+		GROUP BY users.username
+	`
+	row := s.db.QueryRow(query, username)
+	var sshKeys sql.NullString
+	err := row.Scan(&user.Email, &sshKeys)
+	if err != nil {
+		if err == sql.ErrNoRows {
+			return User{}, fmt.Errorf("no user found with username %s", username)
+		}
+		return User{}, err
+	}
+	if sshKeys.Valid {
+		user.SSHPublicKeys = strings.Split(sshKeys.String, ",")
+	}
+	return user, nil
+}
+
+func (s *SQLiteStore) CreateUser(user, email string) error {
+	_, err := s.db.Exec(`INSERT INTO users (username, email) VALUES (?, ?)`, user, email)
+	if err != nil {
+		sqliteErr, ok := err.(*sqlite3.Error)
+		if ok {
+			if sqliteErr.ExtendedCode() == ErrorUniqueConstraintViolation {
+				if strings.Contains(err.Error(), "UNIQUE constraint failed: users.username") {
+					return fmt.Errorf("username %s already exists", user)
+				}
+				if strings.Contains(err.Error(), "UNIQUE constraint failed: users.email") {
+					return fmt.Errorf("email %s already exists", email)
+				}
+			}
+		}
+		return err
+	}
+	return nil
+}
+
 func getLoggedInUser(r *http.Request) (string, error) {
 	if user := r.Header.Get("X-User"); user != "" {
 		return user, nil
 	} else {
 		return "", fmt.Errorf("unauthenticated")
 	}
-	// return "user", nil
+	// return "tabo", nil
 }
 
 type Status int
@@ -534,15 +656,17 @@
 	go func() {
 		r := mux.NewRouter()
 		r.PathPrefix("/static/").Handler(http.FileServer(http.FS(staticResources)))
-		r.HandleFunc("/group/{group-name}/add-user/", s.addUserToGroupHandler)
-		r.HandleFunc("/group/{parent-group}/add-child-group", s.addChildGroupHandler)
-		r.HandleFunc("/group/{owned-group}/add-owner-group", s.addOwnerGroupHandler)
-		r.HandleFunc("/group/{parent-group}/remove-child-group/{child-group}", s.removeChildGroupHandler)
-		r.HandleFunc("/group/{group-name}/remove-owner/{username}", s.removeOwnerFromGroupHandler)
-		r.HandleFunc("/group/{group-name}/remove-member/{username}", s.removeMemberFromGroupHandler)
+		r.HandleFunc("/group/{group-name}/add-user/", s.addUserToGroupHandler).Methods(http.MethodPost)
+		r.HandleFunc("/group/{parent-group}/add-child-group", s.addChildGroupHandler).Methods(http.MethodPost)
+		r.HandleFunc("/group/{owned-group}/add-owner-group", s.addOwnerGroupHandler).Methods(http.MethodPost)
+		r.HandleFunc("/group/{parent-group}/remove-child-group/{child-group}", s.removeChildGroupHandler).Methods(http.MethodPost)
+		r.HandleFunc("/group/{group-name}/remove-owner/{username}", s.removeOwnerFromGroupHandler).Methods(http.MethodPost)
+		r.HandleFunc("/group/{group-name}/remove-member/{username}", s.removeMemberFromGroupHandler).Methods(http.MethodPost)
 		r.HandleFunc("/group/{group-name}", s.groupHandler)
+		r.HandleFunc("/user/{username}/ssh-key", s.addSSHKeyForUserHandler).Methods(http.MethodPost)
+		r.HandleFunc("/user/{username}/remove-ssh-key", s.removeSSHKeyForUserHandler).Methods(http.MethodPost)
 		r.HandleFunc("/user/{username}", s.userHandler)
-		r.HandleFunc("/create-group", s.createGroupHandler)
+		r.HandleFunc("/create-group", s.createGroupHandler).Methods(http.MethodPost)
 		r.HandleFunc("/", s.homePageHandler)
 		e <- http.ListenAndServe(fmt.Sprintf(":%d", *port), r)
 	}()
@@ -550,6 +674,8 @@
 		r := mux.NewRouter()
 		r.HandleFunc("/api/init", s.apiInitHandler)
 		r.HandleFunc("/api/user/{username}", s.apiMemberOfHandler)
+		r.HandleFunc("/api/users", s.apiGetAllUsers).Methods(http.MethodGet)
+		r.HandleFunc("/api/users", s.apiCreateUser).Methods(http.MethodPost)
 		e <- http.ListenAndServe(fmt.Sprintf(":%d", *apiPort), r)
 	}()
 	return <-e
@@ -616,6 +742,17 @@
 	http.Redirect(w, r, "/user/"+loggedInUser, http.StatusSeeOther)
 }
 
+type UserPageData struct {
+	OwnerGroups      []Group
+	MembershipGroups []Group
+	TransitiveGroups []Group
+	LoggedInUserPage bool
+	CurrentUser      string
+	SSHPublicKeys    []string
+	Email            string
+	ErrorMessage     string
+}
+
 func (s *Server) userHandler(w http.ResponseWriter, r *http.Request) {
 	loggedInUser, err := getLoggedInUser(r)
 	if err != nil {
@@ -642,19 +779,19 @@
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
-	data := struct {
-		OwnerGroups      []Group
-		MembershipGroups []Group
-		TransitiveGroups []Group
-		LoggedInUserPage bool
-		CurrentUser      string
-		ErrorMessage     string
-	}{
+	userInfo, err := s.store.GetUser(user)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	data := UserPageData{
 		OwnerGroups:      ownerGroups,
 		MembershipGroups: membershipGroups,
 		TransitiveGroups: transitiveGroups,
 		LoggedInUserPage: loggedInUserPage,
 		CurrentUser:      user,
+		SSHPublicKeys:    userInfo.SSHPublicKeys,
+		Email:            userInfo.Email,
 		ErrorMessage:     errorMsg,
 	}
 	templates, err := parseTemplates(tmpls)
@@ -674,10 +811,6 @@
 		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
@@ -700,6 +833,18 @@
 	http.Redirect(w, r, "/", http.StatusSeeOther)
 }
 
+type GroupPageData struct {
+	GroupName        string
+	Description      string
+	Owners           []string
+	Members          []string
+	AllGroups        []Group
+	TransitiveGroups []Group
+	ChildGroups      []Group
+	OwnerGroups      []Group
+	ErrorMessage     string
+}
+
 func (s *Server) groupHandler(w http.ResponseWriter, r *http.Request) {
 	_, err := getLoggedInUser(r)
 	if err != nil {
@@ -758,17 +903,7 @@
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
-	data := struct {
-		GroupName        string
-		Description      string
-		Owners           []string
-		Members          []string
-		AllGroups        []Group
-		TransitiveGroups []Group
-		ChildGroups      []Group
-		OwnerGroups      []Group
-		ErrorMessage     string
-	}{
+	data := GroupPageData{
 		GroupName:        groupName,
 		Description:      description,
 		Owners:           owners,
@@ -796,31 +931,29 @@
 		http.Error(w, "User Not Logged In", http.StatusUnauthorized)
 		return
 	}
-	if r.Method == http.MethodPost {
-		vars := mux.Vars(r)
-		parentGroup := vars["parent-group"]
-		childGroup := vars["child-group"]
-		if err := isValidGroupName(parentGroup); err != nil {
-			http.Error(w, err.Error(), http.StatusBadRequest)
-			return
-		}
-		if err := isValidGroupName(childGroup); err != nil {
-			http.Error(w, err.Error(), http.StatusBadRequest)
-			return
-		}
-		if err := s.checkIsOwner(w, loggedInUser, parentGroup); err != nil {
-			redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", parentGroup, url.QueryEscape(err.Error()))
-			http.Redirect(w, r, redirectURL, http.StatusSeeOther)
-			return
-		}
-		err := s.store.RemoveFromGroupToGroup(parentGroup, childGroup)
-		if err != nil {
-			redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", parentGroup, url.QueryEscape(err.Error()))
-			http.Redirect(w, r, redirectURL, http.StatusFound)
-			return
-		}
-		http.Redirect(w, r, "/group/"+parentGroup, http.StatusSeeOther)
+	vars := mux.Vars(r)
+	parentGroup := vars["parent-group"]
+	childGroup := vars["child-group"]
+	if err := isValidGroupName(parentGroup); err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
 	}
+	if err := isValidGroupName(childGroup); err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+	if err := s.checkIsOwner(w, loggedInUser, parentGroup); err != nil {
+		redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", parentGroup, url.QueryEscape(err.Error()))
+		http.Redirect(w, r, redirectURL, http.StatusSeeOther)
+		return
+	}
+	err = s.store.RemoveFromGroupToGroup(parentGroup, childGroup)
+	if err != nil {
+		redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", parentGroup, url.QueryEscape(err.Error()))
+		http.Redirect(w, r, redirectURL, http.StatusFound)
+		return
+	}
+	http.Redirect(w, r, "/group/"+parentGroup, http.StatusSeeOther)
 }
 
 func (s *Server) removeOwnerFromGroupHandler(w http.ResponseWriter, r *http.Request) {
@@ -829,28 +962,26 @@
 		http.Error(w, "User Not Logged In", http.StatusUnauthorized)
 		return
 	}
-	if r.Method == http.MethodPost {
-		vars := mux.Vars(r)
-		username := vars["username"]
-		groupName := vars["group-name"]
-		tableName := "owners"
-		if err := isValidGroupName(groupName); err != nil {
-			http.Error(w, err.Error(), http.StatusBadRequest)
-			return
-		}
-		if err := s.checkIsOwner(w, loggedInUser, groupName); err != nil {
-			redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupName, url.QueryEscape(err.Error()))
-			http.Redirect(w, r, redirectURL, http.StatusSeeOther)
-			return
-		}
-		err := s.store.RemoveUserFromTable(username, groupName, tableName)
-		if err != nil {
-			redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupName, url.QueryEscape(err.Error()))
-			http.Redirect(w, r, redirectURL, http.StatusFound)
-			return
-		}
-		http.Redirect(w, r, "/group/"+groupName, http.StatusSeeOther)
+	vars := mux.Vars(r)
+	username := vars["username"]
+	groupName := vars["group-name"]
+	tableName := "owners"
+	if err := isValidGroupName(groupName); err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
 	}
+	if err := s.checkIsOwner(w, loggedInUser, groupName); err != nil {
+		redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupName, url.QueryEscape(err.Error()))
+		http.Redirect(w, r, redirectURL, http.StatusSeeOther)
+		return
+	}
+	err = s.store.RemoveUserFromTable(username, groupName, tableName)
+	if err != nil {
+		redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupName, url.QueryEscape(err.Error()))
+		http.Redirect(w, r, redirectURL, http.StatusFound)
+		return
+	}
+	http.Redirect(w, r, "/group/"+groupName, http.StatusSeeOther)
 }
 
 func (s *Server) removeMemberFromGroupHandler(w http.ResponseWriter, r *http.Request) {
@@ -859,35 +990,29 @@
 		http.Error(w, "User Not Logged In", http.StatusUnauthorized)
 		return
 	}
-	if r.Method == http.MethodPost {
-		vars := mux.Vars(r)
-		username := vars["username"]
-		groupName := vars["group-name"]
-		tableName := "user_to_group"
-		if err := isValidGroupName(groupName); err != nil {
-			http.Error(w, err.Error(), http.StatusBadRequest)
-			return
-		}
-		if err := s.checkIsOwner(w, loggedInUser, groupName); err != nil {
-			redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupName, url.QueryEscape(err.Error()))
-			http.Redirect(w, r, redirectURL, http.StatusSeeOther)
-			return
-		}
-		err := s.store.RemoveUserFromTable(username, groupName, tableName)
-		if err != nil {
-			redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupName, url.QueryEscape(err.Error()))
-			http.Redirect(w, r, redirectURL, http.StatusFound)
-			return
-		}
-		http.Redirect(w, r, "/group/"+groupName, http.StatusSeeOther)
+	vars := mux.Vars(r)
+	username := vars["username"]
+	groupName := vars["group-name"]
+	tableName := "user_to_group"
+	if err := isValidGroupName(groupName); err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
 	}
+	if err := s.checkIsOwner(w, loggedInUser, groupName); err != nil {
+		redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupName, url.QueryEscape(err.Error()))
+		http.Redirect(w, r, redirectURL, http.StatusSeeOther)
+		return
+	}
+	err = s.store.RemoveUserFromTable(username, groupName, tableName)
+	if err != nil {
+		redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupName, url.QueryEscape(err.Error()))
+		http.Redirect(w, r, redirectURL, http.StatusFound)
+		return
+	}
+	http.Redirect(w, r, "/group/"+groupName, http.StatusSeeOther)
 }
 
 func (s *Server) addUserToGroupHandler(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)
@@ -933,10 +1058,6 @@
 
 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)
@@ -967,10 +1088,6 @@
 }
 
 func (s *Server) addOwnerGroupHandler(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)
@@ -1000,6 +1117,62 @@
 	http.Redirect(w, r, "/group/"+ownedGroup, http.StatusSeeOther)
 }
 
+func (s *Server) addSSHKeyForUserHandler(w http.ResponseWriter, r *http.Request) {
+	defer s.pingAllSyncAddresses()
+	loggedInUser, err := getLoggedInUser(r)
+	if err != nil {
+		http.Error(w, "User Not Logged In", http.StatusUnauthorized)
+		return
+	}
+	vars := mux.Vars(r)
+	username := vars["username"]
+	if loggedInUser != username {
+		http.Error(w, "You are not allowed to add SSH key for someone else", http.StatusUnauthorized)
+		return
+	}
+	sshKey := r.FormValue("ssh-key")
+	if sshKey == "" {
+		http.Error(w, "SSH key not present", http.StatusBadRequest)
+		return
+	}
+	if err := s.store.AddSSHKeyForUser(username, sshKey); err != nil {
+		redirectURL := fmt.Sprintf("/user/%s?errorMessage=%s", loggedInUser, url.QueryEscape(err.Error()))
+		http.Redirect(w, r, redirectURL, http.StatusFound)
+		return
+	}
+	http.Redirect(w, r, "/user/"+loggedInUser, http.StatusSeeOther)
+}
+
+func (s *Server) removeSSHKeyForUserHandler(w http.ResponseWriter, r *http.Request) {
+	defer s.pingAllSyncAddresses()
+	loggedInUser, err := getLoggedInUser(r)
+	if err != nil {
+		http.Error(w, "User Not Logged In", http.StatusUnauthorized)
+		return
+	}
+	vars := mux.Vars(r)
+	username := vars["username"]
+	if loggedInUser != username {
+		http.Error(w, "You are not allowed to remove SSH key for someone else", http.StatusUnauthorized)
+		return
+	}
+	if err := r.ParseForm(); err != nil {
+		http.Error(w, "Invalid request body", http.StatusBadRequest)
+		return
+	}
+	sshKey := r.FormValue("ssh-key")
+	if sshKey == "" {
+		http.Error(w, "SSH key not present", http.StatusBadRequest)
+		return
+	}
+	if err := s.store.RemoveSSHKeyForUser(username, sshKey); err != nil {
+		redirectURL := fmt.Sprintf("/user/%s?errorMessage=%s", loggedInUser, url.QueryEscape(err.Error()))
+		http.Redirect(w, r, redirectURL, http.StatusFound)
+		return
+	}
+	http.Redirect(w, r, "/user/"+loggedInUser, http.StatusSeeOther)
+}
+
 type initRequest struct {
 	Owner  string   `json:"owner"`
 	Groups []string `json:"groups"`
@@ -1045,6 +1218,76 @@
 	}
 }
 
+func (s *Server) apiGetAllUsers(w http.ResponseWriter, r *http.Request) {
+	defer s.pingAllSyncAddresses()
+	selfAddress := r.FormValue("selfAddress")
+	if selfAddress != "" {
+		s.addSyncAddress(selfAddress)
+	}
+	users, err := s.store.GetAllUsers()
+	if err != nil {
+		http.Error(w, "Failed to retrieve SSH keys", http.StatusInternalServerError)
+		return
+	}
+	w.Header().Set("Content-Type", "application/json")
+	if err := json.NewEncoder(w).Encode(users); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}
+
+func (s *Server) apiCreateUser(w http.ResponseWriter, r *http.Request) {
+	defer s.pingAllSyncAddresses()
+	selfAddress := r.FormValue("selfAddress")
+	if selfAddress != "" {
+		s.addSyncAddress(selfAddress)
+	}
+	if err := r.ParseForm(); err != nil {
+		http.Error(w, "Invalid request body", http.StatusBadRequest)
+		return
+	}
+	username := r.FormValue("username")
+	email := r.FormValue("email")
+	if username == "" {
+		http.Error(w, "Username cannot be empty", http.StatusBadRequest)
+		return
+	}
+	if email == "" {
+		http.Error(w, "Email cannot be empty", http.StatusBadRequest)
+		return
+	}
+	username = strings.ToLower(username)
+	email = strings.ToLower(email)
+	err := s.store.CreateUser(username, email)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	w.WriteHeader(http.StatusOK)
+}
+
+func (s *Server) pingAllSyncAddresses() {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	for address := range s.syncAddresses {
+		resp, err := http.Get(address)
+		if err != nil {
+			log.Printf("Failed to ping %s: %v", address, err)
+			continue
+		}
+		resp.Body.Close()
+		if resp.StatusCode != http.StatusOK {
+			log.Printf("Ping to %s returned status %d", address, resp.StatusCode)
+		}
+	}
+}
+
+func (s *Server) addSyncAddress(address string) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	s.syncAddresses[address] = struct{}{}
+}
+
 func convertStatus(status string) (Status, error) {
 	switch status {
 	case "Owner":
@@ -1077,6 +1320,10 @@
 	if err != nil {
 		panic(err)
 	}
-	s := Server{store}
+	s := Server{
+		store:         store,
+		syncAddresses: make(map[string]struct{}),
+		mu:            sync.Mutex{},
+	}
 	log.Fatal(s.Start())
 }
diff --git a/core/auth/memberships/memberships-tmpl/base.html b/core/auth/memberships/memberships-tmpl/base.html
index 50b792e..9755c08 100644
--- a/core/auth/memberships/memberships-tmpl/base.html
+++ b/core/auth/memberships/memberships-tmpl/base.html
@@ -5,9 +5,17 @@
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
     <title>{{ block "title" . }}{{ end }}</title>
     <link rel="stylesheet" href="/static/pico.2.0.6.min.css">
-    <link rel="stylesheet" href="/static/main.css?v=0.0.1">
+    <link rel="stylesheet" href="/static/main.css?v=0.0.3">
 </head>
 <body class="container">
+    {{ define "svgIcon" }}
+    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
+        <g fill="none" fill-rule="evenodd">
+            <path d="M24 0v24H0V0zM12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.019-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z" />
+            <path fill="currentColor" d="m12 13.414l5.657 5.657a1 1 0 0 0 1.414-1.414L13.414 12l5.657-5.657a1 1 0 0 0-1.414-1.414L12 10.586L6.343 4.929A1 1 0 0 0 4.93 6.343L10.586 12l-5.657 5.657a1 1 0 1 0 1.414 1.414z" />
+        </g>
+    </svg>
+    {{ end }}
     {{- block "content" . }}
     {{- end }}
     {{ if ne .ErrorMessage "" }}
diff --git a/core/auth/memberships/memberships-tmpl/group.html b/core/auth/memberships/memberships-tmpl/group.html
index 4576a92..c927536 100644
--- a/core/auth/memberships/memberships-tmpl/group.html
+++ b/core/auth/memberships/memberships-tmpl/group.html
@@ -1,16 +1,6 @@
-{{ define "svgIcon" }}
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
-    <g fill="none" fill-rule="evenodd">
-        <path d="M24 0v24H0V0zM12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.019-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z" />
-        <path fill="currentColor" d="m12 13.414l5.657 5.657a1 1 0 0 0 1.414-1.414L13.414 12l5.657-5.657a1 1 0 0 0-1.414-1.414L12 10.586L6.343 4.929A1 1 0 0 0 4.93 6.343L10.586 12l-5.657 5.657a1 1 0 1 0 1.414 1.414z" />
-    </g>
-</svg>
-{{ end }}
-
 {{ define "title" }}
     Group - {{ .GroupName }}
 {{ end }}
-
 {{ define "content" }}
 {{- $parentGroupName := .GroupName }}
     <div>
diff --git a/core/auth/memberships/memberships-tmpl/user.html b/core/auth/memberships/memberships-tmpl/user.html
index 4d52092..562f27d 100644
--- a/core/auth/memberships/memberships-tmpl/user.html
+++ b/core/auth/memberships/memberships-tmpl/user.html
@@ -2,8 +2,40 @@
     User - {{ .CurrentUser }}
 {{ end }}
 {{- define "content" -}}
+    {{ $currentUser := .CurrentUser }}
+    {{ $isLoggedInUser := .LoggedInUserPage}}
     <h1 class="headline">User: {{ .CurrentUser }}</h1>
+    <p>{{ .Email }}</p>
+    <hr class="divider">
+    <h3>SSH Public keys</h3>
+    <div class="ssh-key-grid">
+        {{ if eq (len .SSHPublicKeys) 0 }}
+            <p>No SSH keys configured.</p>
+        {{ else }}
+            {{ range .SSHPublicKeys }}
+                <div class="ssh-key-item">
+                    {{ if $isLoggedInUser }}
+                        <form action="/user/{{ $currentUser }}/remove-ssh-key" method="post" class="remove-form" data-confirmation-message="Are you sure you want to remove SSH key?">
+                            <input type="hidden" name="ssh-key" value="{{ . }}">
+                            <button class="remove ssh-remove" type="submit">
+                                <div>{{ template "svgIcon" }}</div>
+                            </button>
+                        </form>
+                    {{ end }}
+                    <div class="ssh-key">{{ . }}</div>
+                </div>
+            {{ end }}
+        {{ end }}
+    </div>
     {{ if .LoggedInUserPage }}
+    <hr class="divider">
+    <form action="/user/{{ .CurrentUser }}/ssh-key" method="post">
+        <fieldset class="grid twoone">
+            <input type="text" id="ssh-hey" name="ssh-key" placeholder="Add SSH public key..." required>
+            <button type="submit">Add SSH public key</button>
+        </fieldset>
+    </form>
+    <hr class="divider">
     <form action="/create-group" method="post">
         <fieldset class="grid first">
             <input type="text" id="group-name" name="group-name" placeholder="Group name" required>
@@ -12,6 +44,7 @@
         </fieldset>
     </form>
     {{ end }}
+    <hr class="divider">
 
     <h3>Owner of groups</h3>
     <div class="user-remove">
@@ -44,4 +77,15 @@
             </a>
         {{- end }}
     </div>
+
+    <dialog id="confirmation" close>
+        <article>
+            <h3>Attention</h3>
+            <p id="confirmation-message">Are you sure?</p>
+            <footer>
+                <button id="cancel-button" class="secondary cancel-button">Cancel</button>
+                <button id="confirm-button">Confirm</button>
+            </footer>
+        </article>
+    </dialog>
 {{- end }}
diff --git a/core/auth/memberships/static/main.css b/core/auth/memberships/static/main.css
index 17c09f8..1a44b92 100644
--- a/core/auth/memberships/static/main.css
+++ b/core/auth/memberships/static/main.css
@@ -89,6 +89,30 @@
   display: contents;
 }
 
+.ssh-key-grid {
+  display: grid;
+  grid-template-columns: auto 1fr;
+  gap: 10px;
+}
+
+.ssh-key-item {
+  display: contents;
+}
+
+.ssh-key {
+  box-sizing: border-box;
+  word-wrap: break-word;
+  word-break: break-all;
+}
+
+.ssh-key-grid button {
+  height: 16px;
+}
+
+.ssh-key-grid button:focus {
+  --pico-primary-focus: var(--pico-primary-hover);
+}
+
 .headline {
   margin-top: 15px;
 }
@@ -101,8 +125,6 @@
 .remove {
   margin-bottom: 0;
   background-color: var(--pico-primary-hover);
-  /* border-top-right-radius: 0.25rem;
-  border-bottom-right-radius: 0.25rem; */
   border: none;
   outline: none;
   padding: 0;
@@ -113,8 +135,8 @@
 }
 
 .remove svg {
-  height: 20px;
-  width: 20px;
+  height: 16px;
+  width: 16px;
 }
 
 .test {
diff --git a/core/auth/memberships/store_test.go b/core/auth/memberships/store_test.go
index 4a5188a..980c719 100644
--- a/core/auth/memberships/store_test.go
+++ b/core/auth/memberships/store_test.go
@@ -2,10 +2,13 @@
 
 import (
 	"database/sql"
+	"fmt"
 	"net/http"
 	"net/http/httptest"
+	"sync"
 	"testing"
 
+	"github.com/gorilla/mux"
 	_ "github.com/ncruces/go-sqlite3/driver"
 	_ "github.com/ncruces/go-sqlite3/embed"
 )
@@ -59,7 +62,7 @@
 	err = store.Init("admin", []string{"admin", "all"})
 	if err == nil {
 		t.Fatal("initialisation did not fail")
-	} else if err.Error() != "store already initialised" {
+	} else if err.Error() != "Store already initialised" {
 		t.Fatalf("Expected initialisation error, got: %s", err.Error())
 	}
 }
@@ -146,25 +149,82 @@
 	if err != nil {
 		t.Fatal(err)
 	}
-	if err := store.AddChildGroup("a", "a"); err != nil {
+	_, err = db.Exec(`
+	INSERT INTO groups (name, description)
+	VALUES
+		('a', 'xxx'),
+		('b', 'yyy');
+	`)
+	if err != nil {
+		t.Fatal(err)
+	}
+	err = store.AddChildGroup("a", "a")
+	if err == nil || err.Error() != "Parent and child groups can not have same name" {
+		t.Fatalf("Expected error, got: %v", err)
+	}
+	if err := store.AddChildGroup("a", "b"); err != nil {
 		t.Fatalf("Unexpected error: %v", err)
 	}
 }
 
 func TestRemoveChildGroupHandler(t *testing.T) {
-	server := &Server{}
-	req, err := http.NewRequest("POST", "/group/c/remove-child-group/a", nil)
+	db, err := sql.Open("sqlite3", ":memory:")
+	if err != nil {
+		t.Fatal(err)
+	}
+	store, err := NewSQLiteStore(db)
+	if err != nil {
+		t.Fatal(err)
+	}
+	_, err = db.Exec(`
+		CREATE TABLE IF NOT EXISTS groups (
+			name TEXT PRIMARY KEY,
+			description TEXT
+		);
+		INSERT INTO groups (name, description)
+        VALUES
+            ('bb', 'desc'),
+			('aa', 'desc');
+		CREATE TABLE IF NOT EXISTS owners (
+			username TEXT,
+			group_name TEXT,
+			FOREIGN KEY(group_name) REFERENCES groups(name),
+			UNIQUE (username, group_name)
+		);
+		INSERT INTO owners (username, group_name)
+        VALUES
+            ('testuser', 'bb');
+		CREATE TABLE IF NOT EXISTS group_to_group (
+			parent_group TEXT,
+			child_group TEXT
+		);
+        INSERT INTO group_to_group (parent_group, child_group)
+        VALUES
+            ('bb', 'aa');
+        `)
+	if err != nil {
+		t.Fatal(err)
+	}
+	server := &Server{
+		store:         store,
+		syncAddresses: make(map[string]struct{}),
+		mu:            sync.Mutex{},
+	}
+	router := mux.NewRouter()
+	router.HandleFunc("/group/{parent-group}/remove-child-group/{child-group}", server.removeChildGroupHandler).Methods(http.MethodPost)
+	req, err := http.NewRequest("POST", "/group/bb/remove-child-group/aa", nil)
+	req.Header.Set("X-User", "testuser")
 	if err != nil {
 		t.Fatal(err)
 	}
 	rr := httptest.NewRecorder()
-	server.removeChildGroupHandler(rr, req)
-	if status := rr.Code; status != http.StatusOK {
+	router.ServeHTTP(rr, req)
+	if status := rr.Code; status != http.StatusSeeOther {
 		t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK)
 	}
 	body := rr.Body.String()
-	if body != "expected body" {
-		t.Errorf("handler returned unexpected body: got %v want %v",
-			body, "expected body")
+	fmt.Println("BODY: ", rr.Header().Get("Location"))
+	if rr.Header().Get("Location") != "/group/bb" {
+		t.Errorf("handler returned unexpected body: got %v want %v", body, "expected body")
 	}
 }