DodoApp: Restrict users to one domain

Change-Id: I4d09d5ee61d0ec712fd9dfa848c0af0c8e550d68
diff --git a/core/installer/welcome/dodo-app-tmpl/index.html b/core/installer/welcome/dodo-app-tmpl/index.html
index e9a9b11..af82173 100644
--- a/core/installer/welcome/dodo-app-tmpl/index.html
+++ b/core/installer/welcome/dodo-app-tmpl/index.html
@@ -5,6 +5,15 @@
 		<meta charset='utf-8'>
 	</head>
 	<body>
+		<form action="" method="POST">
+			<select name="network">
+				{{ range .Networks }}
+				<option value="{{ .Name }}">{{ .Name }} - {{ .Domain }}</option>
+				{{ end }}
+			</select>
+			<input type="text" name="admin-public-key" placeholder="Admin Public Key" />
+			<button type="submit" name="create-app">Create App</button>
+		</form>
 		{{ range .Apps }}
 		<a href="/{{ . }}">{{ . }}</a>
 		{{ end }}
diff --git a/core/installer/welcome/dodo_app.go b/core/installer/welcome/dodo_app.go
index 1c91cbc..1315266 100644
--- a/core/installer/welcome/dodo_app.go
+++ b/core/installer/welcome/dodo_app.go
@@ -49,15 +49,17 @@
 type DodoAppServer struct {
 	l                 sync.Locker
 	st                Store
+	nf                NetworkFilter
+	ug                UserGetter
 	port              int
 	apiPort           int
 	self              string
+	repoPublicAddr    string
 	sshKey            string
 	gitRepoPublicKey  string
 	client            soft.Client
 	namespace         string
 	envAppManagerAddr string
-	networks          []string
 	env               installer.EnvConfig
 	nsc               installer.NamespaceCreator
 	jc                installer.JobCreator
@@ -70,15 +72,17 @@
 // TODO(gio): Initialize appNs on startup
 func NewDodoAppServer(
 	st Store,
+	nf NetworkFilter,
+	ug UserGetter,
 	port int,
 	apiPort int,
 	self string,
+	repoPublicAddr string,
 	sshKey string,
 	gitRepoPublicKey string,
 	client soft.Client,
 	namespace string,
 	envAppManagerAddr string,
-	networks []string,
 	nsc installer.NamespaceCreator,
 	jc installer.JobCreator,
 	env installer.EnvConfig,
@@ -94,15 +98,17 @@
 	s := &DodoAppServer{
 		&sync.Mutex{},
 		st,
+		nf,
+		ug,
 		port,
 		apiPort,
 		self,
+		repoPublicAddr,
 		sshKey,
 		gitRepoPublicKey,
 		client,
 		namespace,
 		envAppManagerAddr,
-		networks,
 		env,
 		nsc,
 		jc,
@@ -137,6 +143,7 @@
 		r.HandleFunc("/{app-name}"+loginPath, s.handleLogin).Methods(http.MethodPost)
 		r.HandleFunc("/{app-name}", s.handleAppStatus).Methods(http.MethodGet)
 		r.HandleFunc("/", s.handleStatus).Methods(http.MethodGet)
+		r.HandleFunc("/", s.handleCreateApp).Methods(http.MethodPost)
 		e <- http.ListenAndServe(fmt.Sprintf(":%d", s.port), r)
 	}()
 	go func() {
@@ -150,14 +157,48 @@
 	return <-e
 }
 
+type UserGetter interface {
+	Get(r *http.Request) string
+}
+
+type externalUserGetter struct {
+	sc *securecookie.SecureCookie
+}
+
+func NewExternalUserGetter() UserGetter {
+	return &externalUserGetter{}
+}
+
+func (ug *externalUserGetter) Get(r *http.Request) string {
+	cookie, err := r.Cookie(sessionCookie)
+	if err != nil {
+		return ""
+	}
+	var user string
+	if err := ug.sc.Decode(sessionCookie, cookie.Value, &user); err != nil {
+		return ""
+	}
+	return user
+}
+
+type internalUserGetter struct{}
+
+func NewInternalUserGetter() UserGetter {
+	return internalUserGetter{}
+}
+
+func (ug internalUserGetter) Get(r *http.Request) string {
+	return r.Header.Get("X-User")
+}
+
 func (s *DodoAppServer) mwAuth(next http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		if strings.HasSuffix(r.URL.Path, loginPath) || strings.HasPrefix(r.URL.Path, logoutPath) {
 			next.ServeHTTP(w, r)
 			return
 		}
-		cookie, err := r.Cookie(sessionCookie)
-		if err != nil {
+		user := s.ug.Get(r)
+		if user == "" {
 			vars := mux.Vars(r)
 			appName, ok := vars["app-name"]
 			if !ok || appName == "" {
@@ -167,11 +208,6 @@
 			http.Redirect(w, r, fmt.Sprintf("/%s%s", appName, loginPath), http.StatusSeeOther)
 			return
 		}
-		var user string
-		if err := s.sc.Decode(sessionCookie, cookie.Value, &user); err != nil {
-			http.Error(w, "unauthorized", http.StatusUnauthorized)
-			return
-		}
 		next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userCtx, user)))
 	})
 }
@@ -251,7 +287,8 @@
 }
 
 type statusData struct {
-	Apps []string
+	Apps     []string
+	Networks []installer.Network
 }
 
 func (s *DodoAppServer) handleStatus(w http.ResponseWriter, r *http.Request) {
@@ -265,7 +302,12 @@
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
-	data := statusData{apps}
+	networks, err := s.getNetworks(user.(string))
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	data := statusData{apps, networks}
 	if err := s.tmplts.index.Execute(w, data); err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
@@ -279,6 +321,7 @@
 		http.Error(w, "missing app-name", http.StatusBadRequest)
 		return
 	}
+	fmt.Fprintf(w, "git clone %s/%s\n\n\n", s.repoPublicAddr, appName)
 	commits, err := s.st.GetCommitHistory(appName)
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -313,7 +356,11 @@
 	}
 	// TODO(gio): Create commit record on app init as well
 	go func() {
-		networks, err := s.getNetworks()
+		owner, err := s.st.GetAppOwner(req.Repository.Name)
+		if err != nil {
+			return
+		}
+		networks, err := s.getNetworks(owner)
 		if err != nil {
 			return
 		}
@@ -357,9 +404,60 @@
 	s.workers[appName][req.Address] = struct{}{}
 }
 
+func (s *DodoAppServer) handleCreateApp(w http.ResponseWriter, r *http.Request) {
+	u := r.Context().Value(userCtx)
+	if u == nil {
+		http.Error(w, "unauthorized", http.StatusUnauthorized)
+		return
+	}
+	user, ok := u.(string)
+	if !ok {
+		http.Error(w, "could not get user", http.StatusInternalServerError)
+		return
+	}
+	network := r.FormValue("network")
+	if network == "" {
+		http.Error(w, "missing network", http.StatusBadRequest)
+		return
+	}
+	adminPublicKey := r.FormValue("admin-public-key")
+	if network == "" {
+		http.Error(w, "missing admin public key", http.StatusBadRequest)
+		return
+	}
+	g := installer.NewFixedLengthRandomNameGenerator(3)
+	appName, err := g.Generate()
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	if ok, err := s.client.UserExists(user); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	} else if !ok {
+		if err := s.client.AddUser(user, adminPublicKey); err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+	}
+	if err := s.st.CreateUser(user, nil, adminPublicKey, network); err != nil && !errors.Is(err, ErrorAlreadyExists) {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	if err := s.st.CreateApp(appName, user); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	if err := s.CreateApp(user, appName, adminPublicKey, network); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	http.Redirect(w, r, fmt.Sprintf("/%s", appName), http.StatusSeeOther)
+}
+
 type apiCreateAppReq struct {
 	AdminPublicKey string `json:"adminPublicKey"`
-	NetworkName    string `json:"networkName"`
+	Network        string `json:"network"`
 }
 
 type apiCreateAppResp struct {
@@ -379,11 +477,38 @@
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
-	password, err := s.CreateApp(appName, req.AdminPublicKey, req.NetworkName)
+	user, err := s.client.FindUser(req.AdminPublicKey)
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
+	if user != "" {
+		http.Error(w, "public key already registered", http.StatusBadRequest)
+		return
+	}
+	user = appName
+	if err := s.client.AddUser(user, req.AdminPublicKey); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	password := generatePassword()
+	hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	if err := s.st.CreateUser(user, hashed, req.AdminPublicKey, req.Network); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	if err := s.st.CreateApp(appName, user); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	if err := s.CreateApp(user, appName, req.AdminPublicKey, req.Network); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
 	resp := apiCreateAppResp{
 		AppName:  appName,
 		Password: password,
@@ -394,81 +519,52 @@
 	}
 }
 
-func (s *DodoAppServer) CreateApp(appName, adminPublicKey, networkName string) (string, error) {
+func (s *DodoAppServer) CreateApp(user, appName, adminPublicKey, network string) error {
 	s.l.Lock()
 	defer s.l.Unlock()
 	fmt.Printf("Creating app: %s\n", appName)
 	if ok, err := s.client.RepoExists(appName); err != nil {
-		return "", err
+		return err
 	} else if ok {
-		return "", nil
-	}
-	user, err := s.client.FindUser(adminPublicKey)
-	if err != nil {
-		return "", err
-	}
-	if user == "" {
-		user = appName
-		if err := s.client.AddUser(user, adminPublicKey); err != nil {
-			return "", err
-		}
-	}
-	password := generatePassword()
-	// TODO(gio): take admin password for initial application as input
-	if appName == "app" {
-		password = "app"
-	}
-	hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
-	if err != nil {
-		return "", err
-	}
-	if err := s.st.CreateUser(user, hashed); err != nil {
-		if !errors.Is(err, ErrorAlreadyExists) {
-			return "", err
-		} else {
-			password = ""
-		}
-	}
-	if err := s.st.CreateApp(appName, user); err != nil {
-		return "", err
+		return nil
 	}
 	if err := s.client.AddRepository(appName); err != nil {
-		return "", err
+		return err
 	}
 	appRepo, err := s.client.GetRepo(appName)
 	if err != nil {
-		return "", err
+		return err
 	}
-	if err := InitRepo(appRepo, networkName); err != nil {
-		return "", err
+	if err := InitRepo(appRepo, network); err != nil {
+		return err
 	}
 	apps := installer.NewInMemoryAppRepository(installer.CreateAllApps())
 	app, err := installer.FindEnvApp(apps, "dodo-app-instance")
 	if err != nil {
-		return "", err
+		return err
 	}
 	suffixGen := installer.NewFixedLengthRandomSuffixGenerator(3)
 	suffix, err := suffixGen.Generate()
 	if err != nil {
-		return "", err
+		return err
 	}
 	namespace := fmt.Sprintf("%s%s%s", s.env.NamespacePrefix, app.Namespace(), suffix)
 	s.appNs[appName] = namespace
-	networks, err := s.getNetworks()
+	networks, err := s.getNetworks(user)
 	if err != nil {
-		return "", err
+		return err
 	}
 	if err := s.updateDodoApp(appName, namespace, networks); err != nil {
-		return "", err
+		return err
 	}
 	repo, err := s.client.GetRepo(ConfigRepoName)
 	if err != nil {
-		return "", err
+		return err
 	}
 	hf := installer.NewGitHelmFetcher()
 	m, err := installer.NewAppManager(repo, s.nsc, s.jc, hf, "/")
 	if err != nil {
-		return "", err
+		return err
 	}
 	if err := repo.Do(func(fs soft.RepoFS) (string, error) {
 		w, err := fs.Writer(namespacesFile)
@@ -498,41 +594,41 @@
 		}
 		return fmt.Sprintf("Installed app: %s", appName), nil
 	}); err != nil {
-		return "", err
+		return err
 	}
 	cfg, err := m.FindInstance(appName)
 	if err != nil {
-		return "", err
+		return err
 	}
 	fluxKeys, ok := cfg.Input["fluxKeys"]
 	if !ok {
-		return "", fmt.Errorf("Fluxcd keys not found")
+		return fmt.Errorf("Fluxcd keys not found")
 	}
 	fluxPublicKey, ok := fluxKeys.(map[string]any)["public"]
 	if !ok {
-		return "", fmt.Errorf("Fluxcd keys not found")
+		return fmt.Errorf("Fluxcd keys not found")
 	}
 	if ok, err := s.client.UserExists("fluxcd"); err != nil {
-		return "", err
+		return err
 	} else if ok {
 		if err := s.client.AddPublicKey("fluxcd", fluxPublicKey.(string)); err != nil {
-			return "", err
+			return err
 		}
 	} else {
 		if err := s.client.AddUser("fluxcd", fluxPublicKey.(string)); err != nil {
-			return "", err
+			return err
 		}
 	}
 	if err := s.client.AddReadOnlyCollaborator(appName, "fluxcd"); err != nil {
-		return "", err
+		return err
 	}
 	if err := s.client.AddWebhook(appName, fmt.Sprintf("http://%s/update", s.self), "--active=true", "--events=push", "--content-type=json"); err != nil {
-		return "", err
+		return err
 	}
 	if err := s.client.AddReadWriteCollaborator(appName, user); err != nil {
-		return "", err
+		return err
 	}
-	return password, nil
+	return nil
 }
 
 type apiAddAdminKeyReq struct {
@@ -630,7 +726,7 @@
 }
 `
 
-func InitRepo(repo soft.RepoIO, networkName string) error {
+func InitRepo(repo soft.RepoIO, network string) error {
 	return repo.Do(func(fs soft.RepoFS) (string, error) {
 		{
 			w, err := fs.Writer("go.mod")
@@ -654,7 +750,7 @@
 				return "", err
 			}
 			defer w.Close()
-			fmt.Fprintf(w, appCue, networkName)
+			fmt.Fprintf(w, appCue, network)
 		}
 		return "go web app template", nil
 	})
@@ -664,7 +760,7 @@
 	return "foo"
 }
 
-func (s *DodoAppServer) getNetworks() ([]installer.Network, error) {
+func (s *DodoAppServer) getNetworks(user string) ([]installer.Network, error) {
 	addr := fmt.Sprintf("%s/api/networks", s.envAppManagerAddr)
 	resp, err := http.Get(addr)
 	if err != nil {
@@ -674,14 +770,88 @@
 	if json.NewDecoder(resp.Body).Decode(&networks); err != nil {
 		return nil, err
 	}
-	if len(s.networks) == 0 {
-		return networks, nil
+	return s.nf.Filter(user, networks)
+}
+
+func pickNetwork(networks []installer.Network, network string) []installer.Network {
+	for _, n := range networks {
+		if n.Name == network {
+			return []installer.Network{n}
+		}
+	}
+	return []installer.Network{}
+}
+
+type NetworkFilter interface {
+	Filter(user string, networks []installer.Network) ([]installer.Network, error)
+}
+
+type noNetworkFilter struct{}
+
+func NewNoNetworkFilter() NetworkFilter {
+	return noNetworkFilter{}
+}
+
+func (f noNetworkFilter) Filter(app string, networks []installer.Network) ([]installer.Network, error) {
+	return networks, nil
+}
+
+type filterByOwner struct {
+	st Store
+}
+
+func NewNetworkFilterByOwner(st Store) NetworkFilter {
+	return &filterByOwner{st}
+}
+
+func (f *filterByOwner) Filter(user string, networks []installer.Network) ([]installer.Network, error) {
+	network, err := f.st.GetUserNetwork(user)
+	if err != nil {
+		return nil, err
 	}
 	ret := []installer.Network{}
 	for _, n := range networks {
-		if slices.Contains(s.networks, n.Name) {
+		if n.Name == network {
 			ret = append(ret, n)
 		}
 	}
 	return ret, nil
 }
+
+type allowListFilter struct {
+	allowed []string
+}
+
+func NewAllowListFilter(allowed []string) NetworkFilter {
+	return &allowListFilter{allowed}
+}
+
+func (f *allowListFilter) Filter(app string, networks []installer.Network) ([]installer.Network, error) {
+	ret := []installer.Network{}
+	for _, n := range networks {
+		if slices.Contains(f.allowed, n.Name) {
+			ret = append(ret, n)
+		}
+	}
+	return ret, nil
+}
+
+type combinedNetworkFilter struct {
+	filters []NetworkFilter
+}
+
+func NewCombinedFilter(filters ...NetworkFilter) NetworkFilter {
+	return &combinedNetworkFilter{filters}
+}
+
+func (f *combinedNetworkFilter) Filter(app string, networks []installer.Network) ([]installer.Network, error) {
+	ret := networks
+	var err error
+	for _, f := range f.filters {
+		ret, err = f.Filter(app, ret)
+		if err != nil {
+			return nil, err
+		}
+	}
+	return ret, nil
+}
diff --git a/core/installer/welcome/store.go b/core/installer/welcome/store.go
index 857045c..e93c03e 100644
--- a/core/installer/welcome/store.go
+++ b/core/installer/welcome/store.go
@@ -10,7 +10,7 @@
 )
 
 const (
-	errorUniqueConstraintViolation = 2067
+	errorConstraintPrimaryKeyViolation = 1555
 )
 
 var (
@@ -23,8 +23,10 @@
 }
 
 type Store interface {
-	CreateUser(username string, password []byte) error
+	// TODO(gio): Remove publicKey once auto user sync is implemented
+	CreateUser(username string, password []byte, publicKey, network string) error
 	GetUserPassword(username string) ([]byte, error)
+	GetUserNetwork(username string) (string, error)
 	GetApps() ([]string, error)
 	GetUserApps(username string) ([]string, error)
 	CreateApp(name, username string) error
@@ -50,7 +52,9 @@
 	_, err := s.db.Exec(`
 		CREATE TABLE IF NOT EXISTS users (
 			username TEXT PRIMARY KEY,
-            password BLOB
+            password BLOB,
+            public_key TEXT,
+            network TEXT
 		);
 		CREATE TABLE IF NOT EXISTS apps (
 			name TEXT PRIMARY KEY,
@@ -66,12 +70,12 @@
 
 }
 
-func (s *storeImpl) CreateUser(username string, password []byte) error {
-	query := `INSERT INTO users (username, password) VALUES (?, ?)`
-	_, err := s.db.Exec(query, username, password)
+func (s *storeImpl) CreateUser(username string, password []byte, publicKey, network string) error {
+	query := `INSERT INTO users (username, password, public_key, network) VALUES (?, ?, ?, ?)`
+	_, err := s.db.Exec(query, username, password, publicKey, network)
 	if err != nil {
 		sqliteErr, ok := err.(*sqlite3.Error)
-		if ok && sqliteErr.ExtendedCode() == errorUniqueConstraintViolation {
+		if ok && sqliteErr.ExtendedCode() == errorConstraintPrimaryKeyViolation {
 			return ErrorAlreadyExists
 		}
 	}
@@ -91,6 +95,22 @@
 	return ret, nil
 }
 
+func (s *storeImpl) GetUserNetwork(username string) (string, error) {
+	query := `SELECT network FROM users WHERE username = ?`
+	row := s.db.QueryRow(query, username)
+	if err := row.Err(); err != nil {
+		return "", err
+	}
+	var ret string
+	if err := row.Scan(&ret); err != nil {
+		if errors.Is(sql.ErrNoRows, err) {
+			return "", nil
+		}
+		return "", err
+	}
+	return ret, nil
+}
+
 func (s *storeImpl) CreateApp(name, username string) error {
 	query := `INSERT INTO apps (name, username) VALUES (?, ?)`
 	_, err := s.db.Exec(query, name, username)