Просмотр исходного кода

Merge pull request #1523 from porter-dev/nico/por-203-add-allowlist-to-create-projects

[POR-203] Add allowlist to create projects
Nicolas Frati 4 лет назад
Родитель
Сommit
4ebf7fadc1

+ 52 - 0
api/server/handlers/user/can_create_project.go

@@ -0,0 +1,52 @@
+package user
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type CanCreateProject struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewCanCreateProjectHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CanCreateProject {
+	return &CanCreateProject{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *CanCreateProject) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	if c.Config().ServerConf.DisableAllowlist {
+		c.WriteResult(w, r, "")
+		return
+	}
+
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+
+	exists, err := c.Repo().Allowlist().UserEmailExists(user.Email)
+
+	if err != nil {
+		err = fmt.Errorf("couldn't retrieve user: %s", err.Error())
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if !exists {
+		err = fmt.Errorf("user is not authorized")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, 403))
+		return
+	}
+
+	c.WriteResult(w, r, "")
+}

+ 25 - 0
api/server/router/user.go

@@ -421,5 +421,30 @@ func getUserRoutes(
 		Router:   r,
 	})
 
+	// GET /api/can_create_project -> user.CanCreateProject
+	canCreateProjectEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: "/can_create_project",
+			},
+			Scopes: []types.PermissionScope{types.UserScope},
+		},
+	)
+
+	canCreateProjectHandler := user.NewCanCreateProjectHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: canCreateProjectEndpoint,
+		Handler:  canCreateProjectHandler,
+		Router:   r,
+	})
+
 	return routes
 }

+ 3 - 0
api/server/shared/config/env/envconfs.go

@@ -88,6 +88,9 @@ type ServerConf struct {
 
 	// Enable pprof profiling endpoints
 	PprofEnabled bool `env:"PPROF_ENABLED,default=false"`
+
+	// Disable filtering for project creation
+	DisableAllowlist bool `env:"DISABLE_ALLOWLIST,default=false"`
 }
 
 // DBConf is the database configuration: if generated from environment variables,

+ 21 - 0
dashboard/src/main/home/Home.tsx

@@ -136,8 +136,25 @@ class Home extends Component<PropsType, StateType> {
       .catch(console.log);
   };
 
+  checkIfCanCreateProject = () => {
+    api
+      .getCanCreateProject("<token>", {}, {})
+      .then((res) => {
+        if (res.status === 403) {
+          this.context.setCanCreateProject(false);
+          return;
+        }
+        this.context.setCanCreateProject(true);
+      })
+      .catch((err) => {
+        this.context.setCanCreateProject(false);
+        console.error(err);
+      });
+  };
+
   componentDidMount() {
     this.checkOnboarding();
+    this.checkIfCanCreateProject();
     let { match } = this.props;
 
     let { user } = this.context;
@@ -176,6 +193,10 @@ class Home extends Component<PropsType, StateType> {
     }
   }
 
+  componentWillUnmount(): void {
+    this.context.setCanCreateProject(false);
+  }
+
   async checkIfProjectHasBilling(projectId: number) {
     if (!projectId) {
       return false;

+ 10 - 2
dashboard/src/main/home/new-project/NewProject.tsx

@@ -1,4 +1,4 @@
-import React, { useContext, useMemo, useState } from "react";
+import React, { useContext, useEffect, useMemo, useState } from "react";
 
 import { useRouting } from "shared/routing";
 import api from "shared/api";
@@ -22,12 +22,20 @@ type ValidationError = {
 };
 
 export const NewProjectFC = () => {
-  const { user, setProjects, setCurrentProject } = useContext(Context);
+  const { user, setProjects, setCurrentProject, canCreateProject } = useContext(
+    Context
+  );
   const { pushFiltered } = useRouting();
   const [buttonStatus, setButtonStatus] = useState("");
   const [name, setName] = useState("");
   const { projects } = useContext(Context);
 
+  useEffect(() => {
+    if (!canCreateProject) {
+      pushFiltered("/", []);
+    }
+  }, [canCreateProject]);
+
   const isFirstProject = useMemo(() => {
     return !(projects?.length >= 1);
   }, [projects]);

+ 12 - 10
dashboard/src/main/home/sidebar/ProjectSection.tsx

@@ -74,16 +74,18 @@ class ProjectSection extends Component<PropsType, StateType> {
         <div>
           <Dropdown>
             {this.renderOptionList()}
-            <Option
-              selected={false}
-              lastItem={true}
-              onClick={() =>
-                pushFiltered(this.props, "/new-project", ["project_id"])
-              }
-            >
-              <ProjectIconAlt>+</ProjectIconAlt>
-              <ProjectLabel>Create a Project</ProjectLabel>
-            </Option>
+            {this.context.canCreateProject && (
+              <Option
+                selected={false}
+                lastItem={true}
+                onClick={() =>
+                  pushFiltered(this.props, "/new-project", ["project_id"])
+                }
+              >
+                <ProjectIconAlt>+</ProjectIconAlt>
+                <ProjectLabel>Create a Project</ProjectLabel>
+              </Option>
+            )}
           </Dropdown>
         </div>
       );

+ 6 - 0
dashboard/src/shared/Context.tsx

@@ -60,6 +60,8 @@ export interface GlobalContextType {
   queryUsage: (retry?: number) => Promise<void>;
   hasFinishedOnboarding: boolean;
   setHasFinishedOnboarding: (onboardingStatus: boolean) => void;
+  canCreateProject: boolean;
+  setCanCreateProject: (canCreateProject: boolean) => void;
 }
 
 /**
@@ -181,6 +183,10 @@ class ContextProvider extends Component<PropsType, StateType> {
     setHasFinishedOnboarding: (onboardingStatus) => {
       this.setState({ hasFinishedOnboarding: onboardingStatus });
     },
+    canCreateProject: false,
+    setCanCreateProject: (canCreateProject: boolean) => {
+      this.setState({ canCreateProject });
+    },
   };
 
   render() {

+ 6 - 0
dashboard/src/shared/api.tsx

@@ -1204,6 +1204,11 @@ const getLogBucketLogs = baseApi<
     `/api/projects/${project_id}/clusters/${cluster_id}/kube_events/${kube_event_id}/logs`
 );
 
+const getCanCreateProject = baseApi<{}, {}>(
+  "GET",
+  () => "/api/can_create_project"
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -1328,4 +1333,5 @@ export default {
   getKubeEvent,
   getLogBuckets,
   getLogBucketLogs,
+  getCanCreateProject,
 };

+ 2 - 0
dashboard/src/shared/types.tsx

@@ -305,6 +305,8 @@ export interface ContextProps {
   queryUsage: () => Promise<void>;
   hasFinishedOnboarding: boolean;
   setHasFinishedOnboarding: (onboardingStatus: boolean) => void;
+  canCreateProject: boolean;
+  setCanCreateProject: (canCreateProject: boolean) => void;
 }
 
 export enum JobStatusType {

+ 12 - 0
internal/models/allowlist.go

@@ -0,0 +1,12 @@
+package models
+
+import (
+	"gorm.io/gorm"
+)
+
+// Allowlist is a simple list with all the users emails allowed to create new projects
+type Allowlist struct {
+	gorm.Model
+
+	UserEmail string `json:"user_email" gorm:"unique;not null"`
+}

+ 7 - 0
internal/repository/allowlist.go

@@ -0,0 +1,7 @@
+package repository
+
+// AllowlistRepository represents the set of queries on the
+// Allowlist model
+type AllowlistRepository interface {
+	UserEmailExists(email string) (bool, error)
+}

+ 33 - 0
internal/repository/gorm/allowlist.go

@@ -0,0 +1,33 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// AllowlistRepository uses gorm.DB for querying the database
+type AllowlistRepository struct {
+	db *gorm.DB
+}
+
+// NewAllowlistRepository returns a AllowListRepository which uses
+// gorm.DB for querying the database.
+func NewAllowlistRepository(db *gorm.DB) repository.AllowlistRepository {
+	return &AllowlistRepository{db}
+}
+
+func (repo *AllowlistRepository) UserEmailExists(email string) (bool, error) {
+	al := &models.Allowlist{}
+	result := repo.db.Where("user_email = ?", email).Find(&al)
+
+	if err := result.Error; err != nil {
+		return false, err
+	}
+
+	if result.RowsAffected > 0 {
+		return true, nil
+	}
+
+	return false, nil
+}

+ 49 - 0
internal/repository/gorm/allowlist_test.go

@@ -0,0 +1,49 @@
+package gorm_test
+
+import (
+	"testing"
+)
+
+func TestUserEmailExistsOnAllowlist(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_create_allowlist.db",
+	}
+
+	setupTestEnv(tester, t)
+	initAllowlist(tester, t)
+	defer cleanup(tester, t)
+
+	expected := true
+
+	found, err := tester.repo.Allowlist().UserEmailExists("some@email.com")
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	if found != expected {
+		t.Errorf("expected found to be %t but got: %t", expected, found)
+	}
+}
+
+func TestUserDontExistsOnAllowList(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_create_allowlist.db",
+	}
+
+	setupTestEnv(tester, t)
+	initAllowlist(tester, t)
+	defer cleanup(tester, t)
+
+	expected := false
+
+	found, err := tester.repo.Allowlist().UserEmailExists("nonexisting@email.com")
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	if found != expected {
+		t.Errorf("expected found to be %t but got: %t", expected, found)
+	}
+}

+ 18 - 1
internal/repository/gorm/helpers_test.go

@@ -13,12 +13,15 @@ import (
 	ints "github.com/porter-dev/porter/internal/models/integrations"
 	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/repository/gorm"
+
+	_gorm "gorm.io/gorm"
 )
 
 type tester struct {
 	repo           repository.Repository
 	key            *[32]byte
 	dbFileName     string
+	db             *_gorm.DB
 	initUsers      []*models.User
 	initProjects   []*models.Project
 	initGRs        []*models.GitRepo
@@ -36,6 +39,7 @@ type tester struct {
 	initOAuths     []*ints.OAuthIntegration
 	initGCPs       []*ints.GCPIntegration
 	initAWSs       []*ints.AWSIntegration
+	initAllowlist  []*models.Allowlist
 }
 
 func setupTestEnv(tester *tester, t *testing.T) {
@@ -69,6 +73,7 @@ func setupTestEnv(tester *tester, t *testing.T) {
 		&models.KubeEvent{},
 		&models.KubeSubEvent{},
 		&models.Onboarding{},
+		&models.Allowlist{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},
@@ -92,7 +97,7 @@ func setupTestEnv(tester *tester, t *testing.T) {
 	}
 
 	tester.key = &key
-
+	tester.db = db
 	tester.repo = gorm.NewRepository(db, &key, nil)
 }
 
@@ -120,6 +125,18 @@ func initUser(tester *tester, t *testing.T) {
 	tester.initUsers = append(tester.initUsers, user)
 }
 
+func initAllowlist(tester *tester, t *testing.T) {
+	t.Helper()
+
+	allowedUser := &models.Allowlist{
+		UserEmail: "some@email.com",
+	}
+
+	tester.db.Create(&allowedUser)
+
+	tester.initAllowlist = append(tester.initAllowlist, allowedUser)
+}
+
 func initMultiUser(tester *tester, t *testing.T) {
 	t.Helper()
 

+ 1 - 0
internal/repository/gorm/migrate.go

@@ -36,6 +36,7 @@ func AutoMigrate(db *gorm.DB) error {
 		&models.Onboarding{},
 		&models.CredentialsExchangeToken{},
 		&models.BuildConfig{},
+		&models.Allowlist{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},

+ 6 - 0
internal/repository/gorm/repository.go

@@ -37,6 +37,7 @@ type GormRepository struct {
 	onboarding                repository.ProjectOnboardingRepository
 	ceToken                   repository.CredentialsExchangeTokenRepository
 	buildConfig               repository.BuildConfigRepository
+	allowlist                 repository.AllowlistRepository
 }
 
 func (t *GormRepository) User() repository.UserRepository {
@@ -159,6 +160,10 @@ func (t *GormRepository) BuildConfig() repository.BuildConfigRepository {
 	return t.buildConfig
 }
 
+func (t *GormRepository) Allowlist() repository.AllowlistRepository {
+	return t.allowlist
+}
+
 // NewRepository returns a Repository which persists users in memory
 // and accepts a parameter that can trigger read/write errors
 func NewRepository(db *gorm.DB, key *[32]byte, storageBackend credentials.CredentialStorage) repository.Repository {
@@ -193,5 +198,6 @@ func NewRepository(db *gorm.DB, key *[32]byte, storageBackend credentials.Creden
 		onboarding:                NewProjectOnboardingRepository(db),
 		ceToken:                   NewCredentialsExchangeTokenRepository(db),
 		buildConfig:               NewBuildConfigRepository(db),
+		allowlist:                 NewAllowlistRepository(db),
 	}
 }

+ 1 - 0
internal/repository/repository.go

@@ -31,4 +31,5 @@ type Repository interface {
 	Onboarding() ProjectOnboardingRepository
 	CredentialsExchangeToken() CredentialsExchangeTokenRepository
 	BuildConfig() BuildConfigRepository
+	Allowlist() AllowlistRepository
 }

+ 41 - 0
internal/repository/test/allowlist.go

@@ -0,0 +1,41 @@
+package test
+
+import (
+	"errors"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+// AllowlistRepository uses gorm.DB for querying the database
+type AllowlistRepository struct {
+	canQuery  bool
+	allowlist []*models.Allowlist
+}
+
+// NewAllowlistRepository returns a AllowListRepository which uses
+// gorm.DB for querying the database.
+func NewAllowlistRepository(canQuery bool) repository.AllowlistRepository {
+	return &AllowlistRepository{canQuery, []*models.Allowlist{}}
+}
+
+func (repo *AllowlistRepository) UserEmailExists(email string) (bool, error) {
+	if !repo.canQuery {
+		return false, errors.New("cannot read database")
+	}
+
+	if len(repo.allowlist) == 0 {
+		return false, nil
+	}
+
+	founded := false
+
+	for _, allowed := range repo.allowlist {
+		if allowed.UserEmail == email {
+			founded = true
+			break
+		}
+	}
+
+	return founded, nil
+}

+ 6 - 0
internal/repository/test/repository.go

@@ -35,6 +35,7 @@ type TestRepository struct {
 	onboarding                repository.ProjectOnboardingRepository
 	ceToken                   repository.CredentialsExchangeTokenRepository
 	buildConfig               repository.BuildConfigRepository
+	allowlist                 repository.AllowlistRepository
 }
 
 func (t *TestRepository) User() repository.UserRepository {
@@ -157,6 +158,10 @@ func (t *TestRepository) BuildConfig() repository.BuildConfigRepository {
 	return t.buildConfig
 }
 
+func (t *TestRepository) Allowlist() repository.AllowlistRepository {
+	return t.allowlist
+}
+
 // NewRepository returns a Repository which persists users in memory
 // and accepts a parameter that can trigger read/write errors
 func NewRepository(canQuery bool, failingMethods ...string) repository.Repository {
@@ -191,5 +196,6 @@ func NewRepository(canQuery bool, failingMethods ...string) repository.Repositor
 		onboarding:                NewProjectOnboardingRepository(canQuery),
 		ceToken:                   NewCredentialsExchangeTokenRepository(canQuery),
 		buildConfig:               NewBuildConfigRepository(canQuery),
+		allowlist:                 NewAllowlistRepository(canQuery),
 	}
 }