welcome: init group memberships for first create (#115)

* rename createAdminAccount to createAccount

* welcome: call memberships init on first user

* auth: add http endpoints to allowed return addresses

* memberships: make init user member of groups as well

---------

Co-authored-by: Giorgi Lekveishvili <lekva@gl-mbp-m1-max.local>
diff --git a/charts/welcome/templates/install.yaml b/charts/welcome/templates/install.yaml
index e828527..dfbac29 100644
--- a/charts/welcome/templates/install.yaml
+++ b/charts/welcome/templates/install.yaml
@@ -111,6 +111,7 @@
         - --port=8080
         - --create-account-addr={{ .Values.createAccountAddr }}
         - --login-addr={{ .Values.loginAddr }}
+        - --memberships-init-addr={{ .Values.membershipsInitAddr }}
         volumeMounts:
         - name: ssh-key
           readOnly: true
diff --git a/charts/welcome/values.yaml b/charts/welcome/values.yaml
index 3aac390..95ac8ec 100644
--- a/charts/welcome/values.yaml
+++ b/charts/welcome/values.yaml
@@ -6,6 +6,7 @@
 sshPrivateKey: key
 createAccountAddr: http://api.core-auth.svc.cluster.local/identities
 loginAddr: https://accounts-ui.example.com
+membershipsInitAddr: http://memberships.example.svc.cluster.local/api/ini
 ingress:
   className: pcloud-ingress-public
   domain: welcome.example.com
diff --git a/core/auth/memberships/main.go b/core/auth/memberships/main.go
index 1358bb3..4dae1a9 100644
--- a/core/auth/memberships/main.go
+++ b/core/auth/memberships/main.go
@@ -116,6 +116,10 @@
 		if _, err := tx.Exec(query, owner, g); err != nil {
 			return err
 		}
+		query = `INSERT INTO user_to_group (username, group_name) VALUES (?, ?)`
+		if _, err := tx.Exec(query, owner, g); err != nil {
+			return err
+		}
 	}
 	return tx.Commit()
 }
diff --git a/core/auth/memberships/store_test.go b/core/auth/memberships/store_test.go
index d55b3f1..8afb6bc 100644
--- a/core/auth/memberships/store_test.go
+++ b/core/auth/memberships/store_test.go
@@ -27,6 +27,13 @@
 	if len(groups) != 2 {
 		t.Fatalf("Expected two groups, got: %s", groups)
 	}
+	groups, err = store.GetGroupsUserBelongsTo("admin")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(groups) != 2 {
+		t.Fatalf("Expected two groups, got: %s", groups)
+	}
 }
 
 func TestInitFailure(t *testing.T) {
diff --git a/core/installer/cmd/welcome.go b/core/installer/cmd/welcome.go
index b1f3259..43578b2 100644
--- a/core/installer/cmd/welcome.go
+++ b/core/installer/cmd/welcome.go
@@ -11,11 +11,12 @@
 )
 
 var welcomeFlags struct {
-	repo              string
-	sshKey            string
-	port              int
-	createAccountAddr string
-	loginAddr         string
+	repo                string
+	sshKey              string
+	port                int
+	createAccountAddr   string
+	loginAddr           string
+	membershipsInitAddr string
 }
 
 func welcomeCmd() *cobra.Command {
@@ -53,6 +54,12 @@
 		"",
 		"",
 	)
+	cmd.Flags().StringVar(
+		&welcomeFlags.membershipsInitAddr,
+		"memberships-init-addr",
+		"",
+		"",
+	)
 	return cmd
 }
 
@@ -83,6 +90,7 @@
 		nsCreator,
 		welcomeFlags.createAccountAddr,
 		welcomeFlags.loginAddr,
+		welcomeFlags.membershipsInitAddr,
 	)
 	s.Start()
 	return nil
diff --git a/core/installer/tasks/infra.go b/core/installer/tasks/infra.go
index c7e6f9e..6fbd55d 100644
--- a/core/installer/tasks/infra.go
+++ b/core/installer/tasks/infra.go
@@ -4,12 +4,15 @@
 	"fmt"
 	"net"
 	"net/netip"
+	"strings"
 
 	"github.com/miekg/dns"
 
 	"github.com/giolekva/pcloud/core/installer"
 )
 
+var initGroups = []string{"admin"}
+
 func SetupInfra(env Env, startIP net.IP, st *state) []Task {
 	t := newLeafTask("Create client", func() error {
 		repo, err := st.ssClient.GetRepo("config")
@@ -29,6 +32,7 @@
 	})
 	return []Task{
 		CommitEnvironmentConfiguration(env, st),
+		ConfigureFirstAccount(env, st),
 		&t,
 		newConcurrentParentTask(
 			"Core services",
@@ -103,6 +107,27 @@
 	return &t
 }
 
+type firstAccount struct {
+	Created bool     `json:"created"`
+	Groups  []string `json:"groups"`
+}
+
+func ConfigureFirstAccount(env Env, st *state) Task {
+	t := newLeafTask("Configure first account settings", func() error {
+		repo, err := st.ssClient.GetRepo("config")
+		if err != nil {
+			return err
+		}
+		r := installer.NewRepoIO(repo, st.ssClient.Signer)
+		fa := firstAccount{false, initGroups}
+		if err := r.WriteYaml("first-account.yaml", fa); err != nil {
+			return err
+		}
+		return r.CommitAndPush("first account membership configuration")
+	})
+	return &t
+}
+
 func SetupNetwork(env Env, startIP net.IP, st *state) Task {
 	t := newLeafTask("Setup network", func() error {
 		startAddr, err := netip.ParseAddr(startIP.String())
@@ -236,7 +261,9 @@
 		if err != nil {
 			return err
 		}
-		if err := st.appManager.Install(app, st.nsGen, st.emptySuffixGen, map[string]any{}); err != nil {
+		if err := st.appManager.Install(app, st.nsGen, st.emptySuffixGen, map[string]any{
+			"authGroups": strings.Join(initGroups, ","),
+		}); err != nil {
 			return err
 		}
 		return nil
diff --git a/core/installer/values-tmpl/core-auth.cue b/core/installer/values-tmpl/core-auth.cue
index 0e9f26f..391f127 100644
--- a/core/installer/values-tmpl/core-auth.cue
+++ b/core/installer/values-tmpl/core-auth.cue
@@ -230,6 +230,8 @@
 							allowed_return_urls: [
 								"https://*.\(global.domain)/",
 								"https://*.\(global.privateDomain)",
+								"http://*.\(global.domain)/", // TODO(gio): configure ingress nginx private to autoredirect
+								"http://*.\(global.privateDomain)",
 						    ]
 							methods: {
 								password: {
diff --git a/core/installer/values-tmpl/memberships.cue b/core/installer/values-tmpl/memberships.cue
index 898cffd..da70026 100644
--- a/core/installer/values-tmpl/memberships.cue
+++ b/core/installer/values-tmpl/memberships.cue
@@ -1,4 +1,6 @@
-input: {}
+input: {
+	authGroups: string
+}
 
 _subdomain: "memberships"
 _domain: "\(_subdomain).\(global.privateDomain)"
@@ -15,7 +17,7 @@
 	inp: {
 		auth: {
 			enabled: true
-			groups: "" // TODO(gio): set admin
+			groups: input.authGroups
 		}
 		network: networks.private
 		subdomain: _subdomain
diff --git a/core/installer/values-tmpl/welcome.cue b/core/installer/values-tmpl/welcome.cue
index 9ec17bc..f94a77f 100644
--- a/core/installer/values-tmpl/welcome.cue
+++ b/core/installer/values-tmpl/welcome.cue
@@ -5,8 +5,6 @@
 input: {
 	repoAddr: string
 	sshPrivateKey: string
-	createAccountAddr: string
-	loginAddr: string
 }
 
 name: "welcome"
@@ -40,6 +38,7 @@
 			sshPrivateKey: base64.Encode(null, input.sshPrivateKey)
 			createAccountAddr: "http://api.\(global.namespacePrefix)core-auth.svc.cluster.local/identities"
 			loginAddr: "https://accounts-ui.\(global.domain)"
+			membershipsInitAddr: "http://memberships.\(global.namespacePrefix)core-auth-memberships.svc.cluster.local/api/init"
 			ingress: {
 				className: _ingressPublic
 				domain: "welcome.\(global.domain)"
diff --git a/core/installer/welcome/welcome.go b/core/installer/welcome/welcome.go
index 5184a8e..eb7a2dc 100644
--- a/core/installer/welcome/welcome.go
+++ b/core/installer/welcome/welcome.go
@@ -26,11 +26,12 @@
 var staticAssets embed.FS
 
 type Server struct {
-	port              int
-	repo              installer.RepoIO
-	nsCreator         installer.NamespaceCreator
-	createAccountAddr string
-	loginAddr         string
+	port                int
+	repo                installer.RepoIO
+	nsCreator           installer.NamespaceCreator
+	createAccountAddr   string
+	loginAddr           string
+	membershipsInitAddr string
 }
 
 func NewServer(
@@ -39,6 +40,7 @@
 	nsCreator installer.NamespaceCreator,
 	createAccountAddr string,
 	loginAddr string,
+	membershipsInitAddr string,
 ) *Server {
 	return &Server{
 		port,
@@ -46,19 +48,20 @@
 		nsCreator,
 		createAccountAddr,
 		loginAddr,
+		membershipsInitAddr,
 	}
 }
 
 func (s *Server) Start() {
 	r := mux.NewRouter()
 	r.PathPrefix("/static/").Handler(http.FileServer(http.FS(staticAssets)))
-	r.Path("/").Methods("POST").HandlerFunc(s.createAdminAccount)
-	r.Path("/").Methods("GET").HandlerFunc(s.createAdminAccountForm)
+	r.Path("/").Methods("POST").HandlerFunc(s.createAccount)
+	r.Path("/").Methods("GET").HandlerFunc(s.createAccountForm)
 	http.Handle("/", r)
 	log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", s.port), nil))
 }
 
-func (s *Server) createAdminAccountForm(w http.ResponseWriter, r *http.Request) {
+func (s *Server) createAccountForm(w http.ResponseWriter, r *http.Request) {
 	renderRegistrationForm(w, formData{})
 }
 
@@ -150,7 +153,7 @@
 	}
 }
 
-func (s *Server) createAdminAccount(w http.ResponseWriter, r *http.Request) {
+func (s *Server) createAccount(w http.ResponseWriter, r *http.Request) {
 	req, err := extractReq(r)
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -197,6 +200,10 @@
 			return
 		}
 	}
+	if err := s.initMemberships(req.Username); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
 	{
 		config, err := s.repo.ReadConfig()
 		if err != nil {
@@ -234,3 +241,40 @@
 	}
 	renderRegistrationSuccess(w, s.loginAddr)
 }
+
+type firstaccount struct {
+	Created bool     `json:"created"`
+	Groups  []string `json:"groups"`
+}
+
+type initRequest struct {
+	Owner  string   `json:"owner"`
+	Groups []string `json:"groups"`
+}
+
+func (s *Server) initMemberships(username string) error {
+	inp, err := s.repo.Reader("first-account.yaml")
+	if err != nil {
+		return err
+	}
+	var fa firstaccount
+	if err := installer.ReadYaml(inp, &fa); err != nil {
+		return err
+	}
+	if fa.Created {
+		return nil
+	}
+	req := initRequest{username, fa.Groups}
+	var buf bytes.Buffer
+	if err := json.NewEncoder(&buf).Encode(req); err != nil {
+		return err
+	}
+	if _, err = http.Post(s.membershipsInitAddr, "applications/json", &buf); err != nil {
+		return err
+	}
+	fa.Created = true
+	if err := s.repo.WriteYaml("first-account.yaml", fa); err != nil {
+		return err
+	}
+	return s.repo.CommitAndPush("initialized groups for first account")
+}