Memebrships: Refactor Store interface

Use unified memberships table.
Add few internal API endpoints.

Change-Id: I80ac5a0f5c262e04d7898cca571b938a35d68d39
diff --git a/charts/auth-proxy/values.yaml b/charts/auth-proxy/values.yaml
index df26e50..78fe5e3 100644
--- a/charts/auth-proxy/values.yaml
+++ b/charts/auth-proxy/values.yaml
@@ -6,7 +6,7 @@
 upstream: bar.svc.cluster.local
 whoAmIAddr: https://accounts.example.com/sessions/whoami
 loginAddr: https://accounts-ui.example.com/login
-membershipAddr: https://memberships.p.example.com/api/user
+membershipAddr: https://memberships.p.example.com
 membershipPublicAddr: https://memberships.p.example.com
 groups: ""
 portName: http
diff --git a/core/auth/memberships/main.go b/core/auth/memberships/main.go
index c7df14e..8322933 100644
--- a/core/auth/memberships/main.go
+++ b/core/auth/memberships/main.go
@@ -11,14 +11,13 @@
 	"net/http"
 	"net/url"
 	"regexp"
+	"slices"
 	"strings"
 	"sync"
 
-	"github.com/ncruces/go-sqlite3"
+	"github.com/gorilla/mux"
 	_ "github.com/ncruces/go-sqlite3/driver"
 	_ "github.com/ncruces/go-sqlite3/embed"
-
-	"github.com/gorilla/mux"
 )
 
 var port = flag.Int("port", 8080, "Port to listen on")
@@ -40,37 +39,6 @@
 	h.h.ServeHTTP(w, r)
 }
 
-type Store interface {
-	// Initializes store with admin user and their groups.
-	Init(user, email string, groups []string) error
-	CreateGroup(owner string, group Group) error
-	AddChildGroup(parent, child string) error
-	AddOwnerGroup(owned_group, owner_group string) error
-	DoesGroupExist(group string) (bool, error)
-	GetGroupsOwnedBy(user string) ([]Group, error)
-	GetGroupsUserBelongsTo(user string) ([]Group, error)
-	IsGroupOwner(user, group string) (bool, error)
-	IsMemberOfOwnerGroup(user, group string) (bool, error)
-	AddGroupMember(user, group string) error
-	AddGroupOwner(user, group string) error
-	GetGroupOwners(group string) ([]string, error)
-	GetGroupOwnerGroups(group string) ([]Group, error)
-	GetGroupMembers(group string) ([]string, error)
-	GetGroupDescription(group string) (string, error)
-	GetAllTransitiveGroupsForUser(user string) ([]Group, error)
-	GetGroupsGroupBelongsTo(group string) ([]Group, error)
-	GetDirectChildrenGroups(group string) ([]Group, error)
-	GetAllTransitiveGroupsForGroup(group string) ([]Group, error)
-	RemoveFromGroupToGroup(parent, child string) error
-	RemoveUserFromTable(username, groupName, tableName string) error
-	GetAllGroups() ([]Group, error)
-	GetUsers(username []string) ([]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
 	syncAddresses map[string]struct{}
@@ -78,597 +46,24 @@
 }
 
 type Group struct {
-	Name        string
-	Description string
+	Id          string `json:"id"`
+	Title       string `json:"title"`
+	Description string `json:"description"`
 }
 
 type User struct {
-	Username      string   `json:"username"`
-	Email         string   `json:"email"`
-	SSHPublicKeys []string `json:"sshPublicKeys,omitempty"`
-}
-
-type SQLiteStore struct {
-	db *sql.DB
-}
-
-const (
-	ErrorUniqueConstraintViolation     = 2067
-	ErrorConstraintPrimaryKeyViolation = 1555
-)
-
-func NewSQLiteStore(db *sql.DB) (*SQLiteStore, error) {
-	_, 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),
-			UNIQUE (username, group_name)
-		);
-		CREATE TABLE IF NOT EXISTS owner_groups (
-			owner_group TEXT,
-			owned_group TEXT,
-			FOREIGN KEY(owner_group) REFERENCES groups(name),
-			FOREIGN KEY(owned_group) REFERENCES groups(name),
-			UNIQUE (owner_group, owned_group)
-		);
-		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),
-			UNIQUE (parent_group, child_group)
-		);
-		CREATE TABLE IF NOT EXISTS user_to_group (
-			username TEXT,
-			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
-	}
-	return &SQLiteStore{db: db}, nil
-}
-
-func (s *SQLiteStore) Init(user, email string, groups []string) error {
-	tx, err := s.db.Begin()
-	if err != nil {
-		return err
-	}
-	defer tx.Rollback()
-	row := tx.QueryRow("SELECT COUNT(*) FROM groups")
-	var count int
-	if err := row.Scan(&count); err != nil {
-		return err
-	}
-	if count != 0 {
-		return fmt.Errorf("Store already initialised")
-	}
-	query := `INSERT INTO users (username, email) VALUES (?, ?)`
-	if _, err := tx.Exec(query, user, email); err != nil {
-		return err
-	}
-	for _, g := range groups {
-		query = `INSERT INTO groups (name, description) VALUES (?, '')`
-		if _, err := tx.Exec(query, g); err != nil {
-			return err
-		}
-		query = `INSERT INTO owners (username, group_name) VALUES (?, ?)`
-		if _, err := tx.Exec(query, user, g); err != nil {
-			return err
-		}
-		query = `INSERT INTO user_to_group (username, group_name) VALUES (?, ?)`
-		if _, err := tx.Exec(query, user, g); err != nil {
-			return err
-		}
-	}
-	return tx.Commit()
-}
-
-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) GetGroupsUserBelongsTo(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() == ErrorConstraintPrimaryKeyViolation {
-			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
-	}
-	return tx.Commit()
-}
-
-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) AddGroupMember(user, group string) error {
-	_, err := s.db.Exec(`INSERT INTO user_to_group (username, group_name) VALUES (?, ?)`, user, group)
-	if err != nil {
-		sqliteErr, ok := err.(*sqlite3.Error)
-		if ok && sqliteErr.ExtendedCode() == ErrorUniqueConstraintViolation {
-			return fmt.Errorf("%s is already a member of group %s", user, group)
-		}
-		return err
-	}
-	return nil
-}
-
-func (s *SQLiteStore) AddGroupOwner(user, group string) error {
-	_, err := s.db.Exec(`INSERT INTO owners (username, group_name) VALUES (?, ?)`, user, group)
-	if err != nil {
-		sqliteErr, ok := err.(*sqlite3.Error)
-		if ok && sqliteErr.ExtendedCode() == ErrorUniqueConstraintViolation {
-			return fmt.Errorf("%s is already an owner of group %s", user, group)
-		}
-		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) DoesGroupExist(group string) (bool, error) {
-	query := `SELECT EXISTS (SELECT 1 FROM groups WHERE name = ?)`
-	var exists bool
-	if err := s.db.QueryRow(query, group).Scan(&exists); err != nil {
-		return false, err
-	}
-	return exists, nil
-}
-
-func (s *SQLiteStore) AddChildGroup(parent, child string) error {
-	if parent == child {
-		return fmt.Errorf("Parent and child groups can not have same name")
-	}
-	exists, err := s.DoesGroupExist(parent)
-	if err != nil {
-		return fmt.Errorf("Error checking parent group existence: %v", err)
-	}
-	if !exists {
-		return fmt.Errorf("Parent group with name %s does not exist", parent)
-	}
-	exists, err = s.DoesGroupExist(child)
-	if err != nil {
-		return fmt.Errorf("Error checking child group existence: %v", err)
-	}
-	if !exists {
-		return fmt.Errorf("Child group with name %s does not exist", child)
-	}
-	parentGroups, err := s.GetAllTransitiveGroupsForGroup(parent)
-	if err != nil {
-		return err
-	}
-	for _, group := range parentGroups {
-		if group.Name == child {
-			return fmt.Errorf("Circular reference detected: group %s is already a parent of group %s", child, parent)
-		}
-	}
-	_, err = s.db.Exec(`INSERT INTO group_to_group (parent_group, child_group) VALUES (?, ?)`, parent, child)
-	if err != nil {
-		sqliteErr, ok := err.(*sqlite3.Error)
-		if ok && sqliteErr.ExtendedCode() == ErrorUniqueConstraintViolation {
-			return fmt.Errorf("Child group name %s already exists in group %s", child, parent)
-		}
-		return err
-	}
-	return nil
-}
-
-func (s *SQLiteStore) GetAllTransitiveGroupsForUser(user string) ([]Group, error) {
-	if groups, err := s.GetGroupsUserBelongsTo(user); err != nil {
-		return nil, err
-	} else {
-		visited := map[string]struct{}{}
-		return s.getAllParentGroupsRecursive(groups, visited)
-	}
-}
-
-func (s *SQLiteStore) GetAllTransitiveGroupsForGroup(group string) ([]Group, error) {
-	if p, err := s.GetGroupsGroupBelongsTo(group); err != nil {
-		return nil, err
-	} else {
-		// Mark initial group as visited
-		visited := map[string]struct{}{
-			group: struct{}{},
-		}
-		return s.getAllParentGroupsRecursive(p, visited)
-	}
-}
-
-func (s *SQLiteStore) getAllParentGroupsRecursive(groups []Group, visited map[string]struct{}) ([]Group, error) {
-	var ret []Group
-	for _, g := range groups {
-		if _, ok := visited[g.Name]; ok {
-			continue
-		}
-		visited[g.Name] = struct{}{}
-		ret = append(ret, g)
-		if p, err := s.GetGroupsGroupBelongsTo(g.Name); err != nil {
-			return nil, err
-		} else if res, err := s.getAllParentGroupsRecursive(p, visited); err != nil {
-			return nil, err
-		} else {
-			ret = append(ret, res...)
-		}
-	}
-	return ret, nil
-}
-
-func (s *SQLiteStore) GetGroupsGroupBelongsTo(group string) ([]Group, error) {
-	query := `
-        SELECT groups.name, groups.description
-        FROM groups
-        JOIN group_to_group ON groups.name = group_to_group.parent_group
-        WHERE group_to_group.child_group = ?`
-	rows, err := s.db.Query(query, group)
-	if err != nil {
-		return nil, err
-	}
-	defer rows.Close()
-	var parentGroups []Group
-	for rows.Next() {
-		var parentGroup Group
-		if err := rows.Scan(&parentGroup.Name, &parentGroup.Description); err != nil {
-			return nil, err
-		}
-		parentGroups = append(parentGroups, parentGroup)
-	}
-	if err := rows.Err(); err != nil {
-		return nil, err
-	}
-	return parentGroups, nil
-}
-
-func (s *SQLiteStore) GetDirectChildrenGroups(group string) ([]Group, error) {
-	query := `
-        SELECT groups.name, groups.description
-        FROM groups
-        JOIN group_to_group ON groups.name = group_to_group.child_group
-        WHERE group_to_group.parent_group = ?`
-	rows, err := s.db.Query(query, group)
-	if err != nil {
-		return nil, err
-	}
-	defer rows.Close()
-	var childrenGroups []Group
-	for rows.Next() {
-		var childGroup Group
-		if err := rows.Scan(&childGroup.Name, &childGroup.Description); err != nil {
-			return nil, err
-		}
-		childrenGroups = append(childrenGroups, childGroup)
-	}
-	if err := rows.Err(); err != nil {
-		return nil, err
-	}
-	return childrenGroups, nil
-}
-
-func (s *SQLiteStore) RemoveFromGroupToGroup(parent, child string) error {
-	query := `DELETE FROM group_to_group WHERE parent_group = ? AND child_group = ?`
-	rowDeleted, err := s.db.Exec(query, parent, child)
-	if err != nil {
-		return err
-	}
-	rowDeletedNumber, err := rowDeleted.RowsAffected()
-	if err != nil {
-		return err
-	}
-	if rowDeletedNumber == 0 {
-		return fmt.Errorf("Pair of parent '%s' and child '%s' groups not found", parent, child)
-	}
-	return nil
-}
-
-func (s *SQLiteStore) RemoveUserFromTable(username, groupName, tableName string) error {
-	if tableName == "owners" {
-		owners, err := s.GetGroupOwners(groupName)
-		if err != nil {
-			return err
-		}
-		if len(owners) == 1 {
-			return fmt.Errorf("Cannot remove the last owner of the group")
-		}
-	}
-	query := fmt.Sprintf("DELETE FROM %s WHERE username = ? AND group_name = ?", tableName)
-	rowDeleted, err := s.db.Exec(query, username, groupName)
-	if err != nil {
-		return err
-	}
-	rowDeletedNumber, err := rowDeleted.RowsAffected()
-	if err != nil {
-		return err
-	}
-	if rowDeletedNumber == 0 {
-		return fmt.Errorf("Pair of group '%s' and user '%s' not found", groupName, username)
-	}
-	return nil
-}
-
-func (s *SQLiteStore) AddOwnerGroup(owner_group, owned_group string) error {
-	if owned_group == owner_group {
-		return fmt.Errorf("Group can not own itself")
-	}
-	exists, err := s.DoesGroupExist(owned_group)
-	if err != nil {
-		return fmt.Errorf("Error checking owned group existence: %v", err)
-	}
-	if !exists {
-		return fmt.Errorf("Owned group with name %s does not exist", owned_group)
-	}
-	exists, err = s.DoesGroupExist(owner_group)
-	if err != nil {
-		return fmt.Errorf("Error checking owner group existence: %v", err)
-	}
-	if !exists {
-		return fmt.Errorf("Owner group with name %s does not exist", owner_group)
-	}
-	_, err = s.db.Exec(`INSERT INTO owner_groups (owner_group, owned_group) VALUES (?, ?)`, owner_group, owned_group)
-	if err != nil {
-		sqliteErr, ok := err.(*sqlite3.Error)
-		if ok && sqliteErr.ExtendedCode() == ErrorUniqueConstraintViolation {
-			return fmt.Errorf("Group named %s is already owner of a group %s", owner_group, owned_group)
-		}
-		return err
-	}
-	return nil
-}
-
-func (s *SQLiteStore) GetGroupOwnerGroups(group string) ([]Group, error) {
-	query := `
-        SELECT groups.name, groups.description
-        FROM groups
-        JOIN owner_groups ON groups.name = owner_groups.owner_group
-        WHERE owner_groups.owned_group = ?`
-	return s.queryGroups(query, group)
-}
-
-func (s *SQLiteStore) IsMemberOfOwnerGroup(user, group string) (bool, error) {
-	query := `
-		SELECT EXISTS (
-			SELECT 1 FROM owner_groups
-			INNER JOIN user_to_group ON owner_groups.owner_group = user_to_group.group_name
-			WHERE owner_groups.owned_group = ? AND user_to_group.username = ?)`
-	var exists bool
-	err := s.db.QueryRow(query, group, user).Scan(&exists)
-	if err != nil {
-		return false, err
-	}
-	return exists, nil
-}
-
-func (s *SQLiteStore) GetAllGroups() ([]Group, error) {
-	query := `SELECT name, description FROM groups`
-	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) GetUsers(usernames []string) ([]User, error) {
-	var rows *sql.Rows
-	var err error
-	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`
-	var args []interface{}
-	if usernames != nil {
-		if len(usernames) == 0 {
-			return []User{}, nil
-		}
-		query += " WHERE users.username IN ("
-		placeholders := strings.Repeat("?,", len(usernames)-1) + "?"
-		query += placeholders + ") "
-		for _, username := range usernames {
-			args = append(args, username)
-		}
-	}
-	query += " GROUP BY users.username"
-	rows, err = s.db.Query(query, args...)
-	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
+	Id       string `json:"id"`
+	Username string `json:"username"`
+	Email    string `json:"email"`
 }
 
 func getLoggedInUser(r *http.Request) (string, error) {
-	if user := r.Header.Get("X-Forwarded-User"); user != "" {
+	if user := r.Header.Get("X-Forwarded-UserId"); user != "" {
 		return user, nil
 	} else {
 		return "", fmt.Errorf("unauthenticated")
 	}
-	// return "tabo", nil
+	// return "0063f4b6-29cb-4bd6-b8ce-1f6b203d490f", nil
 }
 
 type Status int
@@ -683,13 +78,13 @@
 	go func() {
 		r := mux.NewRouter()
 		r.PathPrefix("/static/").Handler(cachingHandler{http.FileServer(http.FS(staticResources))})
-		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("/group/{groupId:.*}/add-user/", s.addUserToGroupHandler).Methods(http.MethodPost)
+		r.HandleFunc("/group/{groupId:.*}/add-child-group", s.addChildGroupHandler).Methods(http.MethodPost)
+		r.HandleFunc("/group/{groupId:.*}/add-owner-group", s.addOwnerGroupHandler).Methods(http.MethodPost)
+		r.HandleFunc("/group/{groupId:.*}/remove-child-group/{otherId:.*}", s.removeChildGroupHandler).Methods(http.MethodPost)
+		r.HandleFunc("/group/{groupId:.*}/remove-owner/{username}", s.removeOwnerFromGroupHandler).Methods(http.MethodPost)
+		r.HandleFunc("/group/{groupId:.*}/remove-member/{username}", s.removeMemberFromGroupHandler).Methods(http.MethodPost)
+		r.HandleFunc("/group/{groupId:.*}", 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)
@@ -705,6 +100,12 @@
 		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)
+		r.HandleFunc("/api/group", s.apiCreateGroup).Methods(http.MethodPost)
+		r.HandleFunc("/api/add-group-user", s.apiAddUserToGroup).Methods(http.MethodPost)
+		r.HandleFunc("/api/remove-group-user", s.apiRemoveUserFromGroup).Methods(http.MethodPost)
+		r.HandleFunc("/api/add-group-group", s.apiAddGroupToGroup).Methods(http.MethodPost)
+		r.HandleFunc("/api/remove-group-group", s.apiRemoveGroupFromGroup).Methods(http.MethodPost)
+		r.HandleFunc("/api/get-group", s.apiGetGroup).Methods(http.MethodPost)
 		e <- http.ListenAndServe(fmt.Sprintf(":%d", *apiPort), r)
 	}()
 	return <-e
@@ -715,23 +116,30 @@
 	Membership string
 }
 
-func (s *Server) checkIsOwner(w http.ResponseWriter, user, group string) error {
-	isOwner, err := s.store.IsGroupOwner(user, group)
+func (s *Server) checkIsOwner(w http.ResponseWriter, userId, groupId string) error {
+	ownerUsers, err := s.store.GetOwnerUsers(nil, groupId)
 	if err != nil {
 		return err
 	}
-	if isOwner {
+	if slices.ContainsFunc(ownerUsers, func(u User) bool {
+		return u.Id == userId
+	}) {
 		return nil
 	}
-	// TODO(dtabidze): right now this only checks if user is member of just one lvl upper group. should add transitive group check.
-	isMemberOfOwnerGroup, err := s.store.IsMemberOfOwnerGroup(user, group)
+	ownerIds, err := s.store.GetOwnerGroups(nil, groupId)
 	if err != nil {
 		return err
 	}
-	if !isMemberOfOwnerGroup {
-		return fmt.Errorf("You are not the owner or a member of any owner group of the group %s", group)
+	canActAs, err := s.store.GetGroupsUserCanActAs(nil, userId)
+	if err != nil {
+		return err
 	}
-	return nil
+	for _, g := range canActAs {
+		if slices.Index(ownerIds, g) != -1 {
+			return nil
+		}
+	}
+	return fmt.Errorf("not an owner")
 }
 
 type templates struct {
@@ -772,13 +180,12 @@
 }
 
 type UserPageData struct {
+	User             User
 	OwnerGroups      []Group
 	MembershipGroups []Group
 	TransitiveGroups []Group
 	LoggedInUserPage bool
-	CurrentUser      string
 	SSHPublicKeys    []string
-	Email            string
 	ErrorMessage     string
 }
 
@@ -793,34 +200,38 @@
 	user := strings.ToLower(vars["username"])
 	// TODO(dtabidze): should check if username exists or not.
 	loggedInUserPage := loggedInUser == user
-	ownerGroups, err := s.store.GetGroupsOwnedBy(user)
+	ownerGroups, err := s.store.GetGroupsUserOwns(nil, user)
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
-	membershipGroups, err := s.store.GetGroupsUserBelongsTo(user)
+	membershipGroups, err := s.store.GetGroupsUserIsMemberOf(nil, user)
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
-	transitiveGroups, err := s.store.GetAllTransitiveGroupsForUser(user)
+	transitiveGroups, err := s.store.GetGroupsUserCanActAs(nil, user)
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
-	userInfo, err := s.store.GetUser(user)
+	userInfo, err := s.store.GetUser(nil, user)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	sshPublicKeys, err := s.store.GetUserPublicKeys(nil, user)
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
 	data := UserPageData{
+		User:             userInfo,
 		OwnerGroups:      ownerGroups,
 		MembershipGroups: membershipGroups,
 		TransitiveGroups: transitiveGroups,
 		LoggedInUserPage: loggedInUserPage,
-		CurrentUser:      user,
-		SSHPublicKeys:    userInfo.SSHPublicKeys,
-		Email:            userInfo.Email,
+		SSHPublicKeys:    sshPublicKeys,
 		ErrorMessage:     errorMsg,
 	}
 	templates, err := parseTemplates(tmpls)
@@ -845,15 +256,16 @@
 		return
 	}
 	var group Group
-	group.Name = r.PostFormValue("group-name")
-	if err := isValidGroupName(group.Name); err != nil {
+	group.Id = r.PostFormValue("id")
+	group.Title = r.PostFormValue("title")
+	group.Description = r.PostFormValue("description")
+	if err := isValidGroupId(group.Id); err != nil {
 		// http.Error(w, err.Error(), http.StatusBadRequest)
 		redirectURL := fmt.Sprintf("/user/%s?errorMessage=%s", loggedInUser, url.QueryEscape(err.Error()))
 		http.Redirect(w, r, redirectURL, http.StatusFound)
 		return
 	}
-	group.Description = r.PostFormValue("description")
-	if err := s.store.CreateGroup(loggedInUser, group); err != nil {
+	if err := s.store.CreateGroup(nil, loggedInUser, group); err != nil {
 		// http.Error(w, err.Error(), http.StatusInternalServerError)
 		redirectURL := fmt.Sprintf("/user/%s?errorMessage=%s", loggedInUser, url.QueryEscape(err.Error()))
 		http.Redirect(w, r, redirectURL, http.StatusFound)
@@ -863,11 +275,13 @@
 }
 
 type GroupPageData struct {
-	GroupName        string
+	GroupId          string
+	Title            string
 	Description      string
-	Owners           []string
-	Members          []string
+	Owners           []User
+	Members          []User
 	AllGroups        []Group
+	AllUsers         []User
 	TransitiveGroups []Group
 	ChildGroups      []Group
 	OwnerGroups      []Group
@@ -882,62 +296,59 @@
 	}
 	errorMsg := r.URL.Query().Get("errorMessage")
 	vars := mux.Vars(r)
-	groupName := vars["group-name"]
-	exists, err := s.store.DoesGroupExist(groupName)
+	groupId := normalizeGroupId(vars["groupId"])
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
-	if !exists {
-		errorMsg = fmt.Sprintf("group with the name '%s' not found", groupName)
-		http.Error(w, errorMsg, http.StatusNotFound)
-		return
-	}
+	g, err := s.store.GetGroup(nil, groupId)
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
-	owners, err := s.store.GetGroupOwners(groupName)
+	owners, err := s.store.GetOwnerUsers(nil, groupId)
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
-	members, err := s.store.GetGroupMembers(groupName)
+	members, err := s.store.GetMemberUsers(nil, groupId)
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
-	description, err := s.store.GetGroupDescription(groupName)
+	allUsers, err := s.store.GetAllUsers(nil)
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
-	allGroups, err := s.store.GetAllGroups()
+	allGroups, err := s.store.GetAllGroups(nil)
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
-	transitiveGroups, err := s.store.GetAllTransitiveGroupsForGroup(groupName)
+	transitiveGroups, err := s.store.GetGroupsGroupCanActAs(nil, groupId)
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
-	childGroups, err := s.store.GetDirectChildrenGroups(groupName)
+	childGroups, err := s.store.GetMemberGroups(nil, groupId)
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
-	ownerGroups, err := s.store.GetGroupOwnerGroups(groupName)
+	ownerGroups, err := s.store.GetOwnerGroups(nil, groupId)
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
 	data := GroupPageData{
-		GroupName:        groupName,
-		Description:      description,
+		GroupId:          groupId,
+		Title:            g.Title,
+		Description:      g.Description,
 		Owners:           owners,
 		Members:          members,
 		AllGroups:        allGroups,
+		AllUsers:         allUsers,
 		TransitiveGroups: transitiveGroups,
 		ChildGroups:      childGroups,
 		OwnerGroups:      ownerGroups,
@@ -961,28 +372,28 @@
 		return
 	}
 	vars := mux.Vars(r)
-	parentGroup := vars["parent-group"]
-	childGroup := vars["child-group"]
-	if err := isValidGroupName(parentGroup); err != nil {
+	groupId := normalizeGroupId(vars["groupId"])
+	otherId := vars["otherId"]
+	if err := isValidGroupId(groupId); err != nil {
 		http.Error(w, err.Error(), http.StatusBadRequest)
 		return
 	}
-	if err := isValidGroupName(childGroup); err != nil {
+	if err := isValidGroupId(otherId); 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()))
+	if err := s.checkIsOwner(w, loggedInUser, groupId); err != nil {
+		redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupId, url.QueryEscape(err.Error()))
 		http.Redirect(w, r, redirectURL, http.StatusSeeOther)
 		return
 	}
-	err = s.store.RemoveFromGroupToGroup(parentGroup, childGroup)
+	err = s.store.RemoveMemberGroup(nil, groupId, otherId)
 	if err != nil {
-		redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", parentGroup, url.QueryEscape(err.Error()))
+		redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupId, url.QueryEscape(err.Error()))
 		http.Redirect(w, r, redirectURL, http.StatusFound)
 		return
 	}
-	http.Redirect(w, r, "/group/"+parentGroup, http.StatusSeeOther)
+	http.Redirect(w, r, "/group/"+groupId, http.StatusSeeOther)
 }
 
 func (s *Server) removeOwnerFromGroupHandler(w http.ResponseWriter, r *http.Request) {
@@ -993,24 +404,23 @@
 	}
 	vars := mux.Vars(r)
 	username := vars["username"]
-	groupName := vars["group-name"]
-	tableName := "owners"
-	if err := isValidGroupName(groupName); err != nil {
+	groupId := normalizeGroupId(vars["groupId"])
+	if err := isValidGroupId(groupId); 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()))
+	if err := s.checkIsOwner(w, loggedInUser, groupId); err != nil {
+		redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupId, url.QueryEscape(err.Error()))
 		http.Redirect(w, r, redirectURL, http.StatusSeeOther)
 		return
 	}
-	err = s.store.RemoveUserFromTable(username, groupName, tableName)
+	err = s.store.RemoveOwnerUser(nil, groupId, username)
 	if err != nil {
-		redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupName, url.QueryEscape(err.Error()))
+		redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupId, url.QueryEscape(err.Error()))
 		http.Redirect(w, r, redirectURL, http.StatusFound)
 		return
 	}
-	http.Redirect(w, r, "/group/"+groupName, http.StatusSeeOther)
+	http.Redirect(w, r, "/group/"+groupId, http.StatusSeeOther)
 }
 
 func (s *Server) removeMemberFromGroupHandler(w http.ResponseWriter, r *http.Request) {
@@ -1021,24 +431,23 @@
 	}
 	vars := mux.Vars(r)
 	username := vars["username"]
-	groupName := vars["group-name"]
-	tableName := "user_to_group"
-	if err := isValidGroupName(groupName); err != nil {
+	groupId := normalizeGroupId(vars["groupId"])
+	if err := isValidGroupId(groupId); 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()))
+	if err := s.checkIsOwner(w, loggedInUser, groupId); err != nil {
+		redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupId, url.QueryEscape(err.Error()))
 		http.Redirect(w, r, redirectURL, http.StatusSeeOther)
 		return
 	}
-	err = s.store.RemoveUserFromTable(username, groupName, tableName)
+	err = s.store.RemoveMemberUser(nil, groupId, username)
 	if err != nil {
-		redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupName, url.QueryEscape(err.Error()))
+		redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupId, url.QueryEscape(err.Error()))
 		http.Redirect(w, r, redirectURL, http.StatusFound)
 		return
 	}
-	http.Redirect(w, r, "/group/"+groupName, http.StatusSeeOther)
+	http.Redirect(w, r, "/group/"+groupId, http.StatusSeeOther)
 }
 
 func (s *Server) addUserToGroupHandler(w http.ResponseWriter, r *http.Request) {
@@ -1048,13 +457,13 @@
 		return
 	}
 	vars := mux.Vars(r)
-	groupName := vars["group-name"]
-	if err := isValidGroupName(groupName); err != nil {
+	groupId := normalizeGroupId(vars["groupId"])
+	if err := isValidGroupId(groupId); err != nil {
 		http.Error(w, err.Error(), http.StatusBadRequest)
 		return
 	}
-	username := strings.ToLower(r.FormValue("username"))
-	if username == "" {
+	userId := strings.ToLower(r.FormValue("userId"))
+	if userId == "" {
 		http.Error(w, "Username parameter is required", http.StatusBadRequest)
 		return
 	}
@@ -1063,26 +472,26 @@
 		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()))
+	if err := s.checkIsOwner(w, loggedInUser, groupId); err != nil {
+		redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupId, url.QueryEscape(err.Error()))
 		http.Redirect(w, r, redirectURL, http.StatusSeeOther)
 		return
 	}
 	switch status {
 	case Owner:
-		err = s.store.AddGroupOwner(username, groupName)
+		err = s.store.AddOwnerUser(nil, groupId, userId)
 	case Member:
-		err = s.store.AddGroupMember(username, groupName)
+		err = s.store.AddMemberUser(nil, groupId, userId)
 	default:
 		http.Error(w, "Invalid status", http.StatusBadRequest)
 		return
 	}
 	if err != nil {
-		redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupName, url.QueryEscape(err.Error()))
+		redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupId, url.QueryEscape(err.Error()))
 		http.Redirect(w, r, redirectURL, http.StatusFound)
 		return
 	}
-	http.Redirect(w, r, "/group/"+groupName, http.StatusSeeOther)
+	http.Redirect(w, r, "/group/"+groupId, http.StatusSeeOther)
 }
 
 func (s *Server) addChildGroupHandler(w http.ResponseWriter, r *http.Request) {
@@ -1093,27 +502,27 @@
 		return
 	}
 	vars := mux.Vars(r)
-	parentGroup := vars["parent-group"]
-	if err := isValidGroupName(parentGroup); err != nil {
+	groupId := normalizeGroupId(vars["groupId"])
+	otherId := r.FormValue("otherId")
+	if err := isValidGroupId(groupId); err != nil {
 		http.Error(w, err.Error(), http.StatusBadRequest)
 		return
 	}
-	childGroup := r.FormValue("child-group")
-	if err := isValidGroupName(childGroup); err != nil {
+	if err := isValidGroupId(otherId); 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()))
+	if err := s.checkIsOwner(w, loggedInUser, groupId); err != nil {
+		redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupId, url.QueryEscape(err.Error()))
 		http.Redirect(w, r, redirectURL, http.StatusSeeOther)
 		return
 	}
-	if err := s.store.AddChildGroup(parentGroup, childGroup); err != nil {
-		redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", parentGroup, url.QueryEscape(err.Error()))
+	if err := s.store.AddMemberGroup(nil, groupId, otherId); err != nil {
+		redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupId, url.QueryEscape(err.Error()))
 		http.Redirect(w, r, redirectURL, http.StatusFound)
 		return
 	}
-	http.Redirect(w, r, "/group/"+parentGroup, http.StatusSeeOther)
+	http.Redirect(w, r, "/group/"+groupId, http.StatusSeeOther)
 }
 
 func (s *Server) addOwnerGroupHandler(w http.ResponseWriter, r *http.Request) {
@@ -1123,27 +532,27 @@
 		return
 	}
 	vars := mux.Vars(r)
-	ownedGroup := vars["owned-group"]
-	if err := isValidGroupName(ownedGroup); err != nil {
+	groupId := normalizeGroupId(vars["groupId"])
+	otherId := r.FormValue("otherId")
+	if err := isValidGroupId(groupId); err != nil {
 		http.Error(w, err.Error(), http.StatusBadRequest)
 		return
 	}
-	ownerGroup := r.FormValue("owner-group")
-	if err := isValidGroupName(ownerGroup); err != nil {
+	if err := isValidGroupId(otherId); err != nil {
 		http.Error(w, err.Error(), http.StatusBadRequest)
 		return
 	}
-	if err := s.checkIsOwner(w, loggedInUser, ownedGroup); err != nil {
-		redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", ownedGroup, url.QueryEscape(err.Error()))
+	if err := s.checkIsOwner(w, loggedInUser, groupId); err != nil {
+		redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupId, url.QueryEscape(err.Error()))
 		http.Redirect(w, r, redirectURL, http.StatusSeeOther)
 		return
 	}
-	if err := s.store.AddOwnerGroup(ownerGroup, ownedGroup); err != nil {
-		redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", ownedGroup, url.QueryEscape(err.Error()))
+	if err := s.store.AddOwnerGroup(nil, groupId, otherId); err != nil {
+		redirectURL := fmt.Sprintf("/group/%s?errorMessage=%s", groupId, url.QueryEscape(err.Error()))
 		http.Redirect(w, r, redirectURL, http.StatusFound)
 		return
 	}
-	http.Redirect(w, r, "/group/"+ownedGroup, http.StatusSeeOther)
+	http.Redirect(w, r, "/group/"+groupId, http.StatusSeeOther)
 }
 
 func (s *Server) addSSHKeyForUserHandler(w http.ResponseWriter, r *http.Request) {
@@ -1164,7 +573,7 @@
 		http.Error(w, "SSH key not present", http.StatusBadRequest)
 		return
 	}
-	if err := s.store.AddSSHKeyForUser(strings.ToLower(username), sshKey); err != nil {
+	if err := s.store.AddUserPublicKey(nil, strings.ToLower(username), sshKey); err != nil {
 		redirectURL := fmt.Sprintf("/user/%s?errorMessage=%s", loggedInUser, url.QueryEscape(err.Error()))
 		http.Redirect(w, r, redirectURL, http.StatusFound)
 		return
@@ -1194,7 +603,7 @@
 		http.Error(w, "SSH key not present", http.StatusBadRequest)
 		return
 	}
-	if err := s.store.RemoveSSHKeyForUser(username, sshKey); err != nil {
+	if err := s.store.RemoveUserPublicKey(nil, username, sshKey); err != nil {
 		redirectURL := fmt.Sprintf("/user/%s?errorMessage=%s", loggedInUser, url.QueryEscape(err.Error()))
 		http.Redirect(w, r, redirectURL, http.StatusFound)
 		return
@@ -1203,9 +612,8 @@
 }
 
 type initRequest struct {
-	User   string   `json:"user"`
-	Email  string   `json:"email"`
-	Groups []string `json:"groups"`
+	User   User    `json:"user"`
+	Groups []Group `json:"groups"`
 }
 
 func (s *Server) apiInitHandler(w http.ResponseWriter, r *http.Request) {
@@ -1214,14 +622,15 @@
 		http.Error(w, err.Error(), http.StatusBadRequest)
 		return
 	}
-	if err := s.store.Init(req.User, req.Email, req.Groups); err != nil {
+	if err := s.store.Init(req.User, req.Groups); err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
 }
 
 type userInfo struct {
-	MemberOf []string `json:"memberOf"`
+	CanActAs []Group `json:"canActAs"`
+	OwnerOf  []Group `json:"ownerOf"`
 }
 
 func (s *Server) apiMemberOfHandler(w http.ResponseWriter, r *http.Request) {
@@ -1232,17 +641,14 @@
 		return
 	}
 	user = strings.ToLower(user)
-	transitiveGroups, err := s.store.GetAllTransitiveGroupsForUser(user)
+	transitiveGroups, err := s.store.GetGroupsUserCanActAs(nil, user)
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
-	var groupNames []string
-	for _, group := range transitiveGroups {
-		groupNames = append(groupNames, group.Name)
-	}
+	owned, err := s.store.GetGroupsUserOwns(nil, user)
 	w.Header().Set("Content-Type", "application/json")
-	if err := json.NewEncoder(w).Encode(userInfo{groupNames}); err != nil {
+	if err := json.NewEncoder(w).Encode(userInfo{transitiveGroups, owned}); err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
@@ -1254,37 +660,37 @@
 	var err error
 	groups := r.FormValue("groups")
 	if groups == "" {
-		users, err = s.store.GetUsers(nil)
+		users, err = s.store.GetAllUsers(nil)
 	} else {
 		uniqueUsers := make(map[string]struct{})
 		g := strings.Split(groups, ",")
 		uniqueTG := make(map[string]struct{})
 		for _, group := range g {
 			uniqueTG[group] = struct{}{}
-			trGroups, err := s.store.GetAllTransitiveGroupsForGroup(group)
+			trGroups, err := s.store.GetGroupsGroupCanActAs(nil, group)
 			if err != nil {
 				http.Error(w, err.Error(), http.StatusInternalServerError)
 				return
 			}
 			for _, tg := range trGroups {
-				uniqueTG[tg.Name] = struct{}{}
+				uniqueTG[tg.Title] = struct{}{}
 			}
 		}
 		for group := range uniqueTG {
-			u, err := s.store.GetGroupMembers(group)
+			u, err := s.store.GetMemberUsers(nil, group)
 			if err != nil {
 				http.Error(w, err.Error(), http.StatusInternalServerError)
 				return
 			}
 			for _, user := range u {
-				uniqueUsers[user] = struct{}{}
+				uniqueUsers[user.Username] = struct{}{}
 			}
 		}
 		usernames := make([]string, 0, len(uniqueUsers))
 		for username := range uniqueUsers {
 			usernames = append(usernames, username)
 		}
-		users, err = s.store.GetUsers(usernames)
+		users, err = s.store.GetUsers(nil, usernames)
 	}
 	if err != nil {
 		http.Error(w, "Failed to retrieve user infos", http.StatusInternalServerError)
@@ -1297,19 +703,18 @@
 	}
 }
 
-type createUserRequest struct {
-	User  string `json:"user"`
-	Email string `json:"email"`
-}
-
 func (s *Server) apiCreateUser(w http.ResponseWriter, r *http.Request) {
 	defer s.pingAllSyncAddresses()
-	var req createUserRequest
+	var req User
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 		http.Error(w, "Invalid request body", http.StatusBadRequest)
 		return
 	}
-	if req.User == "" {
+	if req.Id == "" {
+		http.Error(w, "Id cannot be empty", http.StatusBadRequest)
+		return
+	}
+	if req.Username == "" {
 		http.Error(w, "Username cannot be empty", http.StatusBadRequest)
 		return
 	}
@@ -1317,7 +722,211 @@
 		http.Error(w, "Email cannot be empty", http.StatusBadRequest)
 		return
 	}
-	if err := s.store.CreateUser(strings.ToLower(req.User), strings.ToLower(req.Email)); err != nil {
+	user := User{Id: req.Id, Username: strings.ToLower(req.Username), Email: strings.ToLower(req.Email)}
+	if err := s.store.CreateUser(nil, user); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}
+
+type createGroupRequest struct {
+	UserId      string `json:"userId"`
+	GroupId     string `json:"groupId"`
+	Title       string `json:"title"`
+	Description string `json:"description"`
+}
+
+func (s *Server) apiCreateGroup(w http.ResponseWriter, r *http.Request) {
+	var req createGroupRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, "Invalid request body", http.StatusBadRequest)
+		return
+	}
+	if err := s.store.CreateGroup(nil, req.UserId, Group{req.GroupId, req.Title, req.Description}); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}
+
+type apiGetGroupReq struct {
+	GroupId string `json:"groupId"`
+}
+
+// TODO(gio): update client
+type apiGetGroupResp struct {
+	Self         Group   `json:"self"`
+	Members      []User  `json:"members"`
+	Owners       []User  `json:"owners"`
+	MemberGroups []Group `json:"memberGroups"`
+	OwnerGroups  []Group `json:"ownerGroups"`
+}
+
+func (s *Server) apiGetGroup(w http.ResponseWriter, r *http.Request) {
+	var req apiGetGroupReq
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, "missing group name", http.StatusBadRequest)
+		return
+	}
+	group, err := s.store.GetGroup(nil, req.GroupId)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	members, err := s.store.GetMemberUsers(nil, req.GroupId)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	owners, err := s.store.GetOwnerUsers(nil, req.GroupId)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	memberGroups, err := s.store.GetMemberGroups(nil, req.GroupId)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	ownerGroups, err := s.store.GetOwnerGroups(nil, req.GroupId)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	if err := json.NewEncoder(w).Encode(apiGetGroupResp{group, members, owners, memberGroups, ownerGroups}); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}
+
+type userGroupStatus struct {
+	GroupId string `json:"groupId"`
+	UserId  string `json:"userId"`
+	Status  string `json:"status"`
+}
+
+func (s *Server) apiAddUserToGroup(w http.ResponseWriter, r *http.Request) {
+	var req userGroupStatus
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, "Invalid request body", http.StatusBadRequest)
+		return
+	}
+	status, err := convertStatus(req.Status)
+	if err != nil {
+		http.Error(w, "Invalid status", http.StatusBadRequest)
+		return
+	}
+	fmt.Printf("AAA %+v\n", req)
+	switch status {
+	case Member:
+		err = s.store.AddMemberUser(nil, req.GroupId, req.UserId)
+	case Owner:
+		err = s.store.AddOwnerUser(nil, req.GroupId, req.UserId)
+	}
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}
+
+func (s *Server) apiRemoveUserFromGroup(w http.ResponseWriter, r *http.Request) {
+	var req userGroupStatus
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, "Invalid request body", http.StatusBadRequest)
+		return
+	}
+	status, err := convertStatus(req.Status)
+	if err != nil {
+		http.Error(w, "Invalid status", http.StatusBadRequest)
+		return
+	}
+	switch status {
+	case Member:
+		err = s.store.RemoveMemberUser(nil, req.GroupId, req.UserId)
+	case Owner:
+		err = s.store.RemoveOwnerUser(nil, req.GroupId, req.UserId)
+	}
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}
+
+type groupGroupStatus struct {
+	GroupId string `json:"groupId"`
+	OtherId string `json:"otherId"`
+	Status  string `json:"status"`
+}
+
+func (s *Server) apiAddGroupToGroup(w http.ResponseWriter, r *http.Request) {
+	var req groupGroupStatus
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, "Invalid request body", http.StatusBadRequest)
+		return
+	}
+	status, err := convertStatus(req.Status)
+	if err != nil {
+		http.Error(w, "Invalid status", http.StatusBadRequest)
+		return
+	}
+	switch status {
+	case Member:
+		err = s.store.AddMemberGroup(nil, req.GroupId, req.OtherId)
+	case Owner:
+		err = s.store.AddOwnerGroup(nil, req.GroupId, req.OtherId)
+	}
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}
+
+func (s *Server) apiRemoveGroupFromGroup(w http.ResponseWriter, r *http.Request) {
+	var req groupGroupStatus
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, "Invalid request body", http.StatusBadRequest)
+		return
+	}
+	status, err := convertStatus(req.Status)
+	if err != nil {
+		http.Error(w, "Invalid status", http.StatusBadRequest)
+		return
+	}
+	switch status {
+	case Member:
+		err = s.store.RemoveMemberGroup(nil, req.GroupId, req.OtherId)
+	case Owner:
+		err = s.store.RemoveOwnerGroup(nil, req.GroupId, req.OtherId)
+	}
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}
+
+type getGroupsOwnedByRequest struct {
+	User string `json:"user"`
+}
+
+type getGroupsOwnedByResponse struct {
+	Groups []string `json:"groups"`
+}
+
+func (s *Server) apiGetUserOwnedGroups(w http.ResponseWriter, r *http.Request) {
+	var req getGroupsOwnedByRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+	groups, err := s.store.GetGroupsUserOwns(nil, req.User)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	var resp getGroupsOwnedByResponse
+	for _, g := range groups {
+		resp.Groups = append(resp.Groups, g.Title)
+	}
+	if err := json.NewEncoder(w).Encode(resp); err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
@@ -1342,7 +951,7 @@
 		http.Error(w, "PublicKey cannot be empty", http.StatusBadRequest)
 		return
 	}
-	if err := s.store.AddSSHKeyForUser(strings.ToLower(req.User), req.PublicKey); err != nil {
+	if err := s.store.AddUserPublicKey(nil, strings.ToLower(req.User), req.PublicKey); err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
@@ -1381,17 +990,17 @@
 }
 
 func convertStatus(status string) (Status, error) {
-	switch status {
-	case "Owner":
+	switch strings.ToLower(status) {
+	case "owner":
 		return Owner, nil
-	case "Member":
+	case "member":
 		return Member, nil
 	default:
 		return Owner, fmt.Errorf("invalid status: %s", status)
 	}
 }
 
-func isValidGroupName(group string) error {
+func isValidGroupId(group string) error {
 	if strings.TrimSpace(group) == "" {
 		return fmt.Errorf("Group name can't be empty or contain only whitespaces")
 	}
@@ -1419,3 +1028,10 @@
 	}
 	log.Fatal(s.Start())
 }
+
+func normalizeGroupId(groupId string) string {
+	if strings.Contains(groupId, "/") && !strings.HasPrefix(groupId, "/") {
+		return fmt.Sprintf("/%s", groupId)
+	}
+	return groupId
+}
diff --git a/core/auth/memberships/memberships-tmpl/group.html b/core/auth/memberships/memberships-tmpl/group.html
index c927536..c3ff8c7 100644
--- a/core/auth/memberships/memberships-tmpl/group.html
+++ b/core/auth/memberships/memberships-tmpl/group.html
@@ -1,16 +1,20 @@
 {{ define "title" }}
-    Group - {{ .GroupName }}
+    Group - {{ .Title }}
 {{ end }}
 {{ define "content" }}
-{{- $parentGroupName := .GroupName }}
+{{- $parentGroupId := .GroupId }}
     <div>
-        <h2 class="headline">Group: {{ .GroupName }}</h2>
+        <h2 class="headline">Group: {{ .Title }}</h2>
         <p class="description">{{ .Description }}</p>
     </div>
     <hr class="divider">
-    <form action="/group/{{ .GroupName }}/add-user/" method="post">
+    <form action="/group/{{ .GroupId }}/add-user/" method="post">
         <fieldset class="grid first">
-            <input type="text" id="username" name="username" placeholder="username" required>
+            <select id="userId" aria-label="Select" name="userId" required>
+                {{- range .AllUsers }}
+                <option value="{{ .Id }}">{{ .Username }}</option>
+                {{- end }}
+            </select>
             <select id="status" name="status" required>
                 <option value="Member" selected>Member</option>
                 <option value="Owner">Owner</option>
@@ -18,21 +22,21 @@
             <button type="submit">Add Member</button>
         </fieldset>
     </form>
-    <form action="/group/{{ .GroupName }}/add-child-group" method="post">
+    <form action="/group/{{ .GroupId }}/add-child-group" method="post">
         <fieldset class="grid twoone">
-            <select id="child-group" aria-label="Select" name="child-group" required>
+            <select id="otherId" aria-label="Select" name="otherId" required>
                 {{- range .AllGroups }}
-                <option value="{{ .Name }}">{{ .Name }}</option>
+                <option value="{{ .Id }}">{{ .Title }}</option>
                 {{- end }}
             </select>
             <button type="submit">Create Child Group</button>
         </fieldset>
     </form>
-    <form action="/group/{{ .GroupName }}/add-owner-group" method="post">
+    <form action="/group/{{ .GroupId }}/add-owner-group" method="post">
         <fieldset class="grid twoone">
-            <select id="owner-group" aria-label="Select" name="owner-group" required>
+            <select id="otherId" aria-label="Select" name="otherId" required>
                 {{- range .AllGroups }}
-                <option value="{{ .Name }}">{{ .Name }}</option>
+                <option value="{{ .Id }}">{{ .Title }}</option>
                 {{- end }}
             </select>
             <button type="submit">Add Owner Group</button>
@@ -44,8 +48,8 @@
         {{- range .Owners }}
         <div>
             <fieldset role="group">
-            <a class="link-button" href="/user/{{ . }}" role="button">{{ . }}</a>
-            <form action="/group/{{ $parentGroupName }}/remove-owner/{{ . }}" method="post" class="remove-form" data-confirmation-message="Are you sure you want to revoke user <strong>{{ . }}</strong>'s ownership of the  <strong>{{ $parentGroupName }}</strong> group?">
+            <a class="link-button" href="/user/{{ .Id }}" role="button">{{ .Username }}</a>
+            <form action="/group/{{ $parentGroupId }}/remove-owner/{{ .Id }}" method="post" class="remove-form" data-confirmation-message="Are you sure you want to revoke user <strong>{{ .Username }}</strong>'s ownership of the  <strong>{{ $parentGroupId }}</strong> group?">
                 <button class="remove" type="submit" aria-label="Remove owner">
                     {{ template "svgIcon" }}
                 </button>
@@ -62,8 +66,8 @@
         {{- range .Members }}
         <div>
             <fieldset role="group">
-                <a class="link-button" href="/user/{{ . }}" role="button">{{ . }}</a>
-            <form action="/group/{{ $parentGroupName }}/remove-member/{{ . }}" method="post" class="remove-form" data-confirmation-message="Are you sure you want to remove user  <strong>{{ . }}</strong> user from  <strong>{{ $parentGroupName }}</strong> group?">
+                <a class="link-button" href="/user/{{ .Id }}" role="button">{{ .Username }}</a>
+            <form action="/group/{{ $parentGroupId }}/remove-member/{{ .Id }}" method="post" class="remove-form" data-confirmation-message="Are you sure you want to remove user  <strong>{{ .Username }}</strong> user from  <strong>{{ $parentGroupId }}</strong> group?">
                 <button class="remove" type="submit">
                     {{ template "svgIcon" }}
                 </button>
@@ -77,9 +81,9 @@
     <h3>Transitive Groups</h3>
     <div class="user-remove">
         {{- range .TransitiveGroups }}
-            <a class="link-button" href="/group/{{ .Name }}" role="button" 
+            <a class="link-button" href="/group/{{ .Id }}" role="button" 
                {{ if ne .Description "" }} data-tooltip="{{ .Description }}" data-placement="bottom" {{ end }}>
-               {{ .Name }}
+               {{ .Title }}
             </a>
         {{- end }}
     </div>
@@ -90,11 +94,11 @@
         {{- range .ChildGroups }}
         <div>
             <fieldset role="group">
-                <a class="link-button" href="/group/{{ .Name }}" role="button" 
+                <a class="link-button" href="/group/{{ .Id }}" role="button" 
                     {{ if ne .Description "" }} data-tooltip="{{ .Description }}" data-placement="bottom" {{ end }}>
-                    {{ .Name }}
+                    {{ .Title }}
                 </a>
-                <form action="/group/{{ $parentGroupName }}/remove-child-group/{{ .Name }}" method="post" class="remove-form" data-confirmation-message="Are you sure you want to remove group  <strong>{{ .Name }}</strong> as a child of the group  <strong>{{ $parentGroupName }}</strong>?">
+                <form action="/group/{{ $parentGroupId }}/remove-child-group/{{ .Id }}" method="post" class="remove-form" data-confirmation-message="Are you sure you want to remove group  <strong>{{ .Title }}</strong> as a child of the group  <strong>{{ $parentGroupId }}</strong>?">
                     <button class="remove" type="submit">
                         {{ template "svgIcon" }}
                     </button>
@@ -108,9 +112,9 @@
     <h3>Owner Groups</h3>
     <div class="user-remove">
         {{- range .OwnerGroups }}
-        <a class="link-button" href="/group/{{ .Name }}" role="button" 
+        <a class="link-button" href="/group/{{ .Id }}" role="button" 
             {{ if ne .Description "" }} data-tooltip="{{ .Description }}" data-placement="bottom" {{ end }}>
-            {{ .Name }}
+            {{ .Title }}
         </a>
         {{- end }}
     </div>
diff --git a/core/auth/memberships/memberships-tmpl/user.html b/core/auth/memberships/memberships-tmpl/user.html
index 562f27d..eda7eb5 100644
--- a/core/auth/memberships/memberships-tmpl/user.html
+++ b/core/auth/memberships/memberships-tmpl/user.html
@@ -1,11 +1,11 @@
 {{ define "title" }}
-    User - {{ .CurrentUser }}
+    User - {{ .User.Username }}
 {{ end }}
 {{- define "content" -}}
-    {{ $currentUser := .CurrentUser }}
+    {{ $currentUserId := .User.Id }}
     {{ $isLoggedInUser := .LoggedInUserPage}}
-    <h1 class="headline">User: {{ .CurrentUser }}</h1>
-    <p>{{ .Email }}</p>
+    <h1 class="headline">User: {{ .User.Username }}</h1>
+    <p>{{ .User.Email }}</p>
     <hr class="divider">
     <h3>SSH Public keys</h3>
     <div class="ssh-key-grid">
@@ -15,7 +15,7 @@
             {{ 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?">
+                        <form action="/user/{{ $currentUserId }}/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>
@@ -29,7 +29,7 @@
     </div>
     {{ if .LoggedInUserPage }}
     <hr class="divider">
-    <form action="/user/{{ .CurrentUser }}/ssh-key" method="post">
+    <form action="/user/{{ .User.Id }}/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>
@@ -38,7 +38,8 @@
     <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>
+            <input type="text" id="id" name="id" placeholder="Id" required>
+			<input type="text" id="title" name="title" placeholder="Title" required>
             <input type="text" id="description" name="description" placeholder="Description">
             <button type="submit">Create Group</button>
         </fieldset>
@@ -49,9 +50,9 @@
     <h3>Owner of groups</h3>
     <div class="user-remove">
         {{- range .OwnerGroups }}
-            <a href="/group/{{ .Name }}" role="button" 
+            <a href="/group/{{ .Id }}" role="button" 
                 {{ if ne .Description "" }} data-tooltip="{{ .Description }}" data-placement="bottom" {{ end }}>
-                {{ .Name }}
+                {{ .Title }}
             </a>
         {{- end }}
     </div>
@@ -60,9 +61,9 @@
     <h3>Direct member of groups</h3>
     <div class="user-remove">
         {{- range .MembershipGroups }}
-            <a href="/group/{{ .Name }}" role="button" 
+            <a href="/group/{{ .Id }}" role="button" 
                 {{ if ne .Description "" }} data-tooltip="{{ .Description }}" data-placement="bottom" {{ end }}>
-                {{ .Name }}
+                {{ .Title }}
             </a>
         {{- end }}
     </div>
@@ -71,9 +72,9 @@
     <h3>Transitive member of groups</h3>
     <div class="user-remove">
         {{- range .TransitiveGroups -}}
-            <a href="/group/{{ .Name }}" role="button" 
+            <a href="/group/{{ .Id }}" role="button" 
                {{ if ne .Description "" }} data-tooltip="{{ .Description }}" data-placement="bottom" {{ end }}>
-               {{ .Name }}
+               {{ .Title }}
             </a>
         {{- end }}
     </div>
diff --git a/core/auth/memberships/schema.sql b/core/auth/memberships/schema.sql
new file mode 100644
index 0000000..be7d9c3
--- /dev/null
+++ b/core/auth/memberships/schema.sql
@@ -0,0 +1,35 @@
+CREATE TABLE IF NOT EXISTS users (
+	   id TEXT PRIMARY KEY,
+	   username TEXT NOT NULL,
+	   email TEXT NOT NULL,
+	   UNIQUE(username),
+	   UNIQUE (email)
+);
+
+CREATE TABLE IF NOT EXISTS keys (
+	   user_id TEXT,
+	   public_key TEXT,
+	   UNIQUE (public_key),
+	   FOREIGN KEY(user_id) REFERENCES users(id)
+);
+
+CREATE TABLE IF NOT EXISTS groups (
+	   id TEXT PRIMARY KEY,
+	   title TEXT NOT NULL,
+	   description TEXT,
+	   external_id TEXT
+);
+
+CREATE TABLE IF NOT EXISTS memberships (
+	   id TEXT NOT NULL,
+	   membership_type TEXT CHECK(membership_type IN ('member', 'owner')) NOT NULL,
+	   member_type TEXT CHECK(member_type IN ('user', 'group')) NOT NULL,
+	   user_id TEXT,
+	   group_id TEXT,
+	   FOREIGN KEY(id) REFERENCES groups(id)
+	   FOREIGN KEY(user_id) REFERENCES users(id),
+	   FOREIGN KEY(group_id) REFERENCES groups(id),
+	   CHECK(user_id IS NULL OR group_id IS NULL),
+	   CHECK((member_type = 'user' AND user_id IS NOT NULL) OR (member_type = 'group' AND group_id IS NOT NULL)),
+	   UNIQUE (id, membership_type, member_type, user_id, group_id)
+);
diff --git a/core/auth/memberships/store.go b/core/auth/memberships/store.go
new file mode 100644
index 0000000..05b09bc
--- /dev/null
+++ b/core/auth/memberships/store.go
@@ -0,0 +1,690 @@
+package main
+
+import (
+	"database/sql"
+	_ "embed"
+	"fmt"
+	"strings"
+
+	"github.com/ncruces/go-sqlite3"
+)
+
+//go:embed schema.sql
+var schema string
+
+const (
+	ErrorUniqueConstraintViolation     = 2067
+	ErrorConstraintPrimaryKeyViolation = 1555
+)
+
+type scanner interface {
+	Scan(dest ...any) error
+}
+
+type Store interface {
+	// Initializes store with admin user and their groups.
+	Init(user User, groups []Group) error
+	CreateUser(tx *sql.Tx, user User) error
+	GetAllUsers(tx *sql.Tx) ([]User, error)
+	GetUser(tx *sql.Tx, id string) (User, error)
+	GetUsers(tx *sql.Tx, ids []string) ([]User, error)
+	CreateGroup(tx *sql.Tx, userId string, group Group) error
+	GetGroup(tx *sql.Tx, id string) (Group, error)
+	GetAllGroups(tx *sql.Tx) ([]Group, error)
+	AddMemberUser(tx *sql.Tx, groupId, userId string) error
+	AddOwnerUser(tx *sql.Tx, groupId, userId string) error
+	AddMemberGroup(tx *sql.Tx, groupId, otherId string) error
+	AddOwnerGroup(tx *sql.Tx, groupId, otherId string) error
+	GetMemberUsers(tx *sql.Tx, groupId string) ([]User, error)
+	GetOwnerUsers(tx *sql.Tx, groupId string) ([]User, error)
+	GetMemberGroups(tx *sql.Tx, groupId string) ([]Group, error)
+	GetOwnerGroups(tx *sql.Tx, groupId string) ([]Group, error)
+	RemoveMemberUser(tx *sql.Tx, groupId, userId string) error
+	RemoveOwnerUser(tx *sql.Tx, groupId, userId string) error
+	RemoveMemberGroup(tx *sql.Tx, groupId, otherId string) error
+	RemoveOwnerGroup(tx *sql.Tx, groupId, otherId string) error
+	GetGroupsUserCanActAs(tx *sql.Tx, userId string) ([]Group, error)
+	GetGroupsGroupCanActAs(tx *sql.Tx, groupId string) ([]Group, error)
+	GetGroupsUserOwns(tx *sql.Tx, userId string) ([]Group, error)
+	GetGroupsUserIsMemberOf(tx *sql.Tx, userId string) ([]Group, error)
+	GetUserPublicKeys(tx *sql.Tx, userId string) ([]string, error)
+	AddUserPublicKey(tx *sql.Tx, userId, publicKey string) error
+	RemoveUserPublicKey(tx *sql.Tx, userId, publicKey string) error
+}
+
+type SQLiteStore struct {
+	db *sql.DB
+}
+
+func NewSQLiteStore(db *sql.DB) (*SQLiteStore, error) {
+	_, err := db.Exec(schema)
+	if err != nil {
+		return nil, err
+	}
+	return &SQLiteStore{db: db}, nil
+}
+
+func (s *SQLiteStore) Init(user User, groups []Group) error {
+	tx, err := s.db.Begin()
+	if err != nil {
+		return err
+	}
+	defer tx.Rollback()
+	row := tx.QueryRow("SELECT COUNT(*) FROM groups")
+	var count int
+	if err := row.Scan(&count); err != nil {
+		return err
+	}
+	if count != 0 {
+		return fmt.Errorf("Store already initialised")
+	}
+	if err := s.CreateUser(tx, user); err != nil {
+		return err
+	}
+	for _, g := range groups {
+		if err := s.CreateGroup(tx, user.Id, g); err != nil {
+			return err
+		}
+		if err := s.AddMemberUser(tx, g.Id, user.Id); err != nil {
+			return err
+		}
+	}
+	return tx.Commit()
+}
+
+func (s *SQLiteStore) CreateUser(tx *sql.Tx, user User) (err error) {
+	if tx == nil {
+		tx, err = s.db.Begin()
+		if err != nil {
+			return
+		}
+		defer func() {
+			if err == nil {
+				err = tx.Commit()
+			} else {
+				tx.Rollback()
+			}
+		}()
+	}
+	query := "INSERT INTO users (id, username, email) VALUES (?, ?, ?)"
+	_, err = tx.Exec(query, user.Id, user.Username, user.Email)
+	return
+}
+
+func (s *SQLiteStore) scanUser(row scanner) (User, error) {
+	var ret User
+	if err := row.Scan(&ret.Id, &ret.Username, &ret.Email); err != nil {
+		return User{}, err
+	}
+	return ret, nil
+}
+
+func (s *SQLiteStore) scanUsers(rows *sql.Rows) ([]User, error) {
+	var ret []User
+	for rows.Next() {
+		if u, err := s.scanUser(rows); err != nil {
+			return nil, err
+		} else {
+			ret = append(ret, u)
+		}
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return ret, nil
+}
+
+func (s *SQLiteStore) scanGroup(row scanner) (Group, error) {
+	var ret Group
+	if err := row.Scan(&ret.Id, &ret.Title, &ret.Description); err != nil {
+		return Group{}, err
+	}
+	return ret, nil
+}
+
+func (s *SQLiteStore) scanGroups(rows *sql.Rows) ([]Group, error) {
+	var ret []Group
+	for rows.Next() {
+		if g, err := s.scanGroup(rows); err != nil {
+			return nil, err
+		} else {
+			ret = append(ret, g)
+		}
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return ret, nil
+}
+
+func (s *SQLiteStore) GetAllUsers(tx *sql.Tx) (ret []User, err error) {
+	if tx == nil {
+		tx, err = s.db.Begin()
+		if err != nil {
+			return
+		}
+		defer func() {
+			if err == nil {
+				err = tx.Commit()
+			}
+			if err != nil {
+				tx.Rollback()
+			}
+		}()
+	}
+	query := "SELECT id, username, email FROM users"
+	var rows *sql.Rows
+	if rows, err = tx.Query(query); err == nil {
+		ret, err = s.scanUsers(rows)
+	}
+	return
+}
+
+func (s *SQLiteStore) GetUser(tx *sql.Tx, id string) (ret User, err error) {
+	if tx == nil {
+		tx, err = s.db.Begin()
+		if err != nil {
+			return
+		}
+		defer func() {
+			if err == nil {
+				err = tx.Commit()
+			} else {
+				tx.Rollback()
+			}
+		}()
+	}
+	query := "SELECT id, username, email FROM users WHERE id = ?"
+	row := tx.QueryRow(query, id)
+	ret, err = s.scanUser(row)
+	return
+}
+
+func (s *SQLiteStore) GetUsers(tx *sql.Tx, ids []string) (ret []User, err error) {
+	if tx == nil {
+		tx, err = s.db.Begin()
+		if err != nil {
+			return
+		}
+		defer func() {
+			if err == nil {
+				err = tx.Commit()
+			}
+			if err != nil {
+				tx.Rollback()
+			}
+		}()
+	}
+	idPlaceholders := make([]string, len(ids))
+	idParams := make([]any, len(ids))
+	for i, id := range ids {
+		idPlaceholders[i] = "?"
+		idParams[i] = id
+	}
+	query := fmt.Sprintf("SELECT id, username, email FROM users WHERE id IN (%s)", strings.Join(idPlaceholders, ", "))
+	var rows *sql.Rows
+	if rows, err = tx.Query(query, idParams...); err == nil {
+		ret, err = s.scanUsers(rows)
+	}
+	return
+}
+
+func (s *SQLiteStore) CreateGroup(tx *sql.Tx, userId string, group Group) (err error) {
+	if tx == nil {
+		tx, err = s.db.Begin()
+		if err != nil {
+			return
+		}
+		defer func() {
+			if err == nil {
+				err = tx.Commit()
+			} else {
+				tx.Rollback()
+			}
+		}()
+	}
+	query := "INSERT INTO groups (id, title, description) VALUES (?, ?, ?)"
+	if _, err = tx.Exec(query, group.Id, group.Title, group.Description); err != nil {
+		sqliteErr, ok := err.(*sqlite3.Error)
+		if ok && sqliteErr.ExtendedCode() == ErrorConstraintPrimaryKeyViolation {
+			err = fmt.Errorf("Group with id %s already exists", group.Id)
+		}
+		return
+	}
+	err = s.addUser(tx, group.Id, userId, "owner")
+	return
+}
+
+func (s *SQLiteStore) GetGroup(tx *sql.Tx, id string) (ret Group, err error) {
+	if tx == nil {
+		tx, err = s.db.Begin()
+		if err != nil {
+			return
+		}
+		defer func() {
+			if err == nil {
+				err = tx.Commit()
+			} else {
+				tx.Rollback()
+			}
+		}()
+	}
+	query := "SELECT id, title, description FROM groups WHERE id = ?"
+	row := tx.QueryRow(query, id)
+	ret, err = s.scanGroup(row)
+	return
+}
+func (s *SQLiteStore) GetAllGroups(tx *sql.Tx) (ret []Group, err error) {
+	if tx == nil {
+		tx, err = s.db.Begin()
+		if err != nil {
+			return
+		}
+		defer func() {
+			if err == nil {
+				err = tx.Commit()
+			}
+			if err != nil {
+				tx.Rollback()
+			}
+		}()
+	}
+	query := "SELECT id, title, description FROM groups"
+	var rows *sql.Rows
+	if rows, err = tx.Query(query); err == nil {
+		ret, err = s.scanGroups(rows)
+	}
+	return
+}
+
+func (s *SQLiteStore) addUser(tx *sql.Tx, groupId, userId, membershipType string) (err error) {
+	if tx == nil {
+		tx, err = s.db.Begin()
+		if err != nil {
+			return
+		}
+		defer func() {
+			if err == nil {
+				err = tx.Commit()
+			}
+			if err != nil {
+				tx.Rollback()
+			}
+		}()
+	}
+	query := "INSERT INTO memberships (id, membership_type, member_type, user_id) VALUES (?, ?, ?, ?)"
+	_, err = tx.Exec(query, groupId, membershipType, "user", userId)
+	return
+}
+
+func (s *SQLiteStore) addGroup(tx *sql.Tx, groupId, otherId, membershipType string) (err error) {
+	if tx == nil {
+		tx, err = s.db.Begin()
+		if err != nil {
+			return
+		}
+		defer func() {
+			if err == nil {
+				err = tx.Commit()
+			}
+			if err != nil {
+				tx.Rollback()
+			}
+		}()
+	}
+	query := "INSERT INTO memberships (id, membership_type, member_type, group_id) VALUES (?, ?, ?, ?)"
+	_, err = tx.Exec(query, groupId, membershipType, "group", otherId)
+	return
+}
+
+func (s *SQLiteStore) removeUser(tx *sql.Tx, groupId, userId, membershipType string) (err error) {
+	if tx == nil {
+		tx, err = s.db.Begin()
+		if err != nil {
+			return
+		}
+		defer func() {
+			if err == nil {
+				err = tx.Commit()
+			}
+			if err != nil {
+				tx.Rollback()
+			}
+		}()
+	}
+	query := "DELETE FROM memberships WHERE id = ? AND membership_type = ? AND member_type = ? AND user_id = ?"
+	_, err = tx.Exec(query, groupId, membershipType, "user", userId)
+	return
+}
+
+func (s *SQLiteStore) removeGroup(tx *sql.Tx, groupId, otherId, membershipType string) (err error) {
+	if tx == nil {
+		tx, err = s.db.Begin()
+		if err != nil {
+			return
+		}
+		defer func() {
+			if err == nil {
+				err = tx.Commit()
+			}
+			if err != nil {
+				tx.Rollback()
+			}
+		}()
+	}
+	query := "DELETE FROM memberships WHERE id = ? AND membership_type = ? AND member_type = ? AND group_id = ?"
+	_, err = tx.Exec(query, groupId, membershipType, "group", otherId)
+	return
+}
+
+func (s *SQLiteStore) getUsers(tx *sql.Tx, groupId, membershipType string) (ret []User, err error) {
+	if tx == nil {
+		tx, err = s.db.Begin()
+		if err != nil {
+			return
+		}
+		defer func() {
+			if err == nil {
+				err = tx.Commit()
+			}
+			if err != nil {
+				tx.Rollback()
+			}
+		}()
+	}
+	query := `
+SELECT u.id, u.username, u.email
+FROM users u
+JOIN memberships m
+ON u.id = m.user_id
+WHERE m.id = ? AND membership_type = ? AND member_type = ?
+`
+	var rows *sql.Rows
+	rows, err = tx.Query(query, groupId, membershipType, "user")
+	if err != nil {
+		return
+	}
+	ret, err = s.scanUsers(rows)
+	return
+}
+
+func (s *SQLiteStore) getGroups(tx *sql.Tx, groupId, membershipType string) (ret []Group, err error) {
+	if tx == nil {
+		tx, err = s.db.Begin()
+		if err != nil {
+			return
+		}
+		defer func() {
+			if err == nil {
+				err = tx.Commit()
+			}
+			if err != nil {
+				tx.Rollback()
+			}
+		}()
+	}
+	query := `
+SELECT g.id, g.title, g.description
+FROM groups AS g
+JOIN memberships AS m
+ON g.id = m.group_id
+WHERE m.id = ? AND m.membership_type = ? AND m.member_type = ?`
+	var rows *sql.Rows
+	if rows, err = tx.Query(query, groupId, membershipType, "group"); err == nil {
+		ret, err = s.scanGroups(rows)
+	}
+	return
+}
+
+func (s *SQLiteStore) AddMemberUser(tx *sql.Tx, groupId, userId string) error {
+	return s.addUser(tx, groupId, userId, "member")
+}
+
+func (s *SQLiteStore) AddOwnerUser(tx *sql.Tx, groupId, userId string) error {
+	return s.addUser(tx, groupId, userId, "owner")
+}
+
+func (s *SQLiteStore) AddMemberGroup(tx *sql.Tx, groupId, otherId string) error {
+	return s.addGroup(tx, groupId, otherId, "member")
+}
+
+func (s *SQLiteStore) AddOwnerGroup(tx *sql.Tx, groupId, otherId string) error {
+	return s.addGroup(tx, groupId, otherId, "owner")
+}
+
+func (s *SQLiteStore) GetMemberUsers(tx *sql.Tx, groupId string) ([]User, error) {
+	return s.getUsers(tx, groupId, "member")
+}
+
+func (s *SQLiteStore) GetOwnerUsers(tx *sql.Tx, groupId string) ([]User, error) {
+	return s.getUsers(tx, groupId, "owner")
+}
+
+func (s *SQLiteStore) GetMemberGroups(tx *sql.Tx, groupId string) ([]Group, error) {
+	return s.getGroups(tx, groupId, "member")
+}
+
+func (s *SQLiteStore) GetOwnerGroups(tx *sql.Tx, groupId string) ([]Group, error) {
+	return s.getGroups(tx, groupId, "owner")
+}
+
+func (s *SQLiteStore) RemoveMemberUser(tx *sql.Tx, groupId, userId string) error {
+	return s.removeUser(tx, groupId, userId, "member")
+}
+
+func (s *SQLiteStore) RemoveOwnerUser(tx *sql.Tx, groupId, userId string) error {
+	return s.removeUser(tx, groupId, userId, "owner")
+}
+
+func (s *SQLiteStore) RemoveMemberGroup(tx *sql.Tx, groupId, otherId string) error {
+	return s.removeGroup(tx, groupId, otherId, "member")
+}
+
+func (s *SQLiteStore) RemoveOwnerGroup(tx *sql.Tx, groupId, otherId string) error {
+	return s.removeGroup(tx, groupId, otherId, "owner")
+}
+
+func (s *SQLiteStore) traverseGroups(tx *sql.Tx, groups ...Group) ([]Group, error) {
+	seen := map[string]struct{}{}
+	for _, g := range groups {
+		seen[g.Id] = struct{}{}
+	}
+	for i := 0; i < len(groups); i++ {
+		g := groups[i]
+		parents, err := s.getGroupGroups(tx, g.Id, "member")
+		if err != nil {
+			return nil, err
+		}
+		for _, p := range parents {
+			if _, ok := seen[p.Id]; !ok {
+				groups = append(groups, p)
+				seen[p.Id] = struct{}{}
+			}
+		}
+	}
+	return groups, nil
+}
+
+func (s *SQLiteStore) GetGroupsUserCanActAs(tx *sql.Tx, userId string) (ret []Group, err error) {
+	if tx == nil {
+		tx, err = s.db.Begin()
+		if err != nil {
+			return
+		}
+		defer func() {
+			if err == nil {
+				err = tx.Commit()
+			}
+			if err != nil {
+				tx.Rollback()
+			}
+		}()
+	}
+	var memberOf []Group
+	memberOf, err = s.GetGroupsUserIsMemberOf(tx, userId)
+	if err != nil {
+		return
+	}
+	ret, err = s.traverseGroups(tx, memberOf...)
+	return
+}
+
+func (s *SQLiteStore) GetGroupsGroupCanActAs(tx *sql.Tx, groupId string) (ret []Group, err error) {
+	if tx == nil {
+		tx, err = s.db.Begin()
+		if err != nil {
+			return
+		}
+		defer func() {
+			if err == nil {
+				err = tx.Commit()
+			}
+			if err != nil {
+				tx.Rollback()
+			}
+		}()
+	}
+	var g Group
+	g, err = s.GetGroup(tx, groupId)
+	if err != nil {
+		return
+	}
+	var groups []Group
+	if groups, err = s.traverseGroups(tx, g); err == nil {
+		ret = groups[1:]
+	}
+	return
+}
+
+func (s *SQLiteStore) getUserGroups(tx *sql.Tx, userId, membershipType string) (ret []Group, err error) {
+	if tx == nil {
+		tx, err = s.db.Begin()
+		if err != nil {
+			return
+		}
+		defer func() {
+			if err == nil {
+				err = tx.Commit()
+			}
+			if err != nil {
+				tx.Rollback()
+			}
+		}()
+	}
+	query := `
+SELECT g.id, g.title, g.description
+FROM groups AS g
+JOIN memberships AS m
+ON g.id = m.id
+WHERE m.user_id = ? AND m.membership_type = ? AND m.member_type = ?`
+	var rows *sql.Rows
+	if rows, err = tx.Query(query, userId, membershipType, "user"); err == nil {
+		ret, err = s.scanGroups(rows)
+	}
+	return
+}
+
+func (s *SQLiteStore) getGroupGroups(tx *sql.Tx, groupId, membershipType string) (ret []Group, err error) {
+	if tx == nil {
+		tx, err = s.db.Begin()
+		if err != nil {
+			return
+		}
+		defer func() {
+			if err == nil {
+				err = tx.Commit()
+			} else {
+				tx.Rollback()
+			}
+		}()
+	}
+	query := `
+SELECT g.id, g.title, g.description
+FROM groups AS g
+JOIN memberships AS m
+ON g.id = m.id
+WHERE m.group_id = ? AND m.membership_type = ? AND m.member_type = ?`
+	var rows *sql.Rows
+	if rows, err = tx.Query(query, groupId, membershipType, "group"); err == nil {
+		ret, err = s.scanGroups(rows)
+	}
+	return
+}
+
+func (s *SQLiteStore) GetGroupsUserOwns(tx *sql.Tx, userId string) ([]Group, error) {
+	return s.getUserGroups(tx, userId, "owner")
+}
+
+func (s *SQLiteStore) GetGroupsUserIsMemberOf(tx *sql.Tx, userId string) ([]Group, error) {
+	return s.getUserGroups(tx, userId, "member")
+}
+
+func (s *SQLiteStore) AddUserPublicKey(tx *sql.Tx, userId, publicKey string) (err error) {
+	if tx == nil {
+		tx, err = s.db.Begin()
+		if err != nil {
+			return
+		}
+		defer func() {
+			if err == nil {
+				err = tx.Commit()
+			} else {
+				tx.Rollback()
+			}
+		}()
+	}
+	query := "INSERT INTO keys (user_id, public_key) VALUES (?, ?)"
+	_, err = tx.Exec(query, userId, publicKey)
+	return
+}
+
+func (s *SQLiteStore) GetUserPublicKeys(tx *sql.Tx, userId string) (ret []string, err error) {
+	if tx == nil {
+		tx, err = s.db.Begin()
+		if err != nil {
+			return
+		}
+		defer func() {
+			if err == nil {
+				err = tx.Commit()
+			} else {
+				tx.Rollback()
+			}
+		}()
+	}
+	query := "SELECT public_key FROM keys WHERE user_id = ?"
+	var rows *sql.Rows
+	if rows, err = tx.Query(query, userId); err != nil {
+		return
+	}
+	defer rows.Close()
+
+	for rows.Next() {
+		var publicKey string
+		if err = rows.Scan(&publicKey); err != nil {
+			return
+		}
+		ret = append(ret, publicKey)
+	}
+	err = rows.Err()
+	return
+}
+
+func (s *SQLiteStore) RemoveUserPublicKey(tx *sql.Tx, userId, publicKey string) (err error) {
+	if tx == nil {
+		tx, err = s.db.Begin()
+		if err != nil {
+			return
+		}
+		defer func() {
+			if err == nil {
+				err = tx.Commit()
+			} else {
+				tx.Rollback()
+			}
+		}()
+	}
+	query := "DELETE FROM keys WHERE user_id = ? AND  public_key = ?"
+	_, err = tx.Exec(query, userId, publicKey)
+	return
+}
diff --git a/core/auth/memberships/store_test.go b/core/auth/memberships/store_test.go
index aba707c..b7be081 100644
--- a/core/auth/memberships/store_test.go
+++ b/core/auth/memberships/store_test.go
@@ -2,19 +2,20 @@
 
 import (
 	"database/sql"
-	"encoding/json"
-	"net/http"
-	"net/http/httptest"
-	"reflect"
-	"sync"
+	"strings"
 	"testing"
 
-	"github.com/gorilla/mux"
 	_ "github.com/ncruces/go-sqlite3/driver"
 	_ "github.com/ncruces/go-sqlite3/embed"
 )
 
-func TestInitSuccess(t *testing.T) {
+var initGroups = []Group{
+	{Id: "admin", Title: "Admin", Description: "Admin group"},
+	{Id: "all", Title: "All", Description: "All members"},
+}
+
+// Helper function to create a new in-memory SQLite store for testing
+func createTestStore(t *testing.T) Store {
 	db, err := sql.Open("sqlite3", ":memory:")
 	if err != nil {
 		t.Fatal(err)
@@ -23,377 +24,1008 @@
 	if err != nil {
 		t.Fatal(err)
 	}
-	if err := store.Init("admin", "admin@admin", []string{"admin", "all"}); err != nil {
+	return store
+}
+
+// Helper function to create a store with initialized data
+func createInitializedStore(t *testing.T) Store {
+	store := createTestStore(t)
+	adminUser := User{Id: "1", Username: "admin", Email: "admin@admin.com"}
+	if err := store.Init(adminUser, initGroups); err != nil {
 		t.Fatal(err)
 	}
-	groups, err := store.GetGroupsOwnedBy("admin")
+	return store
+}
+
+func TestNewSQLiteStore(t *testing.T) {
+	db, err := sql.Open("sqlite3", ":memory:")
 	if err != nil {
 		t.Fatal(err)
 	}
-	if len(groups) != 2 {
-		t.Fatalf("Expected two groups, got: %s", groups)
-	}
-	groups, err = store.GetGroupsUserBelongsTo("admin")
+	defer db.Close()
+
+	store, err := NewSQLiteStore(db)
 	if err != nil {
 		t.Fatal(err)
 	}
-	if len(groups) != 2 {
-		t.Fatalf("Expected two groups, got: %s", groups)
+	if store == nil {
+		t.Fatal("expected store to be non-nil")
 	}
 }
 
-func TestInitFailure(t *testing.T) {
-	db, err := sql.Open("sqlite3", ":memory:")
+func TestInit(t *testing.T) {
+	t.Run("success", func(t *testing.T) {
+		store := createTestStore(t)
+		adminUser := User{Id: "1", Username: "admin", Email: "admin@admin.com"}
+		err := store.Init(adminUser, initGroups)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		// Verify user was created
+		user, err := store.GetUser(nil, "1")
+		if err != nil {
+			t.Fatal(err)
+		}
+		if user.Username != "admin" || user.Email != "admin@admin.com" {
+			t.Fatalf("unexpected user: %+v", user)
+		}
+
+		// Verify groups were created
+		allGroups, err := store.GetAllGroups(nil)
+		if err != nil {
+			t.Fatal(err)
+		}
+		if len(allGroups) != 2 {
+			t.Fatalf("expected 2 groups, got %d", len(allGroups))
+		}
+
+		// Verify user is member of both groups
+		memberGroups, err := store.GetGroupsUserIsMemberOf(nil, "1")
+		if err != nil {
+			t.Fatal(err)
+		}
+		if len(memberGroups) != 2 {
+			t.Fatalf("expected user to be member of 2 groups, got %d", len(memberGroups))
+		}
+	})
+
+	t.Run("already_initialized", func(t *testing.T) {
+		store := createInitializedStore(t)
+		user2 := User{Id: "2", Username: "user2", Email: "user2@test.com"}
+		err := store.Init(user2, []Group{})
+		if err == nil || !strings.Contains(err.Error(), "already initialised") {
+			t.Fatalf("expected 'already initialised' error, got: %v", err)
+		}
+	})
+}
+
+func TestCreateUser(t *testing.T) {
+	t.Run("success", func(t *testing.T) {
+		store := createTestStore(t)
+		user := User{Id: "user1", Username: "testuser", Email: "test@test.com"}
+		err := store.CreateUser(nil, user)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		retrievedUser, err := store.GetUser(nil, "user1")
+		if err != nil {
+			t.Fatal(err)
+		}
+		if retrievedUser.Id != "user1" || retrievedUser.Username != "testuser" || retrievedUser.Email != "test@test.com" {
+			t.Fatalf("unexpected user: %+v", retrievedUser)
+		}
+	})
+
+	t.Run("with_transaction", func(t *testing.T) {
+		store := createTestStore(t)
+		db := store.(*SQLiteStore).db
+		tx, err := db.Begin()
+		if err != nil {
+			t.Fatal(err)
+		}
+		defer tx.Rollback()
+
+		user := User{Id: "user1", Username: "testuser", Email: "test@test.com"}
+		err = store.CreateUser(tx, user)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		if err := tx.Commit(); err != nil {
+			t.Fatal(err)
+		}
+
+		retrievedUser, err := store.GetUser(nil, "user1")
+		if err != nil {
+			t.Fatal(err)
+		}
+		if retrievedUser.Username != "testuser" {
+			t.Fatalf("unexpected username: %s", retrievedUser.Username)
+		}
+	})
+
+	t.Run("duplicate_id", func(t *testing.T) {
+		store := createTestStore(t)
+		user1 := User{Id: "user1", Username: "testuser1", Email: "test1@test.com"}
+		err := store.CreateUser(nil, user1)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		user2 := User{Id: "user1", Username: "testuser2", Email: "test2@test.com"}
+		err = store.CreateUser(nil, user2)
+		if err == nil {
+			t.Fatal("expected error for duplicate user ID")
+		}
+	})
+
+	t.Run("duplicate_username", func(t *testing.T) {
+		store := createTestStore(t)
+		user1 := User{Id: "user1", Username: "testuser", Email: "test1@test.com"}
+		err := store.CreateUser(nil, user1)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		user2 := User{Id: "user2", Username: "testuser", Email: "test2@test.com"}
+		err = store.CreateUser(nil, user2)
+		if err == nil {
+			t.Fatal("expected error for duplicate username")
+		}
+	})
+
+	t.Run("duplicate_email", func(t *testing.T) {
+		store := createTestStore(t)
+		user1 := User{Id: "user1", Username: "testuser1", Email: "test@test.com"}
+		err := store.CreateUser(nil, user1)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		user2 := User{Id: "user2", Username: "testuser2", Email: "test@test.com"}
+		err = store.CreateUser(nil, user2)
+		if err == nil {
+			t.Fatal("expected error for duplicate email")
+		}
+	})
+}
+
+func TestGetUser(t *testing.T) {
+	t.Run("success", func(t *testing.T) {
+		store := createTestStore(t)
+		user := User{Id: "user1", Username: "testuser", Email: "test@test.com"}
+		err := store.CreateUser(nil, user)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		retrievedUser, err := store.GetUser(nil, "user1")
+		if err != nil {
+			t.Fatal(err)
+		}
+		if retrievedUser.Id != "user1" || retrievedUser.Username != "testuser" || retrievedUser.Email != "test@test.com" {
+			t.Fatalf("unexpected user: %+v", retrievedUser)
+		}
+	})
+
+	t.Run("not_found", func(t *testing.T) {
+		store := createTestStore(t)
+		_, err := store.GetUser(nil, "nonexistent")
+		if err == nil {
+			t.Fatal("expected error for nonexistent user")
+		}
+	})
+}
+
+func TestGetAllUsers(t *testing.T) {
+	store := createTestStore(t)
+
+	// Create test users
+	users := []User{
+		{Id: "user1", Username: "testuser1", Email: "test1@test.com"},
+		{Id: "user2", Username: "testuser2", Email: "test2@test.com"},
+		{Id: "user3", Username: "testuser3", Email: "test3@test.com"},
+	}
+
+	for _, u := range users {
+		err := store.CreateUser(nil, u)
+		if err != nil {
+			t.Fatal(err)
+		}
+	}
+
+	allUsers, err := store.GetAllUsers(nil)
 	if err != nil {
 		t.Fatal(err)
 	}
-	store, err := NewSQLiteStore(db)
+	if len(allUsers) != 3 {
+		t.Fatalf("expected 3 users, got %d", len(allUsers))
+	}
+}
+
+func TestGetUsers(t *testing.T) {
+	store := createTestStore(t)
+
+	// Create test users
+	users := []User{
+		{Id: "user1", Username: "testuser1", Email: "test1@test.com"},
+		{Id: "user2", Username: "testuser2", Email: "test2@test.com"},
+		{Id: "user3", Username: "testuser3", Email: "test3@test.com"},
+	}
+
+	for _, u := range users {
+		err := store.CreateUser(nil, u)
+		if err != nil {
+			t.Fatal(err)
+		}
+	}
+
+	selectedUsers, err := store.GetUsers(nil, []string{"user1", "user3"})
 	if err != nil {
 		t.Fatal(err)
 	}
-	_, err = db.Exec(`
-        INSERT INTO groups (name, description)
-        VALUES
-            ('a', 'xxx'),
-            ('b', 'yyy');
-        `)
+	if len(selectedUsers) != 2 {
+		t.Fatalf("expected 2 users, got %d", len(selectedUsers))
+	}
+
+	userIds := make(map[string]bool)
+	for _, u := range selectedUsers {
+		userIds[u.Id] = true
+	}
+	if !userIds["user1"] || !userIds["user3"] {
+		t.Fatal("unexpected users returned")
+	}
+}
+
+func TestCreateGroup(t *testing.T) {
+	t.Run("success", func(t *testing.T) {
+		store := createTestStore(t)
+		owner := User{Id: "owner1", Username: "owner", Email: "owner@test.com"}
+		err := store.CreateUser(nil, owner)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		group := Group{Id: "testgroup", Title: "Test Group", Description: "A test group"}
+		err = store.CreateGroup(nil, "owner1", group)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		retrievedGroup, err := store.GetGroup(nil, "testgroup")
+		if err != nil {
+			t.Fatal(err)
+		}
+		if retrievedGroup.Id != group.Id || retrievedGroup.Title != group.Title {
+			t.Fatalf("unexpected group: %+v", retrievedGroup)
+		}
+
+		// Verify owner was added
+		owners, err := store.GetOwnerUsers(nil, "testgroup")
+		if err != nil {
+			t.Fatal(err)
+		}
+		if len(owners) != 1 || owners[0].Id != "owner1" {
+			t.Fatalf("expected owner1 to be group owner, got: %+v", owners)
+		}
+	})
+
+	t.Run("duplicate_id", func(t *testing.T) {
+		store := createTestStore(t)
+		owner := User{Id: "owner1", Username: "owner", Email: "owner@test.com"}
+		err := store.CreateUser(nil, owner)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		group := Group{Id: "testgroup", Title: "Test Group", Description: "A test group"}
+		err = store.CreateGroup(nil, "owner1", group)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		err = store.CreateGroup(nil, "owner1", group)
+		if err == nil || !strings.Contains(err.Error(), "already exists") {
+			t.Fatalf("expected 'already exists' error, got: %v", err)
+		}
+	})
+}
+
+func TestGetGroup(t *testing.T) {
+	t.Run("success", func(t *testing.T) {
+		store := createInitializedStore(t)
+		group, err := store.GetGroup(nil, "admin")
+		if err != nil {
+			t.Fatal(err)
+		}
+		if group.Id != "admin" || group.Title != "Admin" {
+			t.Fatalf("unexpected group: %+v", group)
+		}
+	})
+
+	t.Run("not_found", func(t *testing.T) {
+		store := createTestStore(t)
+		_, err := store.GetGroup(nil, "nonexistent")
+		if err == nil {
+			t.Fatal("expected error for nonexistent group")
+		}
+	})
+}
+
+func TestGetAllGroups(t *testing.T) {
+	store := createInitializedStore(t)
+	groups, err := store.GetAllGroups(nil)
 	if err != nil {
 		t.Fatal(err)
 	}
-	err = store.Init("admin", "admin", []string{"admin", "all"})
+	if len(groups) != 2 {
+		t.Fatalf("expected 2 groups, got %d", len(groups))
+	}
+}
+
+func TestAddMemberUser(t *testing.T) {
+	store := createTestStore(t)
+
+	// Create users and group
+	user1 := User{Id: "user1", Username: "user1", Email: "user1@test.com"}
+	err := store.CreateUser(nil, user1)
+	if err != nil {
+		t.Fatal(err)
+	}
+	owner1 := User{Id: "owner1", Username: "owner1", Email: "owner1@test.com"}
+	err = store.CreateUser(nil, owner1)
+	if err != nil {
+		t.Fatal(err)
+	}
+	group := Group{Id: "testgroup", Title: "Test Group", Description: "A test group"}
+	err = store.CreateGroup(nil, "owner1", group)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	err = store.AddMemberUser(nil, "testgroup", "user1")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	members, err := store.GetMemberUsers(nil, "testgroup")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(members) != 1 || members[0].Id != "user1" {
+		t.Fatalf("expected user1 to be member, got: %+v", members)
+	}
+}
+
+func TestAddOwnerUser(t *testing.T) {
+	store := createTestStore(t)
+
+	// Create users and group
+	user1 := User{Id: "user1", Username: "user1", Email: "user1@test.com"}
+	err := store.CreateUser(nil, user1)
+	if err != nil {
+		t.Fatal(err)
+	}
+	owner1 := User{Id: "owner1", Username: "owner1", Email: "owner1@test.com"}
+	err = store.CreateUser(nil, owner1)
+	if err != nil {
+		t.Fatal(err)
+	}
+	group := Group{Id: "testgroup", Title: "Test Group", Description: "A test group"}
+	err = store.CreateGroup(nil, "owner1", group)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	err = store.AddOwnerUser(nil, "testgroup", "user1")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	owners, err := store.GetOwnerUsers(nil, "testgroup")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// Should have 2 owners now (original owner1 + user1)
+	if len(owners) != 2 {
+		t.Fatalf("expected 2 owners, got %d", len(owners))
+	}
+
+	ownerIds := make(map[string]bool)
+	for _, owner := range owners {
+		ownerIds[owner.Id] = true
+	}
+	if !ownerIds["owner1"] || !ownerIds["user1"] {
+		t.Fatal("expected both owner1 and user1 to be owners")
+	}
+}
+
+func TestAddMemberGroup(t *testing.T) {
+	store := createTestStore(t)
+
+	// Create owner and groups
+	owner1 := User{Id: "owner1", Username: "owner1", Email: "owner1@test.com"}
+	err := store.CreateUser(nil, owner1)
+	if err != nil {
+		t.Fatal(err)
+	}
+	parentGroup := Group{Id: "parent", Title: "Parent Group", Description: "Parent group"}
+	err = store.CreateGroup(nil, "owner1", parentGroup)
+	if err != nil {
+		t.Fatal(err)
+	}
+	childGroup := Group{Id: "child", Title: "Child Group", Description: "Child group"}
+	err = store.CreateGroup(nil, "owner1", childGroup)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	err = store.AddMemberGroup(nil, "parent", "child")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	memberGroups, err := store.GetMemberGroups(nil, "parent")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(memberGroups) != 1 || memberGroups[0].Id != "child" {
+		t.Fatalf("expected child to be member group, got: %+v", memberGroups)
+	}
+}
+
+func TestAddOwnerGroup(t *testing.T) {
+	store := createTestStore(t)
+
+	// Create owner and groups
+	owner1 := User{Id: "owner1", Username: "owner1", Email: "owner1@test.com"}
+	err := store.CreateUser(nil, owner1)
+	if err != nil {
+		t.Fatal(err)
+	}
+	parentGroup := Group{Id: "parent", Title: "Parent Group", Description: "Parent group"}
+	err = store.CreateGroup(nil, "owner1", parentGroup)
+	if err != nil {
+		t.Fatal(err)
+	}
+	ownerGroup := Group{Id: "ownergroup", Title: "Owner Group", Description: "Owner group"}
+	err = store.CreateGroup(nil, "owner1", ownerGroup)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	err = store.AddOwnerGroup(nil, "parent", "ownergroup")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	ownerGroups, err := store.GetOwnerGroups(nil, "parent")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(ownerGroups) != 1 || ownerGroups[0].Id != "ownergroup" {
+		t.Fatalf("expected ownergroup to be owner group, got: %+v", ownerGroups)
+	}
+}
+
+func TestRemoveMemberUser(t *testing.T) {
+	store := createTestStore(t)
+
+	// Setup: create user, owner, and group, then add user as member
+	user1 := User{Id: "user1", Username: "user1", Email: "user1@test.com"}
+	err := store.CreateUser(nil, user1)
+	if err != nil {
+		t.Fatal(err)
+	}
+	owner1 := User{Id: "owner1", Username: "owner1", Email: "owner1@test.com"}
+	err = store.CreateUser(nil, owner1)
+	if err != nil {
+		t.Fatal(err)
+	}
+	group := Group{Id: "testgroup", Title: "Test Group", Description: "A test group"}
+	err = store.CreateGroup(nil, "owner1", group)
+	if err != nil {
+		t.Fatal(err)
+	}
+	err = store.AddMemberUser(nil, "testgroup", "user1")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// Test removal
+	err = store.RemoveMemberUser(nil, "testgroup", "user1")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	members, err := store.GetMemberUsers(nil, "testgroup")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(members) != 0 {
+		t.Fatalf("expected no members after removal, got: %+v", members)
+	}
+}
+
+func TestRemoveOwnerUser(t *testing.T) {
+	store := createTestStore(t)
+
+	// Setup: create users, group, and add second user as owner
+	user1 := User{Id: "user1", Username: "user1", Email: "user1@test.com"}
+	err := store.CreateUser(nil, user1)
+	if err != nil {
+		t.Fatal(err)
+	}
+	owner1 := User{Id: "owner1", Username: "owner1", Email: "owner1@test.com"}
+	err = store.CreateUser(nil, owner1)
+	if err != nil {
+		t.Fatal(err)
+	}
+	group := Group{Id: "testgroup", Title: "Test Group", Description: "A test group"}
+	err = store.CreateGroup(nil, "owner1", group)
+	if err != nil {
+		t.Fatal(err)
+	}
+	err = store.AddOwnerUser(nil, "testgroup", "user1")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// Test removal
+	err = store.RemoveOwnerUser(nil, "testgroup", "user1")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	owners, err := store.GetOwnerUsers(nil, "testgroup")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(owners) != 1 || owners[0].Id != "owner1" {
+		t.Fatalf("expected only owner1 to remain, got: %+v", owners)
+	}
+}
+
+func TestRemoveMemberGroup(t *testing.T) {
+	store := createTestStore(t)
+
+	// Setup: create groups and add one as member of the other
+	owner1 := User{Id: "owner1", Username: "owner1", Email: "owner1@test.com"}
+	err := store.CreateUser(nil, owner1)
+	if err != nil {
+		t.Fatal(err)
+	}
+	parentGroup := Group{Id: "parent", Title: "Parent Group", Description: "Parent group"}
+	err = store.CreateGroup(nil, "owner1", parentGroup)
+	if err != nil {
+		t.Fatal(err)
+	}
+	childGroup := Group{Id: "child", Title: "Child Group", Description: "Child group"}
+	err = store.CreateGroup(nil, "owner1", childGroup)
+	if err != nil {
+		t.Fatal(err)
+	}
+	err = store.AddMemberGroup(nil, "parent", "child")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// Test removal
+	err = store.RemoveMemberGroup(nil, "parent", "child")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	memberGroups, err := store.GetMemberGroups(nil, "parent")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(memberGroups) != 0 {
+		t.Fatalf("expected no member groups after removal, got: %+v", memberGroups)
+	}
+}
+
+func TestRemoveOwnerGroup(t *testing.T) {
+	store := createTestStore(t)
+
+	// Setup: create groups and add one as owner of the other
+	owner1 := User{Id: "owner1", Username: "owner1", Email: "owner1@test.com"}
+	err := store.CreateUser(nil, owner1)
+	if err != nil {
+		t.Fatal(err)
+	}
+	parentGroup := Group{Id: "parent", Title: "Parent Group", Description: "Parent group"}
+	err = store.CreateGroup(nil, "owner1", parentGroup)
+	if err != nil {
+		t.Fatal(err)
+	}
+	ownerGroup := Group{Id: "ownergroup", Title: "Owner Group", Description: "Owner group"}
+	err = store.CreateGroup(nil, "owner1", ownerGroup)
+	if err != nil {
+		t.Fatal(err)
+	}
+	err = store.AddOwnerGroup(nil, "parent", "ownergroup")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// Test removal
+	err = store.RemoveOwnerGroup(nil, "parent", "ownergroup")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	ownerGroups, err := store.GetOwnerGroups(nil, "parent")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(ownerGroups) != 0 {
+		t.Fatalf("expected no owner groups after removal, got: %+v", ownerGroups)
+	}
+}
+
+func TestGetGroupsUserOwns(t *testing.T) {
+	store := createInitializedStore(t)
+	groups, err := store.GetGroupsUserOwns(nil, "1")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(groups) != 2 {
+		t.Fatalf("expected 2 groups owned by user 1, got %d", len(groups))
+	}
+}
+
+func TestGetGroupsUserIsMemberOf(t *testing.T) {
+	store := createInitializedStore(t)
+	groups, err := store.GetGroupsUserIsMemberOf(nil, "1")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(groups) != 2 {
+		t.Fatalf("expected user 1 to be member of 2 groups, got %d", len(groups))
+	}
+}
+
+func TestGetGroupsUserCanActAs(t *testing.T) {
+	store := createTestStore(t)
+
+	// Create complex hierarchy: user1 -> groupA -> groupB -> groupC
+	user1 := User{Id: "user1", Username: "user1", Email: "user1@test.com"}
+	err := store.CreateUser(nil, user1)
+	if err != nil {
+		t.Fatal(err)
+	}
+	owner1 := User{Id: "owner1", Username: "owner1", Email: "owner1@test.com"}
+	err = store.CreateUser(nil, owner1)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	groups := []Group{
+		{Id: "groupA", Title: "Group A", Description: "Group A"},
+		{Id: "groupB", Title: "Group B", Description: "Group B"},
+		{Id: "groupC", Title: "Group C", Description: "Group C"},
+	}
+
+	for _, g := range groups {
+		err = store.CreateGroup(nil, "owner1", g)
+		if err != nil {
+			t.Fatal(err)
+		}
+	}
+
+	// Build hierarchy: user1 member of groupA, groupA member of groupB, groupB member of groupC
+	err = store.AddMemberUser(nil, "groupA", "user1")
+	if err != nil {
+		t.Fatal(err)
+	}
+	err = store.AddMemberGroup(nil, "groupB", "groupA")
+	if err != nil {
+		t.Fatal(err)
+	}
+	err = store.AddMemberGroup(nil, "groupC", "groupB")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// user1 should be able to act as groupA, groupB, and groupC
+	canActAs, err := store.GetGroupsUserCanActAs(nil, "user1")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if len(canActAs) != 3 {
+		t.Fatalf("expected user1 to act as 3 groups, got %d", len(canActAs))
+	}
+
+	groupIds := make(map[string]bool)
+	for _, g := range canActAs {
+		groupIds[g.Id] = true
+	}
+	if !groupIds["groupA"] || !groupIds["groupB"] || !groupIds["groupC"] {
+		t.Fatal("expected user1 to act as groupA, groupB, and groupC")
+	}
+}
+
+func TestGetGroupsGroupCanActAs(t *testing.T) {
+	store := createTestStore(t)
+
+	// Create hierarchy: groupA -> groupB -> groupC
+	owner1 := User{Id: "owner1", Username: "owner1", Email: "owner1@test.com"}
+	err := store.CreateUser(nil, owner1)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	groups := []Group{
+		{Id: "groupA", Title: "Group A", Description: "Group A"},
+		{Id: "groupB", Title: "Group B", Description: "Group B"},
+		{Id: "groupC", Title: "Group C", Description: "Group C"},
+	}
+
+	for _, g := range groups {
+		err = store.CreateGroup(nil, "owner1", g)
+		if err != nil {
+			t.Fatal(err)
+		}
+	}
+
+	// Build hierarchy: groupA member of groupB, groupB member of groupC
+	err = store.AddMemberGroup(nil, "groupB", "groupA")
+	if err != nil {
+		t.Fatal(err)
+	}
+	err = store.AddMemberGroup(nil, "groupC", "groupB")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// groupA should be able to act as groupB and groupC (but not itself)
+	canActAs, err := store.GetGroupsGroupCanActAs(nil, "groupA")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if len(canActAs) != 2 {
+		t.Fatalf("expected groupA to act as 2 groups, got %d", len(canActAs))
+	}
+
+	groupIds := make(map[string]bool)
+	for _, g := range canActAs {
+		groupIds[g.Id] = true
+	}
+	if !groupIds["groupB"] || !groupIds["groupC"] {
+		t.Fatal("expected groupA to act as groupB and groupC")
+	}
+}
+
+func TestAddUserPublicKey(t *testing.T) {
+	t.Run("success", func(t *testing.T) {
+		store := createTestStore(t)
+		user1 := User{Id: "user1", Username: "user1", Email: "user1@test.com"}
+		err := store.CreateUser(nil, user1)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		publicKey := "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ... user1@example.com"
+		err = store.AddUserPublicKey(nil, "user1", publicKey)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		// Verify the key was added
+		keys, err := store.GetUserPublicKeys(nil, "user1")
+		if err != nil {
+			t.Fatal(err)
+		}
+		if len(keys) != 1 || keys[0] != publicKey {
+			t.Fatalf("expected 1 key, got %d: %v", len(keys), keys)
+		}
+	})
+
+	t.Run("duplicate_key", func(t *testing.T) {
+		store := createTestStore(t)
+		user1 := User{Id: "user1", Username: "user1", Email: "user1@test.com"}
+		err := store.CreateUser(nil, user1)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		publicKey := "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ... user1@example.com"
+		err = store.AddUserPublicKey(nil, "user1", publicKey)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		// Adding the same key again should fail
+		err = store.AddUserPublicKey(nil, "user1", publicKey)
+		if err == nil {
+			t.Fatal("expected error for duplicate public key")
+		}
+	})
+
+	t.Run("nonexistent_user", func(t *testing.T) {
+		store := createTestStore(t)
+		publicKey := "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ... user1@example.com"
+		err := store.AddUserPublicKey(nil, "nonexistent", publicKey)
+		if err == nil {
+			t.Fatal("expected error for nonexistent user")
+		}
+	})
+}
+
+func TestGetUserPublicKeys(t *testing.T) {
+	t.Run("success_multiple_keys", func(t *testing.T) {
+		store := createTestStore(t)
+		user1 := User{Id: "user1", Username: "user1", Email: "user1@test.com"}
+		err := store.CreateUser(nil, user1)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		keys := []string{
+			"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ1... user1@example.com",
+			"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ2... user1@work.com",
+			"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... user1@laptop",
+		}
+
+		// Add multiple keys
+		for _, key := range keys {
+			err = store.AddUserPublicKey(nil, "user1", key)
+			if err != nil {
+				t.Fatal(err)
+			}
+		}
+
+		// Retrieve all keys
+		retrievedKeys, err := store.GetUserPublicKeys(nil, "user1")
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		if len(retrievedKeys) != len(keys) {
+			t.Fatalf("expected %d keys, got %d", len(keys), len(retrievedKeys))
+		}
+
+		// Verify all keys are present
+		keyMap := make(map[string]bool)
+		for _, key := range retrievedKeys {
+			keyMap[key] = true
+		}
+		for _, expectedKey := range keys {
+			if !keyMap[expectedKey] {
+				t.Fatalf("expected key not found: %s", expectedKey)
+			}
+		}
+	})
+
+	t.Run("no_keys", func(t *testing.T) {
+		store := createTestStore(t)
+		user1 := User{Id: "user1", Username: "user1", Email: "user1@test.com"}
+		err := store.CreateUser(nil, user1)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		keys, err := store.GetUserPublicKeys(nil, "user1")
+		if err != nil {
+			t.Fatal(err)
+		}
+		if len(keys) != 0 {
+			t.Fatalf("expected 0 keys for user with no keys, got %d", len(keys))
+		}
+	})
+
+	t.Run("nonexistent_user", func(t *testing.T) {
+		store := createTestStore(t)
+		keys, err := store.GetUserPublicKeys(nil, "nonexistent")
+		if err != nil {
+			t.Fatal(err)
+		}
+		if len(keys) != 0 {
+			t.Fatalf("expected 0 keys for nonexistent user, got %d", len(keys))
+		}
+	})
+}
+
+func TestRemoveUserPublicKey(t *testing.T) {
+	t.Run("success", func(t *testing.T) {
+		store := createTestStore(t)
+		user1 := User{Id: "user1", Username: "user1", Email: "user1@test.com"}
+		err := store.CreateUser(nil, user1)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		publicKey := "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ... user1@example.com"
+		err = store.AddUserPublicKey(nil, "user1", publicKey)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		err = store.RemoveUserPublicKey(nil, "user1", publicKey)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		// Verify key was removed
+		keys, err := store.GetUserPublicKeys(nil, "user1")
+		if err != nil {
+			t.Fatal(err)
+		}
+		if len(keys) != 0 {
+			t.Fatalf("expected 0 keys after removal, got %d", len(keys))
+		}
+
+		// Should be able to add the same key again after removal
+		err = store.AddUserPublicKey(nil, "user1", publicKey)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		// Verify key was re-added
+		keys, err = store.GetUserPublicKeys(nil, "user1")
+		if err != nil {
+			t.Fatal(err)
+		}
+		if len(keys) != 1 || keys[0] != publicKey {
+			t.Fatalf("expected 1 key after re-adding, got %d: %v", len(keys), keys)
+		}
+	})
+
+	t.Run("nonexistent_key", func(t *testing.T) {
+		store := createTestStore(t)
+		user1 := User{Id: "user1", Username: "user1", Email: "user1@test.com"}
+		err := store.CreateUser(nil, user1)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		publicKey := "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ... user1@example.com"
+		// Try to remove a key that was never added
+		err = store.RemoveUserPublicKey(nil, "user1", publicKey)
+		// This should succeed (no error) even if the key doesn't exist
+		if err != nil {
+			t.Fatal(err)
+		}
+	})
+}
+
+func TestTransactionRollback(t *testing.T) {
+	store := createTestStore(t)
+	db := store.(*SQLiteStore).db
+
+	tx, err := db.Begin()
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer tx.Rollback()
+
+	// Create a user within the transaction
+	user1 := User{Id: "user1", Username: "user1", Email: "user1@test.com"}
+	err = store.CreateUser(tx, user1)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// Rollback the transaction
+	if err := tx.Rollback(); err != nil {
+		t.Fatal(err)
+	}
+
+	// User should not exist after rollback
+	_, err = store.GetUser(nil, "user1")
 	if err == nil {
-		t.Fatal("initialisation did not fail")
-	} else if err.Error() != "Store already initialised" {
-		t.Fatalf("Expected initialisation error, got: %s", err.Error())
+		t.Fatal("expected user to not exist after transaction rollback")
 	}
 }
 
-func TestGetAllTransitiveGroupsForGroup(t *testing.T) {
-	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(`
-        INSERT INTO groups (name, description)
-        VALUES
-            ('a', 'xxx'),
-            ('b', 'yyy');
-
-        INSERT INTO group_to_group (child_group, parent_group)
-        VALUES
-            ('a', 'b'),
-            ('b', 'a');
-        `)
-	if err != nil {
-		t.Fatal(err)
-	}
-	groups, err := store.GetAllTransitiveGroupsForGroup("a")
-	if err != nil {
-		t.Fatal(err)
-	}
-	if len(groups) != 1 {
-		t.Fatalf("Expected exactly one transitive group, got: %s", groups)
-	}
-	expected := Group{"b", "yyy"}
-	if groups[0] != expected {
-		t.Fatalf("Expected %s, got: %s", expected, groups[0])
-	}
-}
-
-func TestGetAllTransitiveGroupsForUser(t *testing.T) {
-	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(`
-        INSERT INTO groups (name, description)
-        VALUES
-            ('a', 'xxx'),
-            ('b', 'yyy'),
-            ('c', 'zzz');
-
-        INSERT INTO group_to_group (child_group, parent_group)
-        VALUES
-            ('a', 'c'),
-            ('b', 'c');
-        INSERT INTO user_to_group (username, group_name)
-        VALUES
-            ('u', 'a'),
-            ('u', 'b');
-        `)
-	if err != nil {
-		t.Fatal(err)
-	}
-	groups, err := store.GetAllTransitiveGroupsForUser("u")
-	if err != nil {
-		t.Fatal(err)
-	}
-	if len(groups) != 3 {
-		t.Fatalf("Expected exactly one transitive group, got: %s", groups)
-	}
-}
-
-func TestParentAndChildGroupCases(t *testing.T) {
-	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(`
-	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) {
-	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-Forwarded-User", "testuser")
-	if err != nil {
-		t.Fatal(err)
-	}
-	rr := httptest.NewRecorder()
-	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)
-	}
-	if rr.Header().Get("Location") != "/group/bb" {
-		t.Errorf("handler returned wrong Location header: got %v want %v", rr.Header().Get("Location"), "/group/bb")
-	}
-}
-
-func TestFilterUsersByGroupHandler(t *testing.T) {
-	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
-            ('a', 'a'),
-			('b', 'b'),
-			('c', 'c'),
-			('d', 'd'),
-			('e', 'e'),
-			('f', 'f');
-		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
-            ('testuser1', 'a'),
-			('testuser2', 'd');
-		CREATE TABLE IF NOT EXISTS group_to_group (
-			parent_group TEXT,
-			child_group TEXT
-		);
-        INSERT INTO group_to_group (parent_group, child_group)
-        VALUES
-            ('a', 'b'),
-			('b', 'c'),
-			('d', 'e'),
-			('e', 'f');
-		CREATE TABLE IF NOT EXISTS user_to_group (
-			username TEXT,
-			group_name TEXT,
-			FOREIGN KEY(group_name) REFERENCES groups(name),
-			UNIQUE (username, group_name)
-		);
-        INSERT INTO user_to_group (username, group_name)
-        VALUES
-            ('u1', 'a'),
-			('u2', 'b'),
-			('u3', 'e'),
-			('u4', 'f'),
-			('u5', 'f'),
-			('u6', 'd'),
-			('u7', 'd');
-		CREATE TABLE IF NOT EXISTS users (
-			username TEXT PRIMARY KEY,
-			email TEXT,
-			UNIQUE (email)
-		);
-		INSERT INTO users (username, email)
-		VALUES
-			('u1','u1@d.d'),
-			('u2','u2@d.d'),
-			('u3','u3@d.d'),
-			('u4','u4@d.d'),
-			('u5','u5@d.d'),
-			('u6','u6@d.d'),
-			('u7','u7@d.d');
-		CREATE TABLE IF NOT EXISTS user_ssh_keys (
-			username TEXT,
-			ssh_key TEXT,
-			UNIQUE (ssh_key),
-			FOREIGN KEY(username) REFERENCES users(username)
-		);
-		INSERT INTO user_ssh_keys (username, ssh_key)
-		VALUES
-			('u1','ssh1'),
-			('u1','ssh1-1'),
-			('u2','ssh2'),
-			('u3','ssh3'),
-			('u4','ssh4'),
-			('u5','ssh5'),
-			('u6','ssh6'),
-			('u7','ssh7');
-        `)
-	if err != nil {
-		t.Fatal(err)
-	}
-	server := &Server{
-		store:         store,
-		syncAddresses: make(map[string]struct{}),
-		mu:            sync.Mutex{},
-	}
-	router := mux.NewRouter()
-	// case when group present or exist
-	router.HandleFunc("/api/users", server.apiGetAllUsers).Methods(http.MethodGet)
-	req, err := http.NewRequest("GET", "/api/users?groups=b,e,t", nil)
-	req.Header.Set("X-Forwarded-User", "testuser1")
-	if err != nil {
-		t.Fatal(err)
-	}
-	rr := httptest.NewRecorder()
-	router.ServeHTTP(rr, req)
-	expected := []User{
-		{"u1", "u1@d.d", []string{"ssh1", "ssh1-1"}},
-		{"u2", "u2@d.d", []string{"ssh2"}},
-		{"u3", "u3@d.d", []string{"ssh3"}},
-		{"u6", "u6@d.d", []string{"ssh6"}},
-		{"u7", "u7@d.d", []string{"ssh7"}},
-	}
-
-	var actual []User
-	err = json.NewDecoder(rr.Body).Decode(&actual)
-	if err != nil {
-		t.Fatal(err)
-	}
-	if !reflect.DeepEqual(actual, expected) {
-		t.Errorf("handler returned unexpected body: got %v want %v", actual, expected)
-	}
-
-	// case when no group present
-	req, err = http.NewRequest("GET", "/api/users?groups=", nil)
-	req.Header.Set("X-Forwarded-User", "testuser1")
-	if err != nil {
-		t.Fatal(err)
-	}
-	rr = httptest.NewRecorder()
-	router.ServeHTTP(rr, req)
-
-	expected = []User{
-		{"u1", "u1@d.d", []string{"ssh1", "ssh1-1"}},
-		{"u2", "u2@d.d", []string{"ssh2"}},
-		{"u3", "u3@d.d", []string{"ssh3"}},
-		{"u4", "u4@d.d", []string{"ssh4"}},
-		{"u5", "u5@d.d", []string{"ssh5"}},
-		{"u6", "u6@d.d", []string{"ssh6"}},
-		{"u7", "u7@d.d", []string{"ssh7"}},
-	}
-	err = json.NewDecoder(rr.Body).Decode(&actual)
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	if !reflect.DeepEqual(actual, expected) {
-		t.Errorf("handler returned unexpected body: got %v want %v", actual, expected)
-	}
-
-	// case when wrong groups
-	req, err = http.NewRequest("GET", "/api/users?groups=x,y", nil)
-	req.Header.Set("X-Forwarded-User", "testuser1")
-	if err != nil {
-		t.Fatal(err)
-	}
-	rr = httptest.NewRecorder()
-	router.ServeHTTP(rr, req)
-
-	expected = []User{}
-	err = json.NewDecoder(rr.Body).Decode(&actual)
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	if !reflect.DeepEqual(actual, expected) {
-		t.Errorf("handler returned unexpected body: got %v want %v", actual, expected)
-	}
+func TestConcurrentAccess(t *testing.T) {
+	// Skip this test as SQLite in-memory databases don't handle true concurrency well
+	// This test would be more appropriate for integration testing with a real database
+	t.Skip("Skipping concurrent access test for in-memory SQLite database")
 }
diff --git a/core/auth/proxy/main.go b/core/auth/proxy/main.go
index f8ab620..a4a2efc 100644
--- a/core/auth/proxy/main.go
+++ b/core/auth/proxy/main.go
@@ -35,6 +35,7 @@
 var f embed.FS
 
 var noAuthPathRegexps []*regexp.Regexp
+var allowedGroups []Group
 
 func initPathPatterns() error {
 	for _, p := range strings.Split(*noAuthPathPatterns, ",") {
@@ -51,6 +52,38 @@
 	return nil
 }
 
+type getGroupReq struct {
+	GroupId string `json:"groupId"`
+}
+
+type getGroupResp struct {
+	Self Group `json:"self"`
+}
+
+func initAllowedGroups() error {
+	for _, groupId := range strings.Split(*groups, ",") {
+		gid := strings.TrimSpace(groupId)
+		if len(gid) == 0 {
+			continue
+		}
+		var buf bytes.Buffer
+		if err := json.NewEncoder(&buf).Encode(getGroupReq{gid}); err != nil {
+			return err
+		}
+		resp, err := http.Post(fmt.Sprintf("%s/api/get-group", *membershipAddr), "application/json", &buf)
+		if err != nil {
+			return err
+		}
+		var info getGroupResp
+		if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
+			return err
+		}
+		fmt.Println(info.Self)
+		allowedGroups = append(allowedGroups, info.Self)
+	}
+	return nil
+}
+
 type cachingHandler struct {
 	h http.Handler
 }
@@ -91,7 +124,7 @@
 
 type UnauthorizedPageData struct {
 	MembershipPublicAddr string
-	Groups               []string
+	Groups               []Group
 }
 
 func renderUnauthorizedPage(w http.ResponseWriter, groups []string) {
@@ -102,7 +135,7 @@
 	}
 	data := UnauthorizedPageData{
 		MembershipPublicAddr: *membershipPublicAddr,
-		Groups:               groups,
+		Groups:               allowedGroups,
 	}
 	w.Header().Set("Content-Type", "text/html")
 	w.WriteHeader(http.StatusUnauthorized)
@@ -141,13 +174,13 @@
 		}
 		if *groups != "" {
 			hasPermission := false
-			tg, err := getTransitiveGroups(user.Identity.Traits.Username)
+			tg, err := getGroupsUserCanActAs(user.Identity.Id)
 			if err != nil {
 				http.Error(w, err.Error(), http.StatusInternalServerError)
 				return
 			}
-			for _, i := range strings.Split(*groups, ",") {
-				if slices.Contains(tg, strings.TrimSpace(i)) {
+			for _, i := range allowedGroups {
+				if slices.Contains(tg, i) {
 					hasPermission = true
 					break
 				}
@@ -219,44 +252,35 @@
 	if err != nil {
 		return nil, err
 	}
-	data := make(map[string]any)
-	if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
-		return nil, err
-	}
-	// TODO(gio): remove debugging
-	b, err := json.MarshalIndent(data, "", "  ")
-	if err != nil {
-		return nil, err
-	}
-	fmt.Println(string(b))
-	var buf bytes.Buffer
-	if err := json.NewEncoder(&buf).Encode(data); err != nil {
-		return nil, err
-	}
-	tmp := buf.String()
 	if resp.StatusCode == http.StatusOK {
 		u := &user{}
-		if err := json.NewDecoder(strings.NewReader(tmp)).Decode(u); err != nil {
+		if err := json.NewDecoder(resp.Body).Decode(u); err != nil {
 			return nil, err
 		}
 		return u, nil
 	}
 	e := &authError{}
-	if err := json.NewDecoder(strings.NewReader(tmp)).Decode(e); err != nil {
+	if err := json.NewDecoder(resp.Body).Decode(e); err != nil {
 		return nil, err
 	}
 	if e.Error.Status == "Unauthorized" {
 		return nil, nil
 	}
-	return nil, fmt.Errorf("Unknown error: %s", tmp)
+	return nil, fmt.Errorf("Unknown error")
+}
+
+type Group struct {
+	Id          string `json:"id"`
+	Title       string `json:"title"`
+	Description string `json:"description"`
 }
 
 type MembershipInfo struct {
-	MemberOf []string `json:"memberOf"`
+	CanActAs []Group `json:"canActAs"`
 }
 
-func getTransitiveGroups(user string) ([]string, error) {
-	resp, err := http.Get(fmt.Sprintf("%s/%s", *membershipAddr, user))
+func getGroupsUserCanActAs(user string) ([]Group, error) {
+	resp, err := http.Get(fmt.Sprintf("%s/api/user/%s", *membershipAddr, user))
 	if err != nil {
 		return nil, err
 	}
@@ -264,7 +288,7 @@
 	if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
 		return nil, err
 	}
-	return info.MemberOf, nil
+	return info.CanActAs, nil
 }
 
 func main() {
@@ -275,6 +299,9 @@
 	if err := initPathPatterns(); err != nil {
 		log.Fatal(err)
 	}
+	if err := initAllowedGroups(); err != nil {
+		log.Fatal(err)
+	}
 	http.Handle("/.auth/static/", http.StripPrefix("/.auth", cachingHandler{http.FileServer(http.FS(f))}))
 	http.HandleFunc("/", handle)
 	fmt.Printf("Starting HTTP server on port: %d\n", *port)
diff --git a/core/auth/proxy/unauthorized.html b/core/auth/proxy/unauthorized.html
index 130cf81..28c8913 100644
--- a/core/auth/proxy/unauthorized.html
+++ b/core/auth/proxy/unauthorized.html
@@ -15,7 +15,7 @@
             <p>
                 Only members of
                 {{ range $index, $group := .Groups }}
-                <a href="{{ $.MembershipPublicAddr }}/group/{{ $group }}">{{ $group }}</a>{{ if not (IsLast $index $.Groups) }},{{ end }}
+                <a href="{{ $.MembershipPublicAddr }}/group/{{ $group.Id }}">{{ $group.Title }}</a>{{ if not (IsLast $index $.Groups) }},{{ end }}
                 {{ end }}
                 are authorized to access this page.
             </p>
diff --git a/core/auth/ui/api.go b/core/auth/ui/api.go
index b8c5bad..cced90d 100644
--- a/core/auth/ui/api.go
+++ b/core/auth/ui/api.go
@@ -129,6 +129,10 @@
 	}
 }
 
+type identityCreateResp struct {
+	Id string `json:"id"`
+}
+
 func (s *APIServer) identityCreate(w http.ResponseWriter, r *http.Request) {
 	var req identityCreateReq
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -157,16 +161,26 @@
 		http.Error(w, "failed", http.StatusInternalServerError)
 		return
 	}
-	if resp.StatusCode != http.StatusCreated {
-		var e ErrorResponse
-		if err := json.NewDecoder(resp.Body).Decode(&e); err != nil {
+	if resp.StatusCode == http.StatusCreated {
+		var idResp identityCreateResp
+		if err := json.NewDecoder(resp.Body).Decode(&idResp); err != nil {
 			http.Error(w, "failed to decode", http.StatusInternalServerError)
 			return
 		}
-		errorMessages := extractKratosErrorMessage(e)
-		replyWithErrors(w, errorMessages)
+		if err := json.NewEncoder(w).Encode(idResp); err != nil {
+			http.Error(w, "failed to decode", http.StatusInternalServerError)
+			return
+		}
 		return
 	}
+	var e ErrorResponse
+	if err := json.NewDecoder(resp.Body).Decode(&e); err != nil {
+		http.Error(w, "failed to decode", http.StatusInternalServerError)
+		return
+	}
+	errorMessages := extractKratosErrorMessage(e)
+	replyWithErrors(w, errorMessages)
+	return
 }
 
 type changePasswordReq struct {
diff --git a/core/installer/app_configs/app_global_env.cue b/core/installer/app_configs/app_global_env.cue
index 6b0c1ec..0bd3c8f 100644
--- a/core/installer/app_configs/app_global_env.cue
+++ b/core/installer/app_configs/app_global_env.cue
@@ -124,7 +124,7 @@
 					upstream:       "\(service.name).\(release.namespace).svc.cluster.local:\(service.port)"
 					whoAmIAddr:     "https://accounts.\(g.domain)/sessions/whoami"
 					loginAddr:      "https://accounts-ui.\(g.domain)/login"
-					membershipAddr: "http://memberships-api.\(g.namespacePrefix)core-auth-memberships.svc.cluster.local/api/user"
+					membershipAddr: "http://memberships-api.\(g.namespacePrefix)core-auth-memberships.svc.cluster.local"
 					if g.privateDomain == "" {
 						membershipPublicAddr: "https://memberships.\(g.domain)"
 					}
diff --git a/core/installer/server/welcome/server.go b/core/installer/server/welcome/server.go
index 198f637..23f9e00 100644
--- a/core/installer/server/welcome/server.go
+++ b/core/installer/server/welcome/server.go
@@ -177,12 +177,17 @@
 	}
 }
 
+type identityCreateResp struct {
+	Id string `json:"id"`
+}
+
 func (s *Server) createAccount(w http.ResponseWriter, r *http.Request) {
 	req, err := extractReq(r)
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
+	var idResp identityCreateResp
 	{
 		var buf bytes.Buffer
 		cr := apiCreateAccountReq{req.Username, req.Password}
@@ -223,8 +228,12 @@
 			})
 			return
 		}
+		if err := json.NewDecoder(resp.Body).Decode(&idResp); err != nil {
+			http.Error(w, "Error Decoding JSON", http.StatusInternalServerError)
+			return
+		}
 	}
-	if err := s.createUser(req.Username); err != nil {
+	if err := s.createUser(idResp.Id, req.Username); err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
@@ -232,23 +241,29 @@
 }
 
 type firstAccount struct {
-	Created bool     `json:"created"`
-	Domain  string   `json:"domain"`
-	Groups  []string `json:"groups"`
+	Created bool    `json:"created"`
+	Domain  string  `json:"domain"`
+	Groups  []group `json:"groups"`
+}
+
+type user struct {
+	Id       string `json:"id"`
+	Username string `json:"username"`
+	Email    string `json:"email"`
+}
+
+type group struct {
+	Id          string `json:"id"`
+	Title       string `json:"title"`
+	Description string `json:"description"`
 }
 
 type initRequest struct {
-	User   string   `json:"user"`
-	Email  string   `json:"email"`
-	Groups []string `json:"groups"`
+	User   user    `json:"user"`
+	Groups []group `json:"groups"`
 }
 
-type createUserRequest struct {
-	User  string `json:"user"`
-	Email string `json:"email"`
-}
-
-func (s *Server) createUser(username string) error {
+func (s *Server) createUser(id, username string) error {
 	_, err := s.repo.Do(func(r soft.RepoFS) (string, error) {
 		var fa firstAccount
 		if err := soft.ReadYaml(r, "first-account.yaml", &fa); err != nil {
@@ -256,10 +271,14 @@
 		}
 		var resp *http.Response
 		var err error
+		u := user{
+			id,
+			username,
+			fmt.Sprintf("%s@%s", username, fa.Domain),
+		}
 		if fa.Created {
-			req := createUserRequest{username, fmt.Sprintf("%s@%s", username, fa.Domain)}
 			var buf bytes.Buffer
-			if err := json.NewEncoder(&buf).Encode(req); err != nil {
+			if err := json.NewEncoder(&buf).Encode(u); err != nil {
 				return "", err
 			}
 			resp, err = http.Post(
@@ -268,7 +287,10 @@
 				&buf,
 			)
 		} else {
-			req := initRequest{username, fmt.Sprintf("%s@%s", username, fa.Domain), fa.Groups}
+			req := initRequest{
+				u,
+				fa.Groups,
+			}
 			var buf bytes.Buffer
 			if err := json.NewEncoder(&buf).Encode(req); err != nil {
 				return "", err
diff --git a/core/installer/tasks/infra.go b/core/installer/tasks/infra.go
index d69ba3a..907bba0 100644
--- a/core/installer/tasks/infra.go
+++ b/core/installer/tasks/infra.go
@@ -10,7 +10,19 @@
 	"github.com/giolekva/pcloud/core/installer/soft"
 )
 
-var initGroups = []string{"admin"}
+type group struct {
+	Id          string `json:"id"`
+	Title       string `json:"title"`
+	Description string `json:"description"`
+}
+
+var initGroups = []group{
+	group{
+		"admin",
+		"Admin",
+		"Administrators",
+	},
+}
 
 func CreateRepoClient(env installer.EnvConfig, st *state) Task {
 	t := newLeafTask("Create repo client", func() error {
@@ -77,9 +89,9 @@
 }
 
 type firstAccount struct {
-	Created bool     `json:"created"`
-	Domain  string   `json:"domain"`
-	Groups  []string `json:"groups"`
+	Created bool    `json:"created"`
+	Domain  string  `json:"domain"`
+	Groups  []group `json:"groups"`
 }
 
 func ConfigureFirstAccount(env installer.EnvConfig, st *state) Task {
@@ -407,11 +419,15 @@
 		if env.PrivateDomain != "" {
 			network = "Private"
 		}
+		var initGroupIds []string
+		for _, g := range initGroups {
+			initGroupIds = append(initGroupIds, g.Id)
+		}
 		if _, err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
 			"network":       network,
 			"repoAddr":      st.ssClient.GetRepoAddress("config"),
 			"sshPrivateKey": string(keys.RawPrivateKey()),
-			"authGroups":    strings.Join(initGroups, ","),
+			"authGroups":    strings.Join(initGroupIds, ","),
 		}); err != nil {
 			return err
 		}