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")
}
}