Procházet zdrojové kódy

Merge branch 'belanger/por-83-usage-enforcement' of https://github.com/porter-dev/porter into belanger/por-83-usage-enforcement

jusrhee před 4 roky
rodič
revize
d164a5c462
82 změnil soubory, kde provedl 2916 přidání a 100 odebrání
  1. 2 1
      .air.toml
  2. 1 1
      .github/workflows/dev.yaml
  3. 1 1
      .github/workflows/production.yaml
  4. 1 1
      .github/workflows/staging.yaml
  5. 7 3
      LICENSE
  6. 5 5
      api/server/authz/policy_test.go
  7. 2 2
      api/server/authz/project_test.go
  8. 36 0
      api/server/handlers/billing/billing_ce.go
  9. 28 0
      api/server/handlers/billing/billing_ee.go
  10. 18 0
      api/server/handlers/handler.go
  11. 68 0
      api/server/handlers/invite/invite_ce.go
  12. 44 0
      api/server/handlers/invite/invite_ee.go
  13. 28 9
      api/server/handlers/project/create.go
  14. 8 0
      api/server/handlers/project/delete_role.go
  15. 1 1
      api/server/handlers/project/get_test.go
  16. 50 0
      api/server/handlers/project/get_usage.go
  17. 2 2
      api/server/handlers/project/list_test.go
  18. 8 0
      api/server/handlers/project/update_role.go
  19. 2 0
      api/server/router/cluster.go
  20. 6 0
      api/server/router/invite.go
  21. 100 0
      api/server/router/middleware/usage.go
  22. 87 0
      api/server/router/project.go
  23. 6 0
      api/server/router/router.go
  24. 9 0
      api/server/shared/apierrors/errors.go
  25. 2 0
      api/server/shared/apitest/config.go
  26. 4 0
      api/server/shared/config/config.go
  27. 3 0
      api/server/shared/config/env/envconfs.go
  28. 7 0
      api/server/shared/config/loader/init_ce.go
  29. 36 0
      api/server/shared/config/loader/init_ee.go
  30. 27 23
      api/server/shared/config/loader/loader.go
  31. 11 9
      api/server/shared/config/metadata.go
  32. 7 0
      api/types/error.go
  33. 4 0
      api/types/project.go
  34. 6 0
      api/types/request.go
  35. 66 0
      api/types/usage.go
  36. 2 2
      cmd/app/main.go
  37. 66 0
      dashboard/package-lock.json
  38. 1 0
      dashboard/package.json
  39. 1 0
      dashboard/src/main/Main.tsx
  40. 29 0
      dashboard/src/main/home/Home.tsx
  41. 185 0
      dashboard/src/main/home/modals/UsageWarningModal.tsx
  42. 45 0
      dashboard/src/main/home/project-settings/BillingPage.tsx
  43. 56 30
      dashboard/src/main/home/project-settings/InviteList.tsx
  44. 30 2
      dashboard/src/main/home/project-settings/ProjectSettings.tsx
  45. 10 0
      dashboard/src/shared/Context.tsx
  46. 13 0
      dashboard/src/shared/api.tsx
  47. 20 0
      dashboard/src/shared/types.tsx
  48. 36 0
      ee/LICENSE
  49. 65 0
      ee/api/server/handlers/billing/get_token.go
  50. 84 0
      ee/api/server/handlers/billing/webhook.go
  51. 26 3
      ee/api/server/handlers/invite/accept.go
  52. 3 1
      ee/api/server/handlers/invite/create.go
  53. 3 1
      ee/api/server/handlers/invite/delete.go
  54. 3 1
      ee/api/server/handlers/invite/list.go
  55. 3 1
      ee/api/server/handlers/invite/update_role.go
  56. 386 0
      ee/billing/ironplans.go
  57. 84 0
      ee/billing/types.go
  58. 75 0
      ee/docker/ee.Dockerfile
  59. 13 0
      ee/models/project_billing.go
  60. 15 0
      ee/models/user_billing.go
  61. 46 0
      ee/repository/gorm/project_billing.go
  62. 29 0
      ee/repository/gorm/repository.go
  63. 116 0
      ee/repository/gorm/user_billing.go
  64. 11 0
      ee/repository/project_billing.go
  65. 8 0
      ee/repository/repository.go
  66. 11 0
      ee/repository/user_billing.go
  67. 28 0
      ee/usage/limit.go
  68. 75 0
      internal/billing/billing.go
  69. 28 0
      internal/kubernetes/nodes/nodes.go
  70. 12 0
      internal/models/project.go
  71. 64 0
      internal/models/usage.go
  72. 2 0
      internal/repository/gorm/migrate.go
  73. 6 0
      internal/repository/gorm/repository.go
  74. 87 0
      internal/repository/gorm/usage.go
  75. 2 1
      internal/repository/repository.go
  76. 5 0
      internal/repository/test/repository.go
  77. 129 0
      internal/repository/test/usage.go
  78. 13 0
      internal/repository/usage.go
  79. 15 0
      internal/usage/limit_ce.go
  80. 16 0
      internal/usage/limit_ee.go
  81. 130 0
      internal/usage/usage.go
  82. 136 0
      services/usage/usage.go

+ 2 - 1
.air.toml

@@ -7,7 +7,8 @@ tmp_dir = "tmp"
 
 [build]
 # Just plain old shell command. You could use `make` as well.
-cmd = "go build -o ./tmp/app ./cmd/app"
+cmd = "go build -o ./tmp/app -tags ee -ldflags=\"-X 'main.Version=dev-ee'\" ./cmd/app"
+
 # Binary file yields from `cmd`.
 bin = "tmp/app"
 # Customize binary.

+ 1 - 1
.github/workflows/dev.yaml

@@ -42,7 +42,7 @@ jobs:
           EOL
       - name: Build
         run: |
-          DOCKER_BUILDKIT=1 docker build . -t gcr.io/porter-dev-273614/porter:dev -f ./docker/Dockerfile
+          DOCKER_BUILDKIT=1 docker build . -t gcr.io/porter-dev-273614/porter:dev -f ./ee/docker/ee.Dockerfile
       - name: Push
         run: |
           docker push gcr.io/porter-dev-273614/porter:dev

+ 1 - 1
.github/workflows/production.yaml

@@ -42,7 +42,7 @@ jobs:
           EOL
       - name: Build
         run: |
-          DOCKER_BUILDKIT=1 docker build . -t gcr.io/porter-dev-273614/porter:latest -f ./docker/Dockerfile --build-arg version=production
+          DOCKER_BUILDKIT=1 docker build . -t gcr.io/porter-dev-273614/porter:latest -f ./ee/docker/ee.Dockerfile --build-arg version=production
       - name: Push
         run: |
           docker push gcr.io/porter-dev-273614/porter:latest

+ 1 - 1
.github/workflows/staging.yaml

@@ -42,7 +42,7 @@ jobs:
           EOL
       - name: Build
         run: |
-          DOCKER_BUILDKIT=1 docker build . -t gcr.io/porter-dev-273614/porter:staging -f ./docker/Dockerfile
+          DOCKER_BUILDKIT=1 docker build . -t gcr.io/porter-dev-273614/porter:staging -f ./ee/docker/ee.Dockerfile
       - name: Push
         run: |
           docker push gcr.io/porter-dev-273614/porter:staging

+ 7 - 3
LICENSE

@@ -1,6 +1,10 @@
-MIT License
+Copyright (c) 2021-present Porter Technologies Inc.
 
-Copyright (c) 2020 Porter Technologies Inc.
+Portions of this software are licensed as follows:
+
+* All content that resides under the "ee/" directory of this repository, if that directory exists, is licensed under the license defined in "ee/LICENSE".
+* All third party components incorporated into the Porter Software are licensed under the original license provided by the owner of the applicable component.
+* Content outside of the above mentioned directories or restrictions above is available under the "MIT Expat" license as defined below.
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
@@ -18,4 +22,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
+SOFTWARE.

+ 5 - 5
api/server/authz/policy_test.go

@@ -28,7 +28,7 @@ func TestPolicyMiddlewareSuccessfulProjectCluster(t *testing.T) {
 	}, false, false)
 
 	user := apitest.CreateTestUser(t, config, true)
-	_, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
+	_, _, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
 		Name: "test-project",
 	}, user)
 
@@ -76,7 +76,7 @@ func TestPolicyMiddlewareSuccessfulApplication(t *testing.T) {
 	}, false, false)
 
 	user := apitest.CreateTestUser(t, config, true)
-	_, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
+	_, _, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
 		Name: "test-project",
 	}, user)
 
@@ -141,7 +141,7 @@ func TestPolicyMiddlewareInvalidPermissions(t *testing.T) {
 	}, false, true)
 
 	user := apitest.CreateTestUser(t, config, true)
-	_, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
+	_, _, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
 		Name: "test-project",
 	}, user)
 
@@ -175,7 +175,7 @@ func TestPolicyMiddlewareFailInvalidLoader(t *testing.T) {
 	}, true, false)
 
 	user := apitest.CreateTestUser(t, config, true)
-	_, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
+	_, _, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
 		Name: "test-project",
 	}, user)
 
@@ -208,7 +208,7 @@ func TestPolicyMiddlewareFailBadParam(t *testing.T) {
 	}, true, false)
 
 	user := apitest.CreateTestUser(t, config, true)
-	_, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
+	_, _, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
 		Name: "test-project",
 	}, user)
 

+ 2 - 2
api/server/authz/project_test.go

@@ -18,7 +18,7 @@ func TestProjectMiddlewareSuccessful(t *testing.T) {
 	config, handler, next := loadProjectHandlers(t)
 
 	user := apitest.CreateTestUser(t, config, true)
-	proj, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
+	proj, _, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
 		Name: "test-project",
 	}, user)
 
@@ -46,7 +46,7 @@ func TestProjectMiddlewareFailedRead(t *testing.T) {
 	config, _, _ := loadProjectHandlers(t)
 
 	user := apitest.CreateTestUser(t, config, true)
-	_, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
+	_, _, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
 		Name: "test-project",
 	}, user)
 

+ 36 - 0
api/server/handlers/billing/billing_ce.go

@@ -0,0 +1,36 @@
+// +build !ee
+
+package billing
+
+import (
+	"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/config"
+)
+
+type BillingGetTokenHandler struct {
+	handlers.PorterHandlerReader
+	handlers.Unavailable
+}
+
+func NewBillingGetTokenHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) http.Handler {
+	return handlers.NewUnavailable(config, "billing_get_token")
+}
+
+type BillingWebhookHandler struct {
+	handlers.PorterHandlerReader
+	handlers.Unavailable
+}
+
+func NewBillingWebhookHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+) http.Handler {
+	return handlers.NewUnavailable(config, "billing_webhook")
+}

+ 28 - 0
api/server/handlers/billing/billing_ee.go

@@ -0,0 +1,28 @@
+// +build ee
+
+package billing
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+
+	"github.com/porter-dev/porter/ee/api/server/handlers/billing"
+)
+
+var NewBillingGetTokenHandler func(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) http.Handler
+
+var NewBillingWebhookHandler func(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+) http.Handler
+
+func init() {
+	NewBillingGetTokenHandler = billing.NewBillingGetTokenHandler
+	NewBillingWebhookHandler = billing.NewBillingWebhookHandler
+}

+ 18 - 0
api/server/handlers/handler.go

@@ -108,3 +108,21 @@ func (d *DefaultPorterHandler) PopulateOAuthSession(w http.ResponseWriter, r *ht
 
 	return nil
 }
+
+type Unavailable struct {
+	config    *config.Config
+	handlerID string
+}
+
+func NewUnavailable(config *config.Config, handlerID string) *Unavailable {
+	return &Unavailable{config, handlerID}
+}
+
+func (u *Unavailable) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	apierrors.HandleAPIError(u.config, w, r, apierrors.NewErrPassThroughToClient(
+		fmt.Errorf("%s not available in community edition", u.handlerID),
+		http.StatusBadRequest,
+	), true, apierrors.ErrorOpts{
+		Code: types.ErrCodeUnavailable,
+	})
+}

+ 68 - 0
api/server/handlers/invite/invite_ce.go

@@ -0,0 +1,68 @@
+// +build !ee
+
+package invite
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+)
+
+type InviteUpdateRoleHandler struct {
+	handlers.PorterHandlerReader
+	handlers.Unavailable
+}
+
+func NewInviteUpdateRoleHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+) http.Handler {
+	return handlers.NewUnavailable(config, "invite_update_role")
+}
+
+type InviteAcceptHandler struct {
+	handlers.PorterHandler
+}
+
+func NewInviteAcceptHandler(
+	config *config.Config,
+) http.Handler {
+	return handlers.NewUnavailable(config, "invite_accept")
+}
+
+type InviteCreateHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewInviteCreateHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) http.Handler {
+	return handlers.NewUnavailable(config, "invite_create")
+}
+
+type InviteDeleteHandler struct {
+	handlers.PorterHandler
+	authz.KubernetesAgentGetter
+}
+
+func NewInviteDeleteHandler(
+	config *config.Config,
+) http.Handler {
+	return handlers.NewUnavailable(config, "invite_delete")
+}
+
+type InvitesListHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewInvitesListHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) http.Handler {
+	return handlers.NewUnavailable(config, "invite_list")
+}

+ 44 - 0
api/server/handlers/invite/invite_ee.go

@@ -0,0 +1,44 @@
+// +build ee
+
+package invite
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+
+	"github.com/porter-dev/porter/ee/api/server/handlers/invite"
+)
+
+var NewInviteUpdateRoleHandler func(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+) http.Handler
+
+var NewInviteAcceptHandler func(
+	config *config.Config,
+) http.Handler
+
+var NewInviteCreateHandler func(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) http.Handler
+
+var NewInviteDeleteHandler func(
+	config *config.Config,
+) http.Handler
+
+var NewInvitesListHandler func(
+	config *config.Config,
+	writer shared.ResultWriter,
+) http.Handler
+
+func init() {
+	NewInviteUpdateRoleHandler = invite.NewInviteUpdateRoleHandler
+	NewInviteAcceptHandler = invite.NewInviteAcceptHandler
+	NewInviteCreateHandler = invite.NewInviteCreateHandler
+	NewInviteDeleteHandler = invite.NewInviteDeleteHandler
+	NewInvitesListHandler = invite.NewInvitesListHandler
+}

+ 28 - 9
api/server/handlers/project/create.go

@@ -44,33 +44,52 @@ func (p *ProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	}
 
 	var err error
-	proj, err = CreateProjectWithUser(p.Repo().Project(), proj, user)
+	proj, role, err := CreateProjectWithUser(p.Repo().Project(), proj, user)
 
 	if err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
+	p.WriteResult(w, r, proj.ToProjectType())
+
+	// add project to billing team
+	teamID, err := p.Config().BillingManager.CreateTeam(proj)
+
+	if err != nil {
+		// we do not write error response, since setting up billing error can be
+		// resolved later and may not be fatal
+		p.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
+	}
+
+	if teamID != "" {
+		err = p.Config().BillingManager.AddUserToTeam(teamID, user, role)
+
+		if err != nil {
+			// we do not write error response, since setting up billing error can be
+			// resolved later and may not be fatal
+			p.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
+		}
+	}
+
 	p.Config().AnalyticsClient.Track(analytics.ProjectCreateTrack(&analytics.ProjectCreateTrackOpts{
 		ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(user.ID, proj.ID),
 	}))
-
-	p.WriteResult(w, r, proj.ToProjectType())
 }
 
 func CreateProjectWithUser(
 	projectRepo repository.ProjectRepository,
 	proj *models.Project,
 	user *models.User,
-) (*models.Project, error) {
+) (*models.Project, *models.Role, error) {
 	proj, err := projectRepo.CreateProject(proj)
 
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 
 	// create a new Role with the user as the admin
-	_, err = projectRepo.CreateProjectRole(proj, &models.Role{
+	role, err := projectRepo.CreateProjectRole(proj, &models.Role{
 		Role: types.Role{
 			UserID:    user.ID,
 			ProjectID: proj.ID,
@@ -79,15 +98,15 @@ func CreateProjectWithUser(
 	})
 
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 
 	// read the project again to get the model with the role attached
 	proj, err = projectRepo.ReadProject(proj.ID)
 
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 
-	return proj, nil
+	return proj, role, nil
 }

+ 8 - 0
api/server/handlers/project/delete_role.go

@@ -54,4 +54,12 @@ func (p *RoleDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	}
 
 	p.WriteResult(w, r, res)
+
+	err = p.Config().BillingManager.RemoveUserFromTeam(role)
+
+	if err != nil {
+		// we do not write error response, since setting up billing error can be
+		// resolved later and may not be fatal
+		p.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
+	}
 }

+ 1 - 1
api/server/handlers/project/get_test.go

@@ -14,7 +14,7 @@ func TestGetProjectSuccessful(t *testing.T) {
 	// create a test project
 	config := apitest.LoadConfig(t)
 	user := apitest.CreateTestUser(t, config, true)
-	proj, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
+	proj, _, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
 		Name: "test-project",
 	}, user)
 

+ 50 - 0
api/server/handlers/project/get_usage.go

@@ -0,0 +1,50 @@
+package project
+
+import (
+	"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"
+	"github.com/porter-dev/porter/internal/usage"
+)
+
+type ProjectGetUsageHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewProjectGetUsageHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *ProjectGetUsageHandler {
+	return &ProjectGetUsageHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (p *ProjectGetUsageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	res := &types.GetProjectUsageResponse{}
+
+	currUsage, limit, usageCache, err := usage.GetUsage(&usage.GetUsageOpts{
+		Project: proj,
+		DOConf:  p.Config().DOConf,
+		Repo:    p.Repo(),
+	})
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res.Current = *currUsage
+	res.Limit = *limit
+	res.IsExceeded = usageCache.Exceeded
+	res.ExceededSince = usageCache.ExceededSince
+
+	p.WriteResult(w, r, res)
+}

+ 2 - 2
api/server/handlers/project/list_test.go

@@ -15,7 +15,7 @@ func TestListProjectsSuccessful(t *testing.T) {
 	// create a test project
 	config := apitest.LoadConfig(t)
 	user := apitest.CreateTestUser(t, config, true)
-	proj1, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
+	proj1, _, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
 		Name: "test-project",
 	}, user)
 
@@ -23,7 +23,7 @@ func TestListProjectsSuccessful(t *testing.T) {
 		t.Fatal(err)
 	}
 
-	proj2, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
+	proj2, _, err := project.CreateProjectWithUser(config.Repo.Project(), &models.Project{
 		Name: "test-project-2",
 	}, user)
 

+ 8 - 0
api/server/handlers/project/update_role.go

@@ -57,4 +57,12 @@ func (p *RoleUpdateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	}
 
 	p.WriteResult(w, r, res)
+
+	err = p.Config().BillingManager.UpdateUserInTeam(role)
+
+	if err != nil {
+		// we do not write error response, since setting up billing error can be
+		// resolved later and may not be fatal
+		p.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
+	}
 }

+ 2 - 0
api/server/router/cluster.go

@@ -153,6 +153,8 @@ func getClusterRoutes(
 				types.UserScope,
 				types.ProjectScope,
 			},
+			CheckUsage:  true,
+			UsageMetric: types.Clusters,
 		},
 	)
 

+ 6 - 0
api/server/router/invite.go

@@ -64,6 +64,7 @@ func getInviteRoutes(
 			Scopes: []types.PermissionScope{
 				types.UserScope,
 				types.ProjectScope,
+				types.SettingsScope,
 			},
 		},
 	)
@@ -91,7 +92,10 @@ func getInviteRoutes(
 			Scopes: []types.PermissionScope{
 				types.UserScope,
 				types.ProjectScope,
+				types.SettingsScope,
 			},
+			CheckUsage:  true,
+			UsageMetric: types.Users,
 		},
 	)
 
@@ -145,6 +149,7 @@ func getInviteRoutes(
 			Scopes: []types.PermissionScope{
 				types.UserScope,
 				types.ProjectScope,
+				types.SettingsScope,
 				types.InviteScope,
 			},
 		},
@@ -173,6 +178,7 @@ func getInviteRoutes(
 			Scopes: []types.PermissionScope{
 				types.UserScope,
 				types.ProjectScope,
+				types.SettingsScope,
 				types.InviteScope,
 			},
 		},

+ 100 - 0
api/server/router/middleware/usage.go

@@ -0,0 +1,100 @@
+package middleware
+
+import (
+	"fmt"
+	"net/http"
+
+	"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"
+	"github.com/porter-dev/porter/internal/usage"
+)
+
+type UsageMiddleware struct {
+	config *config.Config
+	metric types.UsageMetric
+}
+
+func NewUsageMiddleware(config *config.Config, metric types.UsageMetric) *UsageMiddleware {
+	return &UsageMiddleware{config, metric}
+}
+
+var UsageErrFmt = "usage limit reached for metric %s: limit %d, requested %d"
+
+func (b *UsageMiddleware) Middleware(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+		// get the project usage limits
+		currentUsage, limit, _, err := usage.GetUsage(&usage.GetUsageOpts{
+			Project: proj,
+			DOConf:  b.config.DOConf,
+			Repo:    b.config.Repo,
+		})
+
+		if err != nil {
+			apierrors.HandleAPIError(
+				b.config,
+				w, r,
+				apierrors.NewErrInternal(err),
+				true,
+			)
+
+			return
+		}
+
+		// check the usage limits
+		allowed := allowUsage(limit, currentUsage, b.metric)
+
+		if allowed {
+			next.ServeHTTP(w, r)
+		} else {
+			limit, curr := getMetricUsage(limit, currentUsage, b.metric)
+
+			apierrors.HandleAPIError(
+				b.config,
+				w, r,
+				apierrors.NewErrPassThroughToClient(
+					fmt.Errorf(UsageErrFmt, b.metric, limit, curr),
+					http.StatusBadRequest,
+				),
+				true,
+			)
+		}
+	})
+}
+
+// checkUsage returns true if the increase in usage is allowed for the given metric,
+// false otherwise. We only assume increments of 1 in usage for now.
+func allowUsage(
+	plan, current *types.ProjectUsage,
+	metric types.UsageMetric,
+) bool {
+	switch metric {
+	case types.Users:
+		return plan.Users > current.Users+1
+	case types.Clusters:
+		return plan.Clusters > current.Clusters+1
+	default:
+		return false
+	}
+}
+
+func getMetricUsage(
+	plan, current *types.ProjectUsage,
+	metric types.UsageMetric,
+) (limit uint, curr uint) {
+	switch metric {
+	case types.CPU:
+		return plan.ResourceCPU, current.ResourceCPU
+	case types.Memory:
+		return plan.ResourceMemory, current.ResourceMemory
+	case types.Users:
+		return plan.Users, current.Users
+	case types.Clusters:
+		return plan.Users, current.Users
+	default:
+		return 0, 0
+	}
+}

+ 87 - 0
api/server/router/project.go

@@ -2,6 +2,7 @@ package router
 
 import (
 	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/api/server/handlers/billing"
 	"github.com/porter-dev/porter/api/server/handlers/cluster"
 	"github.com/porter-dev/porter/api/server/handlers/gitinstallation"
 	"github.com/porter-dev/porter/api/server/handlers/project"
@@ -137,6 +138,86 @@ func getProjectRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/usage -> project.NewProjectGetUsageHandler
+	getUsageEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/usage",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	getUsageHandler := project.NewProjectGetUsageHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getUsageEndpoint,
+		Handler:  getUsageHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/billing/token -> billing.NewBillingGetTokenEndpoint
+	getBillingTokenEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/billing/token",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.SettingsScope,
+			},
+		},
+	)
+
+	getBillingTokenHandler := billing.NewBillingGetTokenHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getBillingTokenEndpoint,
+		Handler:  getBillingTokenHandler,
+		Router:   r,
+	})
+
+	// GET /api/billing_webhook -> billing.NewBillingWebhookHandler
+	getBillingWebhookEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: "/billing_webhook",
+			},
+			Scopes: []types.PermissionScope{},
+		},
+	)
+
+	getBillingWebhookHandler := billing.NewBillingWebhookHandler(
+		config,
+		factory.GetDecoderValidator(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getBillingWebhookEndpoint,
+		Handler:  getBillingWebhookHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/clusters -> cluster.NewClusterListHandler
 	listClusterEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
@@ -509,6 +590,8 @@ func getProjectRoutes(
 				types.UserScope,
 				types.ProjectScope,
 			},
+			CheckUsage:  true,
+			UsageMetric: types.Clusters,
 		},
 	)
 
@@ -565,6 +648,8 @@ func getProjectRoutes(
 				types.UserScope,
 				types.ProjectScope,
 			},
+			CheckUsage:  true,
+			UsageMetric: types.Clusters,
 		},
 	)
 
@@ -621,6 +706,8 @@ func getProjectRoutes(
 				types.UserScope,
 				types.ProjectScope,
 			},
+			CheckUsage:  true,
+			UsageMetric: types.Clusters,
 		},
 	)
 

+ 6 - 0
api/server/router/router.go

@@ -233,6 +233,12 @@ func registerRoutes(config *config.Config, routes []*Route) {
 			atomicGroup.Use(websocketMw.Middleware)
 		}
 
+		if route.Endpoint.Metadata.CheckUsage {
+			usageMW := middleware.NewUsageMiddleware(config, route.Endpoint.Metadata.UsageMetric)
+
+			atomicGroup.Use(usageMW.Middleware)
+		}
+
 		atomicGroup.Method(
 			string(route.Endpoint.Metadata.Method),
 			route.Endpoint.Metadata.Path.RelativePath,

+ 9 - 0
api/server/shared/apierrors/errors.go

@@ -90,12 +90,17 @@ func (e *ErrPassThroughToClient) GetStatusCode() int {
 	return e.statusCode
 }
 
+type ErrorOpts struct {
+	Code uint
+}
+
 func HandleAPIError(
 	config *config.Config,
 	w http.ResponseWriter,
 	r *http.Request,
 	err RequestError,
 	writeErr bool,
+	opts ...ErrorOpts,
 ) {
 	extErrorStr := err.ExternalError()
 
@@ -123,6 +128,10 @@ func HandleAPIError(
 			Error: extErrorStr,
 		}
 
+		if len(opts) > 0 {
+			resp.Code = opts[0].Code
+		}
+
 		// write the status code
 		w.WriteHeader(err.GetStatusCode())
 

+ 2 - 0
api/server/shared/apitest/config.go

@@ -9,6 +9,7 @@ import (
 	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/auth/sessionstore"
 	"github.com/porter-dev/porter/internal/auth/token"
+	"github.com/porter-dev/porter/internal/billing"
 	"github.com/porter-dev/porter/internal/logger"
 	"github.com/porter-dev/porter/internal/repository/test"
 )
@@ -57,6 +58,7 @@ func (t *TestConfigLoader) LoadConfig() (*config.Config, error) {
 		TokenConf:       tokenConf,
 		UserNotifier:    notifier,
 		AnalyticsClient: analytics.InitializeAnalyticsSegmentClient("", l),
+		BillingManager:  &billing.NoopBillingManager{},
 	}, nil
 }
 

+ 4 - 0
api/server/shared/config/config.go

@@ -7,6 +7,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/websocket"
 	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/auth/token"
+	"github.com/porter-dev/porter/internal/billing"
 	"github.com/porter-dev/porter/internal/helm/urlcache"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/logger"
@@ -85,6 +86,9 @@ type Config struct {
 
 	// AnalyticsClient if Segment analytics reporting is enabled on the API instance
 	AnalyticsClient analytics.AnalyticsSegmentClient
+
+	// BillingManager manages billing for Porter instances with billing enabled
+	BillingManager billing.BillingManager
 }
 
 type ConfigLoader interface {

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

@@ -49,6 +49,9 @@ type ServerConf struct {
 	SlackClientID     string `env:"SLACK_CLIENT_ID"`
 	SlackClientSecret string `env:"SLACK_CLIENT_SECRET"`
 
+	IronPlansAPIKey    string `env:"IRON_PLANS_API_KEY"`
+	IronPlansServerURL string `env:"IRON_PLANS_SERVER_URL"`
+
 	DOClientID                 string `env:"DO_CLIENT_ID"`
 	DOClientSecret             string `env:"DO_CLIENT_SECRET"`
 	ProvisionerImageTag        string `env:"PROV_IMAGE_TAG,default=latest"`

+ 7 - 0
api/server/shared/config/loader/init_ce.go

@@ -0,0 +1,7 @@
+// +build !ee
+
+package loader
+
+func init() {
+	sharedInit()
+}

+ 36 - 0
api/server/shared/config/loader/init_ee.go

@@ -0,0 +1,36 @@
+// +build ee
+
+package loader
+
+import (
+	eeBilling "github.com/porter-dev/porter/ee/billing"
+	"github.com/porter-dev/porter/ee/models"
+	eeGorm "github.com/porter-dev/porter/ee/repository/gorm"
+	"github.com/porter-dev/porter/internal/billing"
+)
+
+func init() {
+	sharedInit()
+
+	InstanceDB.AutoMigrate(
+		&models.ProjectBilling{},
+		&models.UserBilling{},
+	)
+
+	var key [32]byte
+
+	for i, b := range []byte(InstanceEnvConf.DBConf.EncryptionKey) {
+		key[i] = b
+	}
+
+	eeRepo := eeGorm.NewEERepository(InstanceDB, &key)
+
+	if InstanceEnvConf.ServerConf.IronPlansAPIKey != "" && InstanceEnvConf.ServerConf.IronPlansServerURL != "" {
+		serverURL := InstanceEnvConf.ServerConf.IronPlansServerURL
+		apiKey := InstanceEnvConf.ServerConf.IronPlansAPIKey
+
+		InstanceBillingManager = eeBilling.NewClient(serverURL, apiKey, eeRepo)
+	} else {
+		InstanceBillingManager = &billing.NoopBillingManager{}
+	}
+}

+ 27 - 23
api/server/shared/config/loader/loader.go

@@ -14,6 +14,7 @@ import (
 	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/auth/sessionstore"
 	"github.com/porter-dev/porter/internal/auth/token"
+	"github.com/porter-dev/porter/internal/billing"
 	"github.com/porter-dev/porter/internal/helm/urlcache"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes/local"
@@ -23,41 +24,44 @@ import (
 	"github.com/porter-dev/porter/internal/repository/gorm"
 
 	lr "github.com/porter-dev/porter/internal/logger"
+
+	pgorm "gorm.io/gorm"
 )
 
-type EnvConfigLoader struct{}
+var InstanceBillingManager billing.BillingManager
+var InstanceEnvConf *EnvConf
+var InstanceDB *pgorm.DB
 
-func NewEnvLoader() config.ConfigLoader {
-	return &EnvConfigLoader{}
+type EnvConfigLoader struct {
+	version string
 }
 
-func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
-	envConf, err := FromEnv()
+func NewEnvLoader(version string) config.ConfigLoader {
+	return &EnvConfigLoader{version}
+}
 
-	if err != nil {
-		return nil, err
-	}
+func sharedInit() {
+	InstanceEnvConf, _ = FromEnv()
+	InstanceDB, _ = adapter.New(InstanceEnvConf.DBConf)
+	InstanceBillingManager = &billing.NoopBillingManager{}
+}
 
+func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
+	envConf := InstanceEnvConf
 	sc := envConf.ServerConf
 
 	res = &config.Config{
-		Logger:     lr.NewConsole(sc.Debug),
-		ServerConf: sc,
-		DBConf:     envConf.DBConf,
-		RedisConf:  envConf.RedisConf,
-	}
-
-	res.Metadata = config.MetadataFromConf(envConf.ServerConf)
-
-	db, err := adapter.New(envConf.DBConf)
-
-	if err != nil {
-		return nil, err
+		Logger:         lr.NewConsole(sc.Debug),
+		ServerConf:     sc,
+		DBConf:         envConf.DBConf,
+		RedisConf:      envConf.RedisConf,
+		BillingManager: InstanceBillingManager,
 	}
 
-	res.DB = db
+	res.Metadata = config.MetadataFromConf(envConf.ServerConf, e.version)
+	res.DB = InstanceDB
 
-	err = gorm.AutoMigrate(db)
+	err = gorm.AutoMigrate(InstanceDB)
 
 	if err != nil {
 		return nil, err
@@ -69,7 +73,7 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 		key[i] = b
 	}
 
-	res.Repo = gorm.NewRepository(db, &key)
+	res.Repo = gorm.NewRepository(InstanceDB, &key)
 
 	// create the session store
 	res.Store, err = sessionstore.NewStore(

+ 11 - 9
api/server/shared/config/metadata.go

@@ -3,17 +3,18 @@ package config
 import "github.com/porter-dev/porter/api/server/shared/config/env"
 
 type Metadata struct {
-	Provisioning       bool `json:"provisioner"`
-	Github             bool `json:"github"`
-	BasicLogin         bool `json:"basic_login"`
-	GithubLogin        bool `json:"github_login"`
-	GoogleLogin        bool `json:"google_login"`
-	SlackNotifications bool `json:"slack_notifications"`
-	Email              bool `json:"email"`
-	Analytics          bool `json:"analytics"`
+	Provisioning       bool   `json:"provisioner"`
+	Github             bool   `json:"github"`
+	BasicLogin         bool   `json:"basic_login"`
+	GithubLogin        bool   `json:"github_login"`
+	GoogleLogin        bool   `json:"google_login"`
+	SlackNotifications bool   `json:"slack_notifications"`
+	Email              bool   `json:"email"`
+	Analytics          bool   `json:"analytics"`
+	Version            string `json:"version"`
 }
 
-func MetadataFromConf(sc *env.ServerConf) *Metadata {
+func MetadataFromConf(sc *env.ServerConf, version string) *Metadata {
 	return &Metadata{
 		// note: provisioning is set in the metadata after the loader is called
 		Provisioning:       false,
@@ -24,6 +25,7 @@ func MetadataFromConf(sc *env.ServerConf) *Metadata {
 		SlackNotifications: sc.SlackClientID != "" && sc.SlackClientSecret != "",
 		Email:              sc.SendgridAPIKey != "",
 		Analytics:          sc.SegmentClientKey != "",
+		Version:            version,
 	}
 }
 

+ 7 - 0
api/types/error.go

@@ -1,5 +1,12 @@
 package types
 
+const (
+	ErrCodeUnavailable uint = 601
+)
+
 type ExternalError struct {
+	// Optional error code for well-known error types
+	Code uint `json:"code,omitempty"`
+
 	Error string `json:"error"`
 }

+ 4 - 0
api/types/project.go

@@ -61,3 +61,7 @@ type DeleteRoleRequest struct {
 type DeleteRoleResponse struct {
 	*Role
 }
+
+type GetBillingTokenResponse struct {
+	Token string `json:"token"`
+}

+ 6 - 0
api/types/request.go

@@ -63,6 +63,12 @@ type APIRequestMetadata struct {
 
 	// Whether the endpoint upgrades to a websocket
 	IsWebsocket bool
+
+	// Whether the endpoint should check for a usage limit
+	CheckUsage bool
+
+	// The usage metric that the request should check for, if CheckUsage
+	UsageMetric UsageMetric
 }
 
 const RequestScopeCtxKey = "requestscopes"

+ 66 - 0
api/types/usage.go

@@ -0,0 +1,66 @@
+package types
+
+import "time"
+
+type UsageMetric string
+
+const (
+	CPU      UsageMetric = "cpu"
+	Memory   UsageMetric = "memory"
+	Clusters UsageMetric = "clusters"
+	Users    UsageMetric = "users"
+)
+
+type ProjectUsage struct {
+	// The CPU usage, in vCPUs
+	ResourceCPU uint `json:"resource_cpu"`
+
+	// The memory usage, in mibibytes (?)
+	ResourceMemory uint `json:"resource_memory"`
+
+	// The number of clusters
+	Clusters uint `json:"clusters"`
+
+	// The number of users
+	Users uint `json:"users"`
+}
+
+var BasicPlan = ProjectUsage{
+	ResourceCPU:    10,
+	ResourceMemory: 20000,
+	Clusters:       1,
+	Users:          1,
+}
+
+var TeamPlan = ProjectUsage{
+	ResourceCPU:    20,
+	ResourceMemory: 40000,
+	Clusters:       3,
+	Users:          3,
+}
+
+var GrowthPlan = ProjectUsage{
+	ResourceCPU:    80,
+	ResourceMemory: 160000,
+	Clusters:       0,
+	Users:          5,
+}
+
+// all unlimited
+var EnterprisePlan = ProjectUsage{
+	ResourceCPU:    0,
+	ResourceMemory: 0,
+	Clusters:       0,
+	Users:          0,
+}
+
+type GetProjectUsageResponse struct {
+	Current ProjectUsage `json:"current"`
+	Limit   ProjectUsage `json:"limit"`
+
+	// Whether the usage is exceeded
+	IsExceeded bool `json:"exceeded"`
+
+	// When the usage has been exceeded since, if IsExceeded
+	ExceededSince *time.Time `json:"exceeded_since,omitempty"`
+}

+ 2 - 2
cmd/app/main.go

@@ -14,7 +14,7 @@ import (
 )
 
 // Version will be linked by an ldflag during build
-var Version string = "dev"
+var Version string = "dev-ce"
 
 func main() {
 	var versionFlag bool
@@ -27,7 +27,7 @@ func main() {
 		os.Exit(0)
 	}
 
-	cl := loader.NewEnvLoader()
+	cl := loader.NewEnvLoader(Version)
 
 	config, err := cl.LoadConfig()
 

+ 66 - 0
dashboard/package-lock.json

@@ -2496,6 +2496,47 @@
       "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz",
       "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg=="
     },
+    "@ironplans/api": {
+      "version": "0.1.1-alpha.0",
+      "resolved": "https://registry.npmjs.org/@ironplans/api/-/api-0.1.1-alpha.0.tgz",
+      "integrity": "sha512-sOtVjG/9O67DSKnTBQr+z2JROQoDsM6FrU02jIYF1tet0ZsQlmxHXT7rWQh2bSrK7IIkhp+5wgUsfRHeexCr1g=="
+    },
+    "@ironplans/react": {
+      "version": "0.1.1-alpha.1",
+      "resolved": "https://registry.npmjs.org/@ironplans/react/-/react-0.1.1-alpha.1.tgz",
+      "integrity": "sha512-5uBZb9kgHdnG144znEGQGbpDo4y2Jy4xHZRegerhOhTyuFVaI8Yrm6xKJucFeDkQIQCh1NqeVTy8a+sPwQl2CQ==",
+      "requires": {
+        "@ironplans/sdk": "^0.1.1-alpha.1"
+      }
+    },
+    "@ironplans/sdk": {
+      "version": "0.1.1-alpha.1",
+      "resolved": "https://registry.npmjs.org/@ironplans/sdk/-/sdk-0.1.1-alpha.1.tgz",
+      "integrity": "sha512-8nno59ZRKju3KYNj9KBHL6xpltZPuIvfUBih14H4R1SpLtEprASy5eurK/Cdxgq3s7uwygU1ompyLKGtdB/ORg==",
+      "requires": {
+        "@ironplans/api": "^0.1.1-alpha.0",
+        "@ironplans/types": "^0.0.30-alpha.0",
+        "js-cookie": "^3.0.1",
+        "jwt-decode": "^3.1.2",
+        "micro-typed-events": "^1.0.2",
+        "qs": "^6.10.1"
+      },
+      "dependencies": {
+        "qs": {
+          "version": "6.10.1",
+          "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.1.tgz",
+          "integrity": "sha512-M528Hph6wsSVOBiYUnGf+K/7w0hNshs/duGsNXPUCLH5XAqjEtiPGwNONLV0tBH8NoGb0mvD5JubnUTrujKDTg==",
+          "requires": {
+            "side-channel": "^1.0.4"
+          }
+        }
+      }
+    },
+    "@ironplans/types": {
+      "version": "0.0.30-alpha.0",
+      "resolved": "https://registry.npmjs.org/@ironplans/types/-/types-0.0.30-alpha.0.tgz",
+      "integrity": "sha512-7goQ8VKTO2ra90j29ZFUiR1mz/Jpz/sqF1S7fEjgOjynL4DQc4P5qTb+to8QbZJQAUSha+vNesCsbN59iNTEUw=="
+    },
     "@jest/types": {
       "version": "24.9.0",
       "resolved": "https://registry.npmjs.org/@jest/types/-/types-24.9.0.tgz",
@@ -7839,6 +7880,11 @@
       "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.6.0.tgz",
       "integrity": "sha512-wVdUBYQeY2gY73RIlPrysvpYx+2vheGo8Y1SNQv/BzHToWpAZzJU7Z6uheKMAe+GLSBig5/Ps2nxg/8tRB73xg=="
     },
+    "js-cookie": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.1.tgz",
+      "integrity": "sha512-+0rgsUXZu4ncpPxRL+lNEptWMOWl9etvPHc/koSRp6MPwpRYAhmk0dUG00J4bxVV3r9uUzfo24wW0knS07SKSw=="
+    },
     "js-tokens": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -7970,6 +8016,11 @@
         "jss": "10.5.1"
       }
     },
+    "jwt-decode": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
+      "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="
+    },
     "killable": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz",
@@ -8195,6 +8246,11 @@
       "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=",
       "dev": true
     },
+    "micro-typed-events": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/micro-typed-events/-/micro-typed-events-1.0.2.tgz",
+      "integrity": "sha512-YfSk2bMe6P4l0m/EMKF0wAsXAAtnsu/EHJ0yvTX8TwmKVokJZQYbd8VsJ7Hy+7lSwSt0b6QHD2F0e6VciJ5a8w=="
+    },
     "micromatch": {
       "version": "4.0.2",
       "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz",
@@ -10090,6 +10146,16 @@
       "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=",
       "dev": true
     },
+    "side-channel": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
+      "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
+      "requires": {
+        "call-bind": "^1.0.0",
+        "get-intrinsic": "^1.0.2",
+        "object-inspect": "^1.9.0"
+      }
+    },
     "signal-exit": {
       "version": "3.0.3",
       "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz",

+ 1 - 0
dashboard/package.json

@@ -3,6 +3,7 @@
   "version": "0.1.0",
   "private": true,
   "dependencies": {
+    "@ironplans/react": "^0.1.1-alpha.1",
     "@material-ui/core": "^4.11.3",
     "@sentry/react": "^6.13.2",
     "@sentry/tracing": "^6.13.2",

+ 1 - 0
dashboard/src/main/Main.tsx

@@ -58,6 +58,7 @@ export default class Main extends Component<PropsType, StateType> {
     api
       .getMetadata("", {}, {})
       .then((res) => {
+        this.context.setEdition(res.data?.version);
         this.setState({ local: !res.data?.provisioner });
       })
       .catch((err) => console.log(err));

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

@@ -32,6 +32,7 @@ import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 import EditInviteOrCollaboratorModal from "./modals/EditInviteOrCollaboratorModal";
 import AccountSettingsModal from "./modals/AccountSettingsModal";
 import discordLogo from "../../assets/discord.svg";
+import UsageWarningModal from "./modals/UsageWarningModal";
 // Guarded components
 const GuardedProjectSettings = fakeGuardedRoute("settings", "", [
   "get",
@@ -297,6 +298,24 @@ class Home extends Component<PropsType, StateType> {
   // 2. Make sure switching projects shows appropriate initial view (dashboard || provisioner)
   // 3. Make sure initializing from URL (DO oauth) displays the appropriate initial view
   componentDidUpdate(prevProps: PropsType) {
+    if (prevProps.currentProject?.id !== this.props.currentProject?.id) {
+      api
+        .getUsage(
+          "<token>",
+          {},
+          { project_id: this.context?.currentProject?.id }
+        )
+        .then((res) => {
+          const usage = res.data;
+          if (usage.exceeded) {
+            this.context.setCurrentModal("UsageWarningModal", {
+              usage,
+            });
+          }
+        })
+        .catch(console.log);
+    }
+
     if (
       prevProps.currentProject !== this.props.currentProject ||
       (!prevProps.currentCluster && this.props.currentCluster)
@@ -557,6 +576,16 @@ class Home extends Component<PropsType, StateType> {
           </Modal>
         )}
 
+        {currentModal === "UsageWarningModal" && (
+          <Modal
+            onRequestClose={() => setCurrentModal(null, null)}
+            width="760px"
+            height="440px"
+          >
+            <UsageWarningModal />
+          </Modal>
+        )}
+
         {currentOverlay && (
           <ConfirmOverlay
             show={true}

+ 185 - 0
dashboard/src/main/home/modals/UsageWarningModal.tsx

@@ -0,0 +1,185 @@
+import React, { useContext, useEffect, useState } from "react";
+import styled from "styled-components";
+import close from "assets/close.png";
+
+import { Context } from "shared/Context";
+import { UsageData } from "shared/types";
+import { Link } from "react-router-dom";
+
+const ReadableNameMap: {
+  [key: string]: string;
+} = {
+  resource_cpu: "CPU",
+  resource_memory: "Memory",
+  clusters: "Cluster number",
+  users: "Users on your team",
+};
+
+const filterExceeded = (usage: UsageData) => {
+  console.log(usage);
+  const current = usage.current;
+  const limits = usage.limit;
+  return Object.keys(usage.current).reduce((acc, key) => {
+    if (!acc.current) {
+      acc.current = {} as any;
+    }
+    if (!acc.limit) {
+      acc.limit = {} as any;
+    }
+    if (current[key] > limits[key]) {
+      acc.current[key] = current[key];
+      acc.limit[key] = limits[key];
+    }
+    return acc;
+  }, {} as Partial<UsageData>);
+};
+
+const UpgradeChartModal: React.FC<{}> = () => {
+  const { setCurrentModal, currentModalData } = useContext(Context);
+  const [usage, setUsage] = useState<UsageData>(null);
+  const [filteredUsage, setFilteredUsage] = useState<Partial<UsageData>>(null);
+  useEffect(() => {
+    if (currentModalData.usage) {
+      const currentUsage: UsageData = currentModalData.usage;
+      console.log(currentModalData);
+      setUsage(currentUsage);
+    }
+  }, [currentModalData?.usage]);
+
+  useEffect(() => {
+    if (usage) {
+      setFilteredUsage(filterExceeded(usage));
+    }
+  }, [usage]);
+
+  if (!usage || !filteredUsage) {
+    return null;
+  }
+  console.log({ usage, filteredUsage });
+  return (
+    <StyledUpgradeChartModal>
+      <CloseButton onClick={() => setCurrentModal(null, null)}>
+        <CloseButtonImg src={close} />
+      </CloseButton>
+      <ModalTitle>Usage warning</ModalTitle>
+      You're current project is currently exceeding its usage limits. Your usage
+      limits are:
+      <DescriptionSection>
+        {filteredUsage !== null &&
+          Object.entries(filteredUsage.limit).map(([key, value]) => {
+            return (
+              <div key={key}>
+                <b>{ReadableNameMap[key]}:</b> {value}
+              </div>
+            );
+          })}
+      </DescriptionSection>
+      Your project is currently using:
+      <DescriptionSection>
+        {filteredUsage !== null &&
+          Object.entries(filteredUsage.current).map(([key, value]) => {
+            return (
+              <div key={key}>
+                <b>{ReadableNameMap[key]}:</b> {value}
+              </div>
+            );
+          })}
+      </DescriptionSection>
+      You have currently <b>7 days</b> to resolve this issue before you loose
+      access to the Porter dashboard.
+      <Button
+        as={Link}
+        to={{
+          pathname: "/project-settings",
+          search: "?selected_tab=billing",
+        }}
+        onClick={() => setCurrentModal(null, null)}
+      >
+        Take me to billing
+      </Button>
+    </StyledUpgradeChartModal>
+  );
+};
+
+export default UpgradeChartModal;
+
+const Button = styled.button`
+  height: 35px;
+  background: #616feecc;
+  :hover {
+    background: #505edddd;
+  }
+  color: white;
+  font-weight: 500;
+  font-size: 13px;
+  padding: 10px 15px;
+  border-radius: 3px;
+  cursor: "pointer";
+  box-shadow: 0 5px 8px 0px #00000010;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-left: 10px;
+
+  width: max-content;
+  position: absolute;
+  right: 20px;
+  bottom: 20px;
+`;
+
+const DescriptionSection = styled.div`
+  margin-top: 10px;
+  margin-bottom: 10px;
+`;
+
+const ModalTitle = styled.div`
+  margin: 0px 0px 13px;
+  display: flex;
+  flex: 1;
+  font-family: Work Sans, sans-serif;
+  font-size: 24px;
+  color: #ffffff;
+  user-select: none;
+  font-weight: 700;
+  align-items: center;
+  position: relative;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  padding: 13px 0 12px 0;
+  z-index: 1;
+  text-align: center;
+  border-radius: 50%;
+  right: 15px;
+  top: 12px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+`;
+
+const CloseButtonImg = styled.img`
+  width: 14px;
+  margin: 0 auto;
+`;
+
+const StyledUpgradeChartModal = styled.div`
+  width: 100%;
+  position: absolute;
+  left: 0;
+  top: 0;
+  height: 100%;
+  padding: 25px 30px;
+  overflow: hidden;
+  border-radius: 6px;
+  background: #202227;
+  font-size: 13px;
+  line-height: 1.8em;
+  font-family: Work Sans, sans-serif;
+`;

+ 45 - 0
dashboard/src/main/home/project-settings/BillingPage.tsx

@@ -0,0 +1,45 @@
+import React, { useContext, useEffect, useState } from "react";
+import { CustomerProvider, PlanSelect } from "@ironplans/react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+function BillingPage() {
+  const [customerToken, setCustomerToken] = useState("");
+  const { currentProject, setCurrentError } = useContext(Context);
+
+  useEffect(() => {
+    let isSubscripted = true;
+    api
+      .getCustomerToken("<token>", {}, { project_id: currentProject?.id })
+      .then((res) => {
+        if (isSubscripted) {
+          const token = res?.data?.token;
+          setCustomerToken(token);
+        }
+      })
+      .catch((err) => {
+        setCurrentError(err);
+      });
+    return () => {
+      isSubscripted = false;
+    };
+  }, [currentProject?.id]);
+
+  return (
+    <div style={{ height: "1000px" }}>
+      <CustomerProvider token={customerToken}>
+        <PlanSelect
+          theme={{
+            base: {
+              darkMode: "on",
+              primaryColor: "white",
+              fontFamily: "sans-serif",
+            },
+          }}
+        ></PlanSelect>
+      </CustomerProvider>
+    </div>
+  );
+}
+
+export default BillingPage;

+ 56 - 30
dashboard/src/main/home/project-settings/InviteList.tsx

@@ -25,7 +25,13 @@ export type Collaborator = {
 };
 
 const InvitePage: React.FunctionComponent<Props> = ({}) => {
-  const { currentProject, setCurrentModal, user } = useContext(Context);
+  const {
+    currentProject,
+    setCurrentModal,
+    setCurrentError,
+    user,
+    edition,
+  } = useContext(Context);
   const [isLoading, setIsLoading] = useState(true);
   const [invites, setInvites] = useState<Array<InviteType>>([]);
   const [email, setEmail] = useState("");
@@ -115,7 +121,13 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
         getData();
         setEmail("");
       })
-      .catch((err) => console.log(err));
+      .catch((err) => {
+        if (err.response.data?.error) {
+          setCurrentError(err.response.data?.error);
+        }
+
+        console.log(err);
+      });
   };
 
   const deleteInvite = (inviteId: number) => {
@@ -154,7 +166,13 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
         )
       )
       .then(getData)
-      .catch((err) => console.log(err));
+      .catch((err) => {
+        if (err.response.data?.error) {
+          setCurrentError(err.response.data?.error);
+        }
+
+        console.log(err);
+      });
   };
 
   const validateEmail = () => {
@@ -346,35 +364,43 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
     return mappedInviteList || [];
   }, [invites, currentProject?.id, window?.location?.host, isHTTPS, user?.id]);
 
+  const isEnterpriseEdition = () => {
+    return edition === "ee";
+  };
+
   return (
     <>
-      <Heading isAtTop={true}>Share Project</Heading>
-      <Helper>Generate a project invite for another user.</Helper>
-      <InputRowWrapper>
-        <InputRow
-          value={email}
-          type="text"
-          setValue={(newEmail: string) => setEmail(newEmail)}
-          width="100%"
-          placeholder="ex: mrp@getporter.dev"
-        />
-      </InputRowWrapper>
-      <Helper>Specify a role for this user.</Helper>
-      <RoleSelectorWrapper>
-        <RadioSelector
-          selected={role}
-          setSelected={setRole}
-          options={roleList}
-        />
-      </RoleSelectorWrapper>
-      <ButtonWrapper>
-        <InviteButton disabled={false} onClick={() => validateEmail()}>
-          Create Invite
-        </InviteButton>
-        {isInvalidEmail && (
-          <Invalid>Invalid email address. Please try again.</Invalid>
-        )}
-      </ButtonWrapper>
+      {isEnterpriseEdition() && (
+        <>
+          <Heading isAtTop={true}>Share Project</Heading>
+          <Helper>Generate a project invite for another user.</Helper>
+          <InputRowWrapper>
+            <InputRow
+              value={email}
+              type="text"
+              setValue={(newEmail: string) => setEmail(newEmail)}
+              width="100%"
+              placeholder="ex: mrp@getporter.dev"
+            />
+          </InputRowWrapper>
+          <Helper>Specify a role for this user.</Helper>
+          <RoleSelectorWrapper>
+            <RadioSelector
+              selected={role}
+              setSelected={setRole}
+              options={roleList}
+            />
+          </RoleSelectorWrapper>
+          <ButtonWrapper>
+            <InviteButton disabled={false} onClick={() => validateEmail()}>
+              Create Invite
+            </InviteButton>
+            {isInvalidEmail && (
+              <Invalid>Invalid email address. Please try again.</Invalid>
+            )}
+          </ButtonWrapper>
+        </>
+      )}
 
       <Heading>Invites & Collaborators</Heading>
       <Helper>Manage pending invites and view collaborators.</Helper>

+ 30 - 2
dashboard/src/main/home/project-settings/ProjectSettings.tsx

@@ -9,8 +9,11 @@ import Heading from "components/form-components/Heading";
 import Helper from "components/form-components/Helper";
 import TitleSection from "components/TitleSection";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+import { RouteComponentProps, withRouter, WithRouterProps } from "react-router";
+import { getQueryParam } from "shared/routing";
+import BillingPage from "./BillingPage";
 
-type PropsType = WithAuthProps & {};
+type PropsType = RouteComponentProps & WithAuthProps & {};
 
 type StateType = {
   projectName: string;
@@ -25,12 +28,28 @@ class ProjectSettings extends Component<PropsType, StateType> {
     tabOptions: [] as { value: string; label: string }[],
   };
 
+  componentDidUpdate(prevProps: PropsType) {
+    const selectedTab = getQueryParam(this.props, "selected_tab");
+    if (prevProps.location.search !== this.props.location.search) {
+      if (selectedTab) {
+        this.setState({ currentTab: selectedTab });
+      } else {
+        this.setState({ currentTab: "manage-access" });
+      }
+    }
+  }
+
   componentDidMount() {
     let { currentProject } = this.context;
     this.setState({ projectName: currentProject.name });
     const tabOptions = [];
     tabOptions.push({ value: "manage-access", label: "Manage Access" });
+
     if (this.props.isAuthorized("settings", "", ["get", "delete"])) {
+      tabOptions.push({
+        value: "billing",
+        label: "Billing",
+      });
       tabOptions.push({
         value: "additional-settings",
         label: "Additional Settings",
@@ -38,6 +57,11 @@ class ProjectSettings extends Component<PropsType, StateType> {
     }
 
     this.setState({ tabOptions });
+
+    const selectedTab = getQueryParam(this.props, "selected_tab");
+    if (selectedTab) {
+      this.setState({ currentTab: selectedTab });
+    }
   }
 
   renderTabContents = () => {
@@ -45,6 +69,10 @@ class ProjectSettings extends Component<PropsType, StateType> {
       return <InvitePage />;
     }
 
+    if (this.state.currentTab === "billing") {
+      return <BillingPage />;
+    }
+
     if (this.state.currentTab === "manage-access") {
       return <InvitePage />;
     } else {
@@ -107,7 +135,7 @@ class ProjectSettings extends Component<PropsType, StateType> {
 
 ProjectSettings.contextType = Context;
 
-export default withAuth(ProjectSettings);
+export default withRouter(withAuth(ProjectSettings));
 
 const Warning = styled.div`
   font-size: 13px;

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

@@ -49,6 +49,8 @@ export interface GlobalContextType {
   capabilities: CapabilityType;
   setCapabilities: (capabilities: CapabilityType) => void;
   clearContext: () => void;
+  edition: "ee" | "ce";
+  setEdition: (appVersion: string) => void;
 }
 
 /**
@@ -135,6 +137,14 @@ class ContextProvider extends Component<PropsType, StateType> {
         devOpsMode: true,
       });
     },
+    edition: "ce",
+    setEdition: (version: string) => {
+      const [edition] = version.split("-").reverse();
+      // typesafe just in case we mess up something it will default to ce
+      if (edition === "ce" || edition === "ee") {
+        this.setState({ edition });
+      }
+    },
   };
 
   render() {

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

@@ -1044,6 +1044,17 @@ const createWebhookToken = baseApi<
     `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${chart_name}/0/webhook`
 );
 
+const getUsage = baseApi<{}, { project_id: number }>(
+  "GET",
+  ({ project_id }) => `/api/projects/${project_id}/usage`
+);
+
+// Used for billing purposes
+const getCustomerToken = baseApi<{}, { project_id: number }>(
+  "GET",
+  ({ project_id }) => `/api/projects/${project_id}/billing/token`
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -1149,4 +1160,6 @@ export default {
   removeCollaborator,
   getPolicyDocument,
   createWebhookToken,
+  getUsage,
+  getCustomerToken,
 };

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

@@ -296,6 +296,8 @@ export interface ContextProps {
   capabilities: CapabilityType;
   setCapabilities: (capabilities: CapabilityType) => void;
   clearContext: () => void;
+  edition: "ee" | "ce";
+  setEdition: (appVersion: string) => void;
 }
 
 export enum JobStatusType {
@@ -308,3 +310,21 @@ export interface JobStatusWithTimeType {
   status: JobStatusType;
   start_time: string;
 }
+
+export interface UsageData {
+  current: {
+    [key: string]: number;
+    resource_cpu: number;
+    resource_memory: number;
+    clusters: number;
+    users: number;
+  };
+  limit: {
+    [key: string]: number;
+    resource_cpu: number;
+    resource_memory: number;
+    clusters: number;
+    users: number;
+  };
+  exceeds: boolean;
+}

+ 36 - 0
ee/LICENSE

@@ -0,0 +1,36 @@
+The Porter Enterprise license (the “Enterprise License”)
+Copyright (c) 2021-present Porter Technologies Inc.
+
+With regard to the Porter Software:
+
+This software and associated documentation files (the "Software") may only be
+used in production, if you (and any entity that you represent) have agreed to,
+and are in compliance with, the Porter Terms of Service, available at 
+https://docs.porter.run/docs/terms-of-service (the “Enterprise Terms”), or other
+agreement governing the use of the Software, as agreed by you and Porter,
+and otherwise have a valid Porter Enterprise license for the
+correct number of user seats. Subject to the foregoing sentence, you are free to
+modify this Software and publish patches to the Software. You agree that Porter
+and/or its licensors (as applicable) retain all right, title and interest in and
+to all such modifications and/or patches, and all such modifications and/or
+patches may only be used, copied, modified, displayed, distributed, or otherwise
+exploited with a valid Porter Enterprise license for the  correct
+number of user seats.  Notwithstanding the foregoing, you may copy and modify
+the Software for development and testing purposes, without requiring a
+subscription.  You agree that Porter and/or its licensors (as applicable) retain
+all right, title and interest in and to all such modifications.  You are not
+granted any other rights beyond what is expressly stated herein.  Subject to the
+foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,
+and/or sell the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+For all third party components incorporated into the Porter Software, those
+components are licensed under the original license provided by the owner of the
+applicable component.

+ 65 - 0
ee/api/server/handlers/billing/get_token.go

@@ -0,0 +1,65 @@
+package billing
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"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 BillingGetTokenHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewBillingGetTokenHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) http.Handler {
+	return &BillingGetTokenHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *BillingGetTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	// we double-check that the user is an admin the project
+	roles, err := c.Repo().Project().ListProjectRoles(proj.ID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	for _, role := range roles {
+		if role.UserID != 0 && role.UserID == user.ID {
+			if role.Kind != types.RoleAdmin {
+				c.HandleAPIError(w, r, apierrors.NewErrForbidden(
+					fmt.Errorf("user %d is not an admin in project %d", user.ID, proj.ID),
+				))
+
+				return
+			}
+		}
+	}
+
+	token, err := c.Config().BillingManager.GetIDToken(proj.ID, user)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, &types.GetBillingTokenResponse{
+		Token: token,
+	})
+}

+ 84 - 0
ee/api/server/handlers/billing/webhook.go

@@ -0,0 +1,84 @@
+package billing
+
+import (
+	"errors"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"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"
+	"gorm.io/gorm"
+)
+
+type BillingWebhookHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewBillingWebhookHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+) http.Handler {
+	return &BillingWebhookHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, nil),
+	}
+}
+
+func (c *BillingWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	payload, err := ioutil.ReadAll(r.Body)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// verify webhook secret
+	signature := r.Header.Get("x-signature")
+
+	if !c.Config().BillingManager.VerifySignature(signature, payload) {
+		c.HandleAPIError(w, r, apierrors.NewErrForbidden(
+			fmt.Errorf("could not verify signature for billing webhook"),
+		))
+
+		return
+	}
+
+	// parse usage and update project
+	newUsage, err := c.Config().BillingManager.ParseProjectUsageFromWebhook(payload)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// newUsage will be nil if webhook event type is not "subscription", so return without
+	// updating usage in this case
+	if newUsage == nil {
+		return
+	}
+
+	// update the project's usage
+	existingUsage, err := c.Repo().ProjectUsage().ReadProjectUsage(newUsage.ProjectID)
+	notFound := errors.Is(err, gorm.ErrRecordNotFound)
+
+	if !notFound && err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if notFound {
+		_, err = c.Repo().ProjectUsage().CreateProjectUsage(newUsage)
+	} else {
+		newUsage.ID = existingUsage.ID
+		_, err = c.Repo().ProjectUsage().UpdateProjectUsage(newUsage)
+	}
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

+ 26 - 3
api/server/handlers/invite/accept.go → ee/api/server/handlers/invite/accept.go

@@ -1,3 +1,5 @@
+// +build ee
+
 package invite
 
 import (
@@ -21,7 +23,7 @@ type InviteAcceptHandler struct {
 
 func NewInviteAcceptHandler(
 	config *config.Config,
-) *InviteAcceptHandler {
+) http.Handler {
 	return &InviteAcceptHandler{
 		PorterHandler: handlers.NewDefaultPorterHandler(config, nil, nil),
 	}
@@ -81,13 +83,15 @@ func (c *InviteAcceptHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		kind = models.RoleDeveloper
 	}
 
-	if _, err = c.Repo().Project().CreateProjectRole(proj, &models.Role{
+	role := &models.Role{
 		Role: types.Role{
 			UserID:    user.ID,
 			ProjectID: proj.ID,
 			Kind:      types.RoleKind(kind),
 		},
-	}); err != nil {
+	}
+
+	if role, err = c.Repo().Project().CreateProjectRole(proj, role); err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
@@ -100,5 +104,24 @@ func (c *InviteAcceptHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
+	// add project to billing team
+	teamID, err := c.Config().BillingManager.GetTeamID(proj)
+
+	if err != nil {
+		// we do not write error response, since setting up billing error can be
+		// resolved later and may not be fatal
+		c.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
+	}
+
+	if teamID != "" {
+		err = c.Config().BillingManager.AddUserToTeam(teamID, user, role)
+
+		if err != nil {
+			// we do not write error response, since setting up billing error can be
+			// resolved later and may not be fatal
+			c.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
+		}
+	}
+
 	http.Redirect(w, r, "/dashboard", 302)
 }

+ 3 - 1
api/server/handlers/invite/create.go → ee/api/server/handlers/invite/create.go

@@ -1,3 +1,5 @@
+// +build ee
+
 package invite
 
 import (
@@ -23,7 +25,7 @@ func NewInviteCreateHandler(
 	config *config.Config,
 	decoderValidator shared.RequestDecoderValidator,
 	writer shared.ResultWriter,
-) *InviteCreateHandler {
+) http.Handler {
 	return &InviteCreateHandler{
 		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
 	}

+ 3 - 1
api/server/handlers/invite/delete.go → ee/api/server/handlers/invite/delete.go

@@ -1,3 +1,5 @@
+// +build ee
+
 package invite
 
 import (
@@ -18,7 +20,7 @@ type InviteDeleteHandler struct {
 
 func NewInviteDeleteHandler(
 	config *config.Config,
-) *InviteDeleteHandler {
+) http.Handler {
 	return &InviteDeleteHandler{
 		PorterHandler:         handlers.NewDefaultPorterHandler(config, nil, nil),
 		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),

+ 3 - 1
api/server/handlers/invite/list.go → ee/api/server/handlers/invite/list.go

@@ -1,3 +1,5 @@
+// +build ee
+
 package invite
 
 import (
@@ -18,7 +20,7 @@ type InvitesListHandler struct {
 func NewInvitesListHandler(
 	config *config.Config,
 	writer shared.ResultWriter,
-) *InvitesListHandler {
+) http.Handler {
 	return &InvitesListHandler{
 		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
 	}

+ 3 - 1
api/server/handlers/invite/update_role.go → ee/api/server/handlers/invite/update_role.go

@@ -1,3 +1,5 @@
+// +build ee
+
 package invite
 
 import (
@@ -18,7 +20,7 @@ type InviteUpdateRoleHandler struct {
 func NewInviteUpdateRoleHandler(
 	config *config.Config,
 	decoderValidator shared.RequestDecoderValidator,
-) *InviteUpdateRoleHandler {
+) http.Handler {
 	return &InviteUpdateRoleHandler{
 		PorterHandlerReader: handlers.NewDefaultPorterHandler(config, decoderValidator, nil),
 	}

+ 386 - 0
ee/billing/ironplans.go

@@ -0,0 +1,386 @@
+// +build ee
+
+package billing
+
+import (
+	"crypto/hmac"
+	"crypto/sha256"
+	"encoding/base64"
+	"encoding/hex"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/ee/models"
+	"github.com/porter-dev/porter/ee/repository"
+	"gorm.io/gorm"
+
+	cemodels "github.com/porter-dev/porter/internal/models"
+)
+
+// Client contains an API client for IronPlans
+type Client struct {
+	apiKey    string
+	serverURL string
+	repo      repository.EERepository
+
+	httpClient *http.Client
+}
+
+// NewClient creates a new billing API client
+func NewClient(serverURL, apiKey string, repo repository.EERepository) *Client {
+	httpClient := &http.Client{
+		Timeout: time.Minute,
+	}
+
+	return &Client{apiKey, serverURL, repo, httpClient}
+}
+
+func (c *Client) CreateTeam(proj *cemodels.Project) (string, error) {
+	resp := &Team{}
+	err := c.postRequest("/teams/v1", &CreateTeamRequest{
+		Name: proj.Name,
+	}, resp)
+
+	if err != nil {
+		return "", err
+	}
+
+	_, err = c.repo.ProjectBilling().CreateProjectBilling(&models.ProjectBilling{
+		ProjectID:     proj.ID,
+		BillingTeamID: resp.ID,
+	})
+
+	if err != nil {
+		return "", err
+	}
+
+	return resp.ID, err
+}
+
+func (c *Client) GetTeamID(proj *cemodels.Project) (teamID string, err error) {
+	projBilling, err := c.repo.ProjectBilling().ReadProjectBillingByProjectID(proj.ID)
+
+	if err != nil {
+		return "", err
+	}
+
+	return projBilling.BillingTeamID, nil
+}
+
+func (c *Client) AddUserToTeam(teamID string, user *cemodels.User, role *cemodels.Role) error {
+	roleEnum := RoleEnumMember
+
+	// if user's role is admin, add them to the team as an owner
+	if role.Kind == types.RoleAdmin {
+		roleEnum = RoleEnumOwner
+	}
+
+	req := &AddTeammateRequest{
+		TeamID:   teamID,
+		Role:     roleEnum,
+		Email:    user.Email,
+		SourceID: fmt.Sprintf("%d-%d", role.ProjectID, user.ID),
+	}
+
+	resp := &Teammate{}
+
+	err := c.postRequest("/team_memberships/v1", req, resp)
+
+	if err != nil {
+		return err
+	}
+
+	_, err = c.repo.UserBilling().CreateUserBilling(&models.UserBilling{
+		ProjectID:  role.ProjectID,
+		UserID:     user.ID,
+		TeammateID: resp.ID,
+		Token:      []byte(""),
+	})
+
+	return err
+}
+
+func (c *Client) UpdateUserInTeam(role *cemodels.Role) error {
+	// get the user billing information to get the membership id
+	userBilling, err := c.repo.UserBilling().ReadUserBilling(role.ProjectID, role.UserID)
+
+	if err != nil {
+		return err
+	}
+
+	roleEnum := RoleEnumMember
+
+	// if user's role is admin, add them to the team as an owner
+	if role.Kind == types.RoleAdmin {
+		roleEnum = RoleEnumOwner
+	}
+
+	req := &UpdateTeammateRequest{
+		Role: roleEnum,
+	}
+
+	resp := &Teammate{}
+
+	return c.putRequest(fmt.Sprintf("/team_memberships/v1/%s", userBilling.TeammateID), req, resp)
+}
+
+func (c *Client) RemoveUserFromTeam(role *cemodels.Role) error {
+	// get the user billing information to get the membership id
+	userBilling, err := c.repo.UserBilling().ReadUserBilling(role.ProjectID, role.UserID)
+
+	if err != nil {
+		return err
+	}
+
+	return c.deleteRequest(fmt.Sprintf("/team_memberships/v1/%s", userBilling.TeammateID), nil, nil)
+}
+
+// GetIDToken gets an id token for a user in a project, creating the ID token if necessary
+func (c *Client) GetIDToken(projectID uint, user *cemodels.User) (token string, err error) {
+	// attempt to read the user billing data from the
+	userBilling, err := c.repo.UserBilling().ReadUserBilling(projectID, user.ID)
+	notFound := errors.Is(err, gorm.ErrRecordNotFound)
+
+	if !notFound && err != nil {
+		return "", err
+	}
+
+	if !notFound {
+		token = string(userBilling.Token)
+
+		if token != "" {
+			// check if the JWT token has expired
+			isTokExpired := isExpired(token)
+
+			// if JWT token has not expired, return the token
+			if !isTokExpired {
+				return token, nil
+			}
+		}
+	}
+
+	req := &CreateIDTokenRequest{
+		Email:  user.Email,
+		UserID: fmt.Sprintf("%d-%d", projectID, user.ID),
+	}
+
+	resp := &CreateIDTokenResponse{}
+
+	err = c.postRequest("/customers/v1/token", req, resp)
+
+	if err != nil {
+		return "", err
+	}
+
+	token = resp.Token
+
+	if notFound {
+		_, err := c.repo.UserBilling().CreateUserBilling(&models.UserBilling{
+			ProjectID: projectID,
+			UserID:    user.ID,
+			Token:     []byte(token),
+		})
+
+		if err != nil {
+			return "", err
+		}
+	} else {
+		_, err := c.repo.UserBilling().UpdateUserBilling(&models.UserBilling{
+			Model: &gorm.Model{
+				ID: userBilling.ID,
+			},
+			ProjectID: projectID,
+			UserID:    user.ID,
+			Token:     []byte(token),
+		})
+
+		if err != nil {
+			return "", err
+		}
+	}
+
+	return token, nil
+}
+
+// VerifySignature verifies a webhook signature based on hmac protocol
+// https://docs.ironplans.com/webhook-events/webhook-events
+func (c *Client) VerifySignature(signature string, body []byte) bool {
+	if len(signature) != 71 || !strings.HasPrefix(signature, "sha256=") {
+		return false
+	}
+
+	actual := make([]byte, 32)
+	_, err := hex.Decode(actual, []byte(signature[7:]))
+
+	if err != nil {
+		return false
+	}
+
+	computed := hmac.New(sha256.New, []byte(c.apiKey))
+	_, err = computed.Write(body)
+
+	if err != nil {
+		return false
+	}
+
+	return hmac.Equal(computed.Sum(nil), actual)
+}
+
+func (c *Client) postRequest(path string, data interface{}, dst interface{}) error {
+	return c.writeRequest("POST", path, data, dst)
+}
+
+func (c *Client) putRequest(path string, data interface{}, dst interface{}) error {
+	return c.writeRequest("PUT", path, data, dst)
+}
+
+func (c *Client) deleteRequest(path string, data interface{}, dst interface{}) error {
+	return c.writeRequest("DELETE", path, data, dst)
+}
+
+func (c *Client) writeRequest(method, path string, data interface{}, dst interface{}) error {
+	reqURL, err := url.Parse(c.serverURL)
+
+	if err != nil {
+		return nil
+	}
+
+	reqURL.Path = path
+
+	var strData []byte
+
+	if data != nil {
+		strData, err = json.Marshal(data)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	req, err := http.NewRequest(
+		method,
+		reqURL.String(),
+		strings.NewReader(string(strData)),
+	)
+
+	if err != nil {
+		return err
+	}
+
+	req.Header.Set("Content-Type", "application/json; charset=utf-8")
+	req.Header.Set("Accept", "application/json; charset=utf-8")
+	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiKey))
+
+	res, err := c.httpClient.Do(req)
+
+	if err != nil {
+		return err
+	}
+
+	defer res.Body.Close()
+
+	if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusBadRequest {
+		resBytes, err := ioutil.ReadAll(res.Body)
+
+		if err != nil {
+			return fmt.Errorf("request failed with status code %d, but could not read body (%s)\n", res.StatusCode, err.Error())
+		}
+
+		return fmt.Errorf("request failed with status code %d: %s\n", res.StatusCode, string(resBytes))
+	}
+
+	if dst != nil {
+		return json.NewDecoder(res.Body).Decode(dst)
+	}
+
+	return nil
+}
+
+const (
+	FeatureSlugCPU      string = "cpu"
+	FeatureSlugMemory   string = "memory"
+	FeatureSlugClusters string = "clusters"
+	FeatureSlugUsers    string = "users"
+)
+
+func (c *Client) ParseProjectUsageFromWebhook(payload []byte) (*cemodels.ProjectUsage, error) {
+	subscription := &SubscriptionWebhookRequest{}
+
+	err := json.Unmarshal(payload, subscription)
+
+	if err != nil {
+		return nil, err
+	}
+
+	// if event type is not subscription, return wrong webhook event type error
+	if subscription.EventType != "subscription" {
+		return nil, nil
+	}
+
+	// get the project id linked to that team
+	projBilling, err := c.repo.ProjectBilling().ReadProjectBillingByTeamID(subscription.TeamID)
+
+	if err != nil {
+		return nil, err
+	}
+
+	usage := &cemodels.ProjectUsage{
+		ProjectID: projBilling.ProjectID,
+	}
+
+	for _, feature := range subscription.Plan.Features {
+		// look for slug of "cpus" and "memory"
+		maxLimit := uint(feature.MaxLimit)
+		switch feature.Slug {
+		case FeatureSlugCPU:
+			usage.ResourceCPU = maxLimit
+		case FeatureSlugMemory:
+			usage.ResourceMemory = 1000 * maxLimit
+		case FeatureSlugClusters:
+			usage.Clusters = maxLimit
+		case FeatureSlugUsers:
+			usage.Users = maxLimit
+		}
+	}
+
+	return usage, nil
+}
+
+type expiryJWT struct {
+	ExpiresAt int64 `json:"exp"`
+}
+
+func isExpired(token string) bool {
+	var encoded string
+
+	if tokenSplit := strings.Split(token, "."); len(tokenSplit) != 3 {
+		return true
+	} else {
+		encoded = tokenSplit[1]
+	}
+
+	decodedBytes, err := base64.RawStdEncoding.DecodeString(encoded)
+
+	if err != nil {
+		return true
+	}
+
+	expiryData := &expiryJWT{}
+
+	err = json.Unmarshal(decodedBytes, expiryData)
+
+	if err != nil {
+		return true
+	}
+
+	expiryTime := time.Unix(expiryData.ExpiresAt, 0)
+
+	return expiryTime.Before(time.Now())
+}

+ 84 - 0
ee/billing/types.go

@@ -0,0 +1,84 @@
+// +build ee
+
+package billing
+
+type Team struct {
+	ID           string       `json:"id"`
+	ProviderID   string       `json:"provider_id"`
+	Name         string       `json:"name"`
+	Members      []Teammate   `json:"members"`
+	Subscription Subscription `json:"subscription"`
+}
+
+type RoleEnum string
+
+const (
+	RoleEnumOwner  RoleEnum = "owner"
+	RoleEnumMember RoleEnum = "member"
+)
+
+type Teammate struct {
+	ID         string   `json:"id"`
+	CustomerID string   `json:"customer_id"`
+	Role       RoleEnum `json:"role"`
+	Email      string   `json:"email"`
+}
+
+type Subscription struct {
+	ID       string `json:"id"`
+	Plan     Plan   `json:"plan"`
+	IsActive bool   `json:"is_active"`
+}
+
+type Plan struct {
+	ID         string        `json:"id"`
+	ProviderID string        `json:"string"`
+	Name       string        `json:"name"`
+	IsActive   bool          `json:"is_active"`
+	Features   []PlanFeature `json:"features"`
+}
+
+type PlanFeature struct {
+	ID          string      `json:"id"`
+	IsActive    bool        `json:"is_active"`
+	FeatureSpec FeatureSpec `json:"spec"`
+	Slug        string      `json:"slug"`
+	MaxLimit    int64       `json:"max_limit"`
+}
+
+type FeatureSpec struct {
+	ID         string `json:"id"`
+	Name       string `json:"name"`
+	MaxLimit   int64  `json:"max_limit"`
+	ProviderID string `json:"provider_id"`
+}
+
+type CreateTeamRequest struct {
+	Name string `json:"name"`
+}
+
+type AddTeammateRequest struct {
+	Role     RoleEnum `json:"role"`
+	Email    string   `json:"email"`
+	SourceID string   `json:"source_id"`
+	TeamID   string   `json:"team_id"`
+}
+
+type UpdateTeammateRequest struct {
+	Role RoleEnum `json:"role"`
+}
+
+type CreateIDTokenRequest struct {
+	Email  string `json:"customer_email"`
+	UserID string `json:"customer_source_id"`
+}
+
+type CreateIDTokenResponse struct {
+	Token string `json:"token"`
+}
+
+type SubscriptionWebhookRequest struct {
+	EventType string `json:"event_type"`
+	TeamID    string `json:"team_id"`
+	Plan      Plan   `json:"plan"`
+}

+ 75 - 0
ee/docker/ee.Dockerfile

@@ -0,0 +1,75 @@
+# syntax=docker/dockerfile:1.1.7-experimental
+
+# Base Go environment
+# -------------------
+FROM golang:1.15-alpine as base
+WORKDIR /porter
+
+RUN apk update && apk add --no-cache gcc musl-dev git
+
+COPY go.mod go.sum ./
+COPY /cmd ./cmd
+COPY /internal ./internal
+COPY /api ./api
+COPY /ee ./ee
+
+RUN --mount=type=cache,target=$GOPATH/pkg/mod \
+    go mod download
+
+# Go build environment
+# --------------------
+FROM base AS build-go
+
+ARG version=production
+
+RUN --mount=type=cache,target=/root/.cache/go-build \
+    --mount=type=cache,target=$GOPATH/pkg/mod \
+    go build -ldflags="-w -s -X 'main.Version=${version}'" -tags ee -a -o ./bin/app ./cmd/app && \
+    go build -ldflags '-w -s' -a -tags ee -o ./bin/migrate ./cmd/migrate && \
+    go build -ldflags '-w -s' -a -tags ee -o ./bin/ready ./cmd/ready
+
+# Go test environment
+# -------------------
+FROM base AS porter-test
+
+RUN --mount=type=cache,target=/root/.cache/go-build \
+    --mount=type=cache,target=$GOPATH/pkg/mod \
+    go test ./...
+
+# Webpack build environment
+# -------------------------
+FROM node:latest as build-webpack
+WORKDIR /webpack
+
+COPY ./dashboard ./
+
+RUN npm i
+
+ENV NODE_ENV=production
+
+RUN npm run build
+
+# Deployment environment
+# ----------------------
+FROM alpine
+RUN apk update
+
+COPY --from=build-go /porter/bin/app /porter/
+COPY --from=build-go /porter/bin/migrate /porter/
+COPY --from=build-go /porter/bin/ready /porter/
+COPY --from=build-webpack /webpack/build /porter/static
+
+ENV DEBUG=false
+ENV STATIC_FILE_PATH=/porter/static
+ENV SERVER_PORT=8080
+ENV SERVER_TIMEOUT_READ=5s
+ENV SERVER_TIMEOUT_WRITE=10s
+ENV SERVER_TIMEOUT_IDLE=15s
+
+ENV COOKIE_SECRETS=secret
+
+ENV SQL_LITE=true
+ENV ADMIN_INIT=false
+
+EXPOSE 8080
+CMD /porter/migrate && /porter/app

+ 13 - 0
ee/models/project_billing.go

@@ -0,0 +1,13 @@
+// +build ee
+
+package models
+
+import "gorm.io/gorm"
+
+// ProjectBilling stores a billing data per project
+type ProjectBilling struct {
+	*gorm.Model
+
+	ProjectID     uint
+	BillingTeamID string
+}

+ 15 - 0
ee/models/user_billing.go

@@ -0,0 +1,15 @@
+// +build ee
+
+package models
+
+import "gorm.io/gorm"
+
+// UserBilling stores a billing token per user in a project
+type UserBilling struct {
+	*gorm.Model
+
+	ProjectID  uint
+	UserID     uint
+	TeammateID string
+	Token      []byte
+}

+ 46 - 0
ee/repository/gorm/project_billing.go

@@ -0,0 +1,46 @@
+// +build ee
+
+package gorm
+
+import (
+	"github.com/porter-dev/porter/ee/models"
+	"github.com/porter-dev/porter/ee/repository"
+	"gorm.io/gorm"
+)
+
+// ProjectBillingRepository uses gorm.DB for querying the database
+type ProjectBillingRepository struct {
+	db *gorm.DB
+}
+
+func NewProjectBillingRepository(db *gorm.DB) repository.ProjectBillingRepository {
+	return &ProjectBillingRepository{db}
+}
+
+func (repo *ProjectBillingRepository) CreateProjectBilling(projBilling *models.ProjectBilling) (*models.ProjectBilling, error) {
+	if err := repo.db.Create(projBilling).Error; err != nil {
+		return nil, err
+	}
+
+	return projBilling, nil
+}
+
+func (repo *ProjectBillingRepository) ReadProjectBillingByProjectID(projID uint) (*models.ProjectBilling, error) {
+	projBilling := &models.ProjectBilling{}
+
+	if err := repo.db.Where("project_id = ?", projID).First(&projBilling).Error; err != nil {
+		return nil, err
+	}
+
+	return projBilling, nil
+}
+
+func (repo *ProjectBillingRepository) ReadProjectBillingByTeamID(teamID string) (*models.ProjectBilling, error) {
+	projBilling := &models.ProjectBilling{}
+
+	if err := repo.db.Where("billing_team_id = ?", teamID).First(&projBilling).Error; err != nil {
+		return nil, err
+	}
+
+	return projBilling, nil
+}

+ 29 - 0
ee/repository/gorm/repository.go

@@ -0,0 +1,29 @@
+// +build ee
+
+package gorm
+
+import (
+	"github.com/porter-dev/porter/ee/repository"
+	"gorm.io/gorm"
+)
+
+type GormRepository struct {
+	userBilling repository.UserBillingRepository
+	projBilling repository.ProjectBillingRepository
+}
+
+func (t *GormRepository) UserBilling() repository.UserBillingRepository {
+	return t.userBilling
+}
+
+func (t *GormRepository) ProjectBilling() repository.ProjectBillingRepository {
+	return t.projBilling
+}
+
+// NewEERepository returns an EERepository
+func NewEERepository(db *gorm.DB, key *[32]byte) repository.EERepository {
+	return &GormRepository{
+		userBilling: NewUserBillingRepository(db, key),
+		projBilling: NewProjectBillingRepository(db),
+	}
+}

+ 116 - 0
ee/repository/gorm/user_billing.go

@@ -0,0 +1,116 @@
+// +build ee
+
+package gorm
+
+import (
+	"github.com/porter-dev/porter/ee/models"
+	"github.com/porter-dev/porter/ee/repository"
+	cerepository "github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// UserBillingRepository uses gorm.DB for querying the database
+type UserBillingRepository struct {
+	db  *gorm.DB
+	key *[32]byte
+}
+
+func NewUserBillingRepository(db *gorm.DB, key *[32]byte) repository.UserBillingRepository {
+	return &UserBillingRepository{db, key}
+}
+
+// CreateUserBilling adds a new User row to the Users table in the database
+func (repo *UserBillingRepository) CreateUserBilling(userBilling *models.UserBilling) (*models.UserBilling, error) {
+	err := repo.EncryptUserBillingData(userBilling, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	if err := repo.db.Create(userBilling).Error; err != nil {
+		return nil, err
+	}
+
+	err = repo.DecryptUserBillingData(userBilling, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return userBilling, nil
+}
+
+func (repo *UserBillingRepository) ReadUserBilling(projectID, userID uint) (*models.UserBilling, error) {
+	userBilling := &models.UserBilling{}
+
+	if err := repo.db.Where("project_id = ? AND user_id = ?", projectID, userID).First(&userBilling).Error; err != nil {
+		return nil, err
+	}
+
+	err := repo.DecryptUserBillingData(userBilling, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return userBilling, nil
+}
+
+// UpdateUserBilling updates user billing in the db
+func (repo *UserBillingRepository) UpdateUserBilling(userBilling *models.UserBilling) (*models.UserBilling, error) {
+	err := repo.EncryptUserBillingData(userBilling, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	if err := repo.db.Save(userBilling).Error; err != nil {
+		return nil, err
+	}
+
+	err = repo.DecryptUserBillingData(userBilling, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return userBilling, nil
+}
+
+// EncryptUserBillingData will encrypt the user's billing data before writing
+// to the DB
+func (repo *UserBillingRepository) EncryptUserBillingData(
+	userBilling *models.UserBilling,
+	key *[32]byte,
+) error {
+	if tok := userBilling.Token; len(tok) > 0 {
+		cipherData, err := cerepository.Encrypt(tok, key)
+
+		if err != nil {
+			return err
+		}
+
+		userBilling.Token = cipherData
+	}
+
+	return nil
+}
+
+// DecryptUserBillingData will decrypt the user's billing data before returning it
+// from the DB
+func (repo *UserBillingRepository) DecryptUserBillingData(
+	userBilling *models.UserBilling,
+	key *[32]byte,
+) error {
+	if tok := userBilling.Token; len(tok) > 0 {
+		plaintext, err := cerepository.Decrypt(tok, key)
+
+		if err != nil {
+			return err
+		}
+
+		userBilling.Token = plaintext
+	}
+
+	return nil
+}

+ 11 - 0
ee/repository/project_billing.go

@@ -0,0 +1,11 @@
+// +build ee
+
+package repository
+
+import "github.com/porter-dev/porter/ee/models"
+
+type ProjectBillingRepository interface {
+	CreateProjectBilling(userBilling *models.ProjectBilling) (*models.ProjectBilling, error)
+	ReadProjectBillingByProjectID(projectID uint) (*models.ProjectBilling, error)
+	ReadProjectBillingByTeamID(teamID string) (*models.ProjectBilling, error)
+}

+ 8 - 0
ee/repository/repository.go

@@ -0,0 +1,8 @@
+// +build ee
+
+package repository
+
+type EERepository interface {
+	UserBilling() UserBillingRepository
+	ProjectBilling() ProjectBillingRepository
+}

+ 11 - 0
ee/repository/user_billing.go

@@ -0,0 +1,11 @@
+// +build ee
+
+package repository
+
+import "github.com/porter-dev/porter/ee/models"
+
+type UserBillingRepository interface {
+	CreateUserBilling(userBilling *models.UserBilling) (*models.UserBilling, error)
+	ReadUserBilling(projectID, userID uint) (*models.UserBilling, error)
+	UpdateUserBilling(userBilling *models.UserBilling) (*models.UserBilling, error)
+}

+ 28 - 0
ee/usage/limit.go

@@ -0,0 +1,28 @@
+// +build ee
+
+package usage
+
+import (
+	"errors"
+
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+func GetLimit(repo repository.Repository, proj *models.Project) (limit *types.ProjectUsage, err error) {
+	// query for the project limit; if not found, default to basic
+	limitModel, err := repo.ProjectUsage().ReadProjectUsage(proj.ID)
+
+	if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
+		copyBasic := types.BasicPlan
+		limit = &copyBasic
+	} else if err != nil {
+		return nil, err
+	} else {
+		limit = limitModel.ToProjectUsageType()
+	}
+
+	return limit, nil
+}

+ 75 - 0
internal/billing/billing.go

@@ -0,0 +1,75 @@
+package billing
+
+import (
+	"fmt"
+
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// BillingManager contains methods for managing billing for a project
+type BillingManager interface {
+	// CreateTeam creates the concept of a billing "team". This is currently a one-to-one
+	// mapping with projects, but this may change in the future (i.e. multiple projects
+	// per same team)
+	CreateTeam(proj *models.Project) (teamID string, err error)
+
+	// GetTeamID gets the billing team id for a project
+	GetTeamID(proj *models.Project) (teamID string, err error)
+
+	// AddUserToTeam adds a user to a team, and cases on whether the user can view
+	// billing based on the role.
+	AddUserToTeam(teamID string, user *models.User, role *models.Role) error
+
+	// UpdateUserInTeam updates a user's role in a team, and cases on whether the user can view
+	// billing based on the role.
+	UpdateUserInTeam(role *models.Role) error
+
+	// RemoveUserFromTeam removes a user from a team
+	RemoveUserFromTeam(role *models.Role) error
+
+	// GetIDToken retrieves a billing token for a user. The billing token can be exchanged
+	// to view billing information.
+	GetIDToken(projectID uint, user *models.User) (token string, err error)
+
+	// ParseProjectUsageFromWebhook parses the project usage from a webhook payload sent
+	// from a billing agent
+	ParseProjectUsageFromWebhook(payload []byte) (*models.ProjectUsage, error)
+
+	// VerifySignature verifies the signature for a webhook
+	VerifySignature(signature string, body []byte) bool
+}
+
+// NoopBillingManager performs no billing operations
+type NoopBillingManager struct{}
+
+func (n *NoopBillingManager) CreateTeam(proj *models.Project) (teamID string, err error) {
+	return fmt.Sprintf("%d", proj.ID), nil
+}
+
+func (n *NoopBillingManager) GetTeamID(proj *models.Project) (teamID string, err error) {
+	return fmt.Sprintf("%d", proj.ID), nil
+}
+
+func (n *NoopBillingManager) AddUserToTeam(teamID string, user *models.User, role *models.Role) error {
+	return nil
+}
+
+func (n *NoopBillingManager) UpdateUserInTeam(role *models.Role) error {
+	return nil
+}
+
+func (n *NoopBillingManager) RemoveUserFromTeam(role *models.Role) error {
+	return nil
+}
+
+func (n *NoopBillingManager) GetIDToken(projectID uint, user *models.User) (token string, err error) {
+	return "", nil
+}
+
+func (n *NoopBillingManager) ParseProjectUsageFromWebhook(payload []byte) (*models.ProjectUsage, error) {
+	return nil, nil
+}
+
+func (n *NoopBillingManager) VerifySignature(signature string, body []byte) bool {
+	return false
+}

+ 28 - 0
internal/kubernetes/nodes/nodes.go

@@ -10,6 +10,34 @@ import (
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 )
 
+type TotalAllocatable struct {
+	CPU    uint
+	Memory uint
+}
+
+func GetAllocatableResources(clientset kubernetes.Interface) (*TotalAllocatable, error) {
+	nodeList, err := clientset.CoreV1().Nodes().List(context.TODO(), metav1.ListOptions{})
+
+	if err != nil {
+		return nil, err
+	}
+
+	var totCPU uint64 = 0
+	var totMem uint64 = 0
+
+	for _, node := range nodeList.Items {
+		capac := node.Status.Allocatable
+
+		totCPU += uint64(capac.Cpu().MilliValue())
+		totMem += capac.Memory().AsDec().UnscaledBig().Uint64()
+	}
+
+	return &TotalAllocatable{
+		CPU:    uint(totCPU),
+		Memory: uint(totMem),
+	}, nil
+}
+
 type NodeUsage struct {
 	cpuReqs                        string
 	memoryReqs                     string

+ 12 - 0
internal/models/project.go

@@ -7,6 +7,15 @@ import (
 	ints "github.com/porter-dev/porter/internal/models/integrations"
 )
 
+type ProjectPlan string
+
+const (
+	ProjectPlanBasic      ProjectPlan = "basic"
+	ProjectPlanTeam       ProjectPlan = "team"
+	ProjectPlanGrowth     ProjectPlan = "growth"
+	ProjectPlanEnterprise ProjectPlan = "enterprise"
+)
+
 // Project type that extends gorm.Model
 type Project struct {
 	gorm.Model
@@ -14,6 +23,9 @@ type Project struct {
 	Name  string `json:"name"`
 	Roles []Role `json:"roles"`
 
+	ProjectUsageID      uint
+	ProjectUsageCacheID uint
+
 	// linked repos
 	GitRepos []GitRepo `json:"git_repos,omitempty"`
 

+ 64 - 0
internal/models/usage.go

@@ -0,0 +1,64 @@
+package models
+
+import (
+	"time"
+
+	"github.com/porter-dev/porter/api/types"
+	"gorm.io/gorm"
+)
+
+// ProjectUsage keeps track of the usage limits for a project
+type ProjectUsage struct {
+	gorm.Model
+
+	// The project ID that this model refers to
+	ProjectID uint
+
+	// The CPU usage, in vCPUs
+	ResourceCPU uint
+
+	// The memory usage, in bytes
+	ResourceMemory uint
+
+	// The number of clusters
+	Clusters uint
+
+	// The number of users
+	Users uint
+}
+
+// ToProjectUsageType converts the project usage model to a project usage API type
+func (p *ProjectUsage) ToProjectUsageType() *types.ProjectUsage {
+	return &types.ProjectUsage{
+		ResourceCPU:    p.ResourceCPU,
+		ResourceMemory: p.ResourceMemory,
+		Clusters:       p.Clusters,
+		Users:          p.Users,
+	}
+}
+
+// ProjectUsageCache stores the latest cache of the resource usage for a project,
+// for fields that are expensive to compute
+type ProjectUsageCache struct {
+	gorm.Model
+
+	// The project ID that this model refers to
+	ProjectID uint
+
+	// The CPU usage, in vCPUs
+	ResourceCPU uint
+
+	// The memory usage, in bytes
+	ResourceMemory uint
+
+	// Whether the user is exceeding usage
+	Exceeded bool
+
+	// How long the user has been exceeding resource limits
+	ExceededSince *time.Time
+}
+
+func (p *ProjectUsageCache) Is24HrOld() bool {
+	timeSince := time.Now().Sub(p.UpdatedAt)
+	return timeSince > 24*time.Hour
+}

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

@@ -29,6 +29,8 @@ func AutoMigrate(db *gorm.DB) error {
 		&models.NotificationConfig{},
 		&models.EventContainer{},
 		&models.SubEvent{},
+		&models.ProjectUsage{},
+		&models.ProjectUsageCache{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},

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

@@ -31,6 +31,7 @@ type GormRepository struct {
 	slackIntegration          repository.SlackIntegrationRepository
 	notificationConfig        repository.NotificationConfigRepository
 	event                     repository.EventRepository
+	projectUsage              repository.ProjectUsageRepository
 }
 
 func (t *GormRepository) User() repository.UserRepository {
@@ -133,6 +134,10 @@ func (t *GormRepository) Event() repository.EventRepository {
 	return t.event
 }
 
+func (t *GormRepository) ProjectUsage() repository.ProjectUsageRepository {
+	return t.projectUsage
+}
+
 // 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) repository.Repository {
@@ -162,5 +167,6 @@ func NewRepository(db *gorm.DB, key *[32]byte) repository.Repository {
 		slackIntegration:          NewSlackIntegrationRepository(db, key),
 		notificationConfig:        NewNotificationConfigRepository(db),
 		event:                     NewEventRepository(db),
+		projectUsage:              NewProjectUsageRepository(db),
 	}
 }

+ 87 - 0
internal/repository/gorm/usage.go

@@ -0,0 +1,87 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// ProjectUsageRepository implements repository.ProjectUsageRepository
+type ProjectUsageRepository struct {
+	db *gorm.DB
+}
+
+// NewProjectUsageRepository will return errors if canQuery is false
+func NewProjectUsageRepository(db *gorm.DB) repository.ProjectUsageRepository {
+	return &ProjectUsageRepository{db}
+}
+
+// CreateProjectUsage creates a new project usage limit
+func (repo *ProjectUsageRepository) CreateProjectUsage(
+	usage *models.ProjectUsage,
+) (*models.ProjectUsage, error) {
+	if err := repo.db.Create(usage).Error; err != nil {
+		return nil, err
+	}
+
+	return usage, nil
+}
+
+// ReadProjectUsage finds the project usage matching a project ID
+func (repo *ProjectUsageRepository) ReadProjectUsage(
+	projID uint,
+) (*models.ProjectUsage, error) {
+	res := &models.ProjectUsage{}
+
+	if err := repo.db.Where("project_id = ?", projID).First(res).Error; err != nil {
+		return nil, err
+	}
+
+	return res, nil
+}
+
+// UpdateProjectUsage modifies an existing ProjectUsage in the database
+func (repo *ProjectUsageRepository) UpdateProjectUsage(
+	usage *models.ProjectUsage,
+) (*models.ProjectUsage, error) {
+	if err := repo.db.Save(usage).Error; err != nil {
+		return nil, err
+	}
+
+	return usage, nil
+}
+
+// CreateProjectUsageCache creates a new project usage cache
+func (repo *ProjectUsageRepository) CreateProjectUsageCache(
+	cache *models.ProjectUsageCache,
+) (*models.ProjectUsageCache, error) {
+	if err := repo.db.Create(cache).Error; err != nil {
+		return nil, err
+	}
+
+	return cache, nil
+}
+
+// ReadProjectUsageCache finds the project usage cache matching a project ID
+func (repo *ProjectUsageRepository) ReadProjectUsageCache(
+	projID uint,
+) (*models.ProjectUsageCache, error) {
+	res := &models.ProjectUsageCache{}
+
+	if err := repo.db.Where("project_id = ?", projID).First(res).Error; err != nil {
+		return nil, err
+	}
+
+	return res, nil
+}
+
+// UpdateProjectUsageCache modifies an existing ProjectUsageCache in the database
+func (repo *ProjectUsageRepository) UpdateProjectUsageCache(
+	cache *models.ProjectUsageCache,
+) (*models.ProjectUsageCache, error) {
+	if err := repo.db.Save(cache).Error; err != nil {
+		return nil, err
+	}
+
+	return cache, nil
+}

+ 2 - 1
internal/repository/repository.go

@@ -25,5 +25,6 @@ type Repository interface {
 	GithubAppOAuthIntegration() GithubAppOAuthIntegrationRepository
 	SlackIntegration() SlackIntegrationRepository
 	NotificationConfig() NotificationConfigRepository
-	Event()                     EventRepository
+	Event() EventRepository
+	ProjectUsage() ProjectUsageRepository
 }

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

@@ -30,6 +30,7 @@ type TestRepository struct {
 	slackIntegration          repository.SlackIntegrationRepository
 	notificationConfig        repository.NotificationConfigRepository
 	event                     repository.EventRepository
+	projectUsage              repository.ProjectUsageRepository
 }
 
 func (t *TestRepository) User() repository.UserRepository {
@@ -132,6 +133,10 @@ func (t *TestRepository) Event() repository.EventRepository {
 	return t.event
 }
 
+func (t *TestRepository) ProjectUsage() repository.ProjectUsageRepository {
+	return t.projectUsage
+}
+
 // 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 {

+ 129 - 0
internal/repository/test/usage.go

@@ -0,0 +1,129 @@
+package test
+
+import (
+	"errors"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// ProjectUsageRepository implements repository.ProjectUsageRepository
+type ProjectUsageRepository struct {
+	canQuery bool
+	usages   []*models.ProjectUsage
+	caches   []*models.ProjectUsageCache
+}
+
+// NewProjectUsageRepository will return errors if canQuery is false
+func NewProjectUsageRepository(canQuery bool) repository.ProjectUsageRepository {
+	return &ProjectUsageRepository{
+		canQuery,
+		[]*models.ProjectUsage{},
+		[]*models.ProjectUsageCache{},
+	}
+}
+
+// CreateProjectUsage creates a new project usage limit
+func (repo *ProjectUsageRepository) CreateProjectUsage(
+	usage *models.ProjectUsage,
+) (*models.ProjectUsage, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	if usage == nil {
+		return nil, nil
+	}
+
+	repo.usages = append(repo.usages, usage)
+
+	return usage, nil
+}
+
+// CreateProjectUsage reads a project usage by project id
+func (repo *ProjectUsageRepository) ReadProjectUsage(
+	projID uint,
+) (*models.ProjectUsage, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	for _, pu := range repo.usages {
+		if pu != nil && pu.ProjectID == projID {
+			return pu, nil
+		}
+	}
+
+	return nil, gorm.ErrRecordNotFound
+}
+
+// UpdateProjectUsage modifies an existing ProjectUsage in the database
+func (repo *ProjectUsageRepository) UpdateProjectUsage(
+	usage *models.ProjectUsage,
+) (*models.ProjectUsage, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	if int(usage.ID-1) >= len(repo.usages) || repo.usages[usage.ID-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(usage.ID - 1)
+	repo.usages[index] = usage
+
+	return usage, nil
+}
+
+// CreateProjectUsageCache creates a new project usage cache
+func (repo *ProjectUsageRepository) CreateProjectUsageCache(
+	cache *models.ProjectUsageCache,
+) (*models.ProjectUsageCache, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	if cache == nil {
+		return nil, nil
+	}
+
+	repo.caches = append(repo.caches, cache)
+
+	return cache, nil
+}
+
+// CreateProjectUsageCache reads a project usage by project id
+func (repo *ProjectUsageRepository) ReadProjectUsageCache(
+	projID uint,
+) (*models.ProjectUsageCache, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	for _, puc := range repo.caches {
+		if puc != nil && puc.ProjectID == projID {
+			return puc, nil
+		}
+	}
+
+	return nil, gorm.ErrRecordNotFound
+}
+
+// UpdateProjectUsageCache modifies an existing ProjectUsageCache in the database
+func (repo *ProjectUsageRepository) UpdateProjectUsageCache(
+	cache *models.ProjectUsageCache,
+) (*models.ProjectUsageCache, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	if int(cache.ID-1) >= len(repo.caches) || repo.usages[cache.ID-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(cache.ID - 1)
+	repo.caches[index] = cache
+
+	return cache, nil
+}

+ 13 - 0
internal/repository/usage.go

@@ -0,0 +1,13 @@
+package repository
+
+import "github.com/porter-dev/porter/internal/models"
+
+// ProjectUsageRepository represents the set of queries on the ProjectUsage model
+type ProjectUsageRepository interface {
+	CreateProjectUsage(usage *models.ProjectUsage) (*models.ProjectUsage, error)
+	ReadProjectUsage(projID uint) (*models.ProjectUsage, error)
+	UpdateProjectUsage(cache *models.ProjectUsage) (*models.ProjectUsage, error)
+	CreateProjectUsageCache(cache *models.ProjectUsageCache) (*models.ProjectUsageCache, error)
+	ReadProjectUsageCache(projID uint) (*models.ProjectUsageCache, error)
+	UpdateProjectUsageCache(cache *models.ProjectUsageCache) (*models.ProjectUsageCache, error)
+}

+ 15 - 0
internal/usage/limit_ce.go

@@ -0,0 +1,15 @@
+// +build !ee
+
+package usage
+
+import (
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+func GetLimit(repo repository.Repository, proj *models.Project) (limit *types.ProjectUsage, err error) {
+	copyLimit := types.BasicPlan
+
+	return &copyLimit, nil
+}

+ 16 - 0
internal/usage/limit_ee.go

@@ -0,0 +1,16 @@
+// +build ee
+
+package usage
+
+import (
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/ee/usage"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+var GetLimit func(repo repository.Repository, proj *models.Project) (limit *types.ProjectUsage, err error)
+
+func init() {
+	GetLimit = usage.GetLimit
+}

+ 130 - 0
internal/usage/usage.go

@@ -0,0 +1,130 @@
+package usage
+
+import (
+	"errors"
+	"time"
+
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/kubernetes/nodes"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"golang.org/x/oauth2"
+	"gorm.io/gorm"
+)
+
+type GetUsageOpts struct {
+	Repo    repository.Repository
+	DOConf  *oauth2.Config
+	Project *models.Project
+}
+
+// GetUsage gets a project's current usage and usage limit
+func GetUsage(opts *GetUsageOpts) (
+	current, limit *types.ProjectUsage,
+	resourceUse *models.ProjectUsageCache,
+	err error,
+) {
+	limit, err = GetLimit(opts.Repo, opts.Project)
+
+	if err != nil {
+		return nil, nil, nil, err
+	}
+
+	// query for the linked cluster counts
+	clusters, err := opts.Repo.Cluster().ListClustersByProjectID(opts.Project.ID)
+
+	if err != nil {
+		return nil, nil, nil, err
+	}
+
+	// query for the linked user counts
+	roles, err := opts.Repo.Project().ListProjectRoles(opts.Project.ID)
+
+	if err != nil {
+		return nil, nil, nil, err
+	}
+
+	usageCache, err := opts.Repo.ProjectUsage().ReadProjectUsageCache(opts.Project.ID)
+	isCacheFound := true
+
+	if isCacheFound = !errors.Is(err, gorm.ErrRecordNotFound); err != nil && isCacheFound {
+		return nil, nil, nil, err
+	}
+
+	// if the usage cache is 24 hours old, was not found, or usage is over limit,
+	// re-query for the usage
+	if !isCacheFound || usageCache.Is24HrOld() || usageCache.ResourceMemory > limit.ResourceMemory || usageCache.ResourceCPU > limit.ResourceCPU {
+		cpu, memory, err := getResourceUsage(opts, clusters)
+
+		if err != nil {
+			return nil, nil, nil, err
+		}
+
+		if !isCacheFound {
+			usageCache = &models.ProjectUsageCache{
+				ProjectID:      opts.Project.ID,
+				ResourceCPU:    cpu,
+				ResourceMemory: memory,
+			}
+		} else {
+			usageCache.ResourceCPU = cpu
+			usageCache.ResourceMemory = memory
+		}
+
+		isExceeded := usageCache.ResourceCPU > limit.ResourceCPU || usageCache.ResourceMemory > limit.ResourceMemory
+
+		if !usageCache.Exceeded && isExceeded {
+			// update the usage cache with a time exceeded
+			currTime := time.Now()
+			usageCache.ExceededSince = &currTime
+		}
+
+		usageCache.Exceeded = isExceeded
+
+		if !isCacheFound {
+			usageCache, err = opts.Repo.ProjectUsage().CreateProjectUsageCache(usageCache)
+		} else {
+			usageCache, err = opts.Repo.ProjectUsage().UpdateProjectUsageCache(usageCache)
+		}
+	}
+
+	return &types.ProjectUsage{
+		ResourceCPU:    usageCache.ResourceCPU,
+		ResourceMemory: usageCache.ResourceMemory,
+		Clusters:       uint(len(clusters)),
+		Users:          uint(len(roles)),
+	}, limit, usageCache, nil
+}
+
+// gets the total resource usage across all nodes in all clusters
+func getResourceUsage(opts *GetUsageOpts, clusters []*models.Cluster) (uint, uint, error) {
+	var totCPU, totMem uint = 0, 0
+
+	for _, cluster := range clusters {
+		ooc := &kubernetes.OutOfClusterConfig{
+			Cluster:           cluster,
+			Repo:              opts.Repo,
+			DigitalOceanOAuth: opts.DOConf,
+		}
+
+		agent, err := kubernetes.GetAgentOutOfClusterConfig(ooc)
+
+		if err != nil {
+			continue
+			// return 0, 0, fmt.Errorf("failed to get agent: %s", err.Error())
+		}
+
+		totAlloc, err := nodes.GetAllocatableResources(agent.Clientset)
+
+		if err != nil {
+			continue
+			// return 0, 0, fmt.Errorf("failed to get alloc: %s", err.Error())
+		}
+
+		totCPU += totAlloc.CPU
+		totMem += totAlloc.Memory
+	}
+
+	return totCPU / 1000, totMem / (1000 * 1000), nil
+}

+ 136 - 0
services/usage/usage.go

@@ -0,0 +1,136 @@
+package usage
+
+import (
+	"time"
+
+	"github.com/porter-dev/porter/api/server/shared/config/env"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/adapter"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/oauth"
+	"github.com/porter-dev/porter/internal/repository"
+	"github.com/porter-dev/porter/internal/usage"
+	"golang.org/x/oauth2"
+	"gorm.io/gorm"
+
+	rgorm "github.com/porter-dev/porter/internal/repository/gorm"
+)
+
+type UsageTracker struct {
+	db     *gorm.DB
+	repo   repository.Repository
+	doConf *oauth2.Config
+}
+
+type UsageTrackerOpts struct {
+	DBConf         *env.DBConf
+	DOClientID     string
+	DOClientSecret string
+	DOScopes       []string
+	ServerURL      string
+}
+
+const stepSize = 100
+
+func NewUsageTracker(opts *UsageTrackerOpts) (*UsageTracker, error) {
+	db, err := adapter.New(opts.DBConf)
+
+	if err != nil {
+		return nil, err
+	}
+
+	var key [32]byte
+
+	for i, b := range []byte(opts.DBConf.EncryptionKey) {
+		key[i] = b
+	}
+
+	repo := rgorm.NewRepository(db, &key)
+
+	doConf := oauth.NewDigitalOceanClient(&oauth.Config{
+		ClientID:     opts.DOClientID,
+		ClientSecret: opts.DOClientSecret,
+		Scopes:       opts.DOScopes,
+		BaseURL:      opts.ServerURL,
+	})
+
+	return &UsageTracker{db, repo, doConf}, nil
+}
+
+type UsageTrackerResponse struct {
+	CPULimit      uint
+	CPUUsage      uint
+	MemoryLimit   uint
+	MemoryUsage   uint
+	Exceeded      bool
+	ExceededSince *time.Time
+	Project       *models.Project
+	AdminEmails   []string
+}
+
+func (u *UsageTracker) GetProjectUsage() (map[uint]*UsageTrackerResponse, error) {
+	res := make(map[uint]*UsageTrackerResponse)
+
+	// get the count of the projects
+	var count int64
+
+	if err := u.db.Model(&models.Project{}).Count(&count).Error; err != nil {
+		return nil, err
+	}
+
+	// iterate (count / stepSize) + 1 times using Limit and Offset
+	for i := 0; i < (int(count)/stepSize)+1; i++ {
+		projects := []*models.Project{}
+
+		if err := u.db.Order("id asc").Offset(i * stepSize).Limit(stepSize).Find(&projects).Error; err != nil {
+			return nil, err
+		}
+
+		// go through each project
+		for _, project := range projects {
+			_, limit, cache, err := usage.GetUsage(&usage.GetUsageOpts{
+				Repo:    u.repo,
+				DOConf:  u.doConf,
+				Project: project,
+			})
+
+			if err != nil {
+				continue
+			}
+
+			// get the admin emails for the project
+			roles, err := u.repo.Project().ListProjectRoles(project.ID)
+
+			if err != nil {
+				continue
+			}
+
+			adminEmails := make([]string, 0)
+
+			for _, role := range roles {
+				if role.Kind == types.RoleAdmin {
+					user, err := u.repo.User().ReadUser(role.UserID)
+
+					if err != nil {
+						continue
+					}
+
+					adminEmails = append(adminEmails, user.Email)
+				}
+			}
+
+			res[project.ID] = &UsageTrackerResponse{
+				CPUUsage:      cache.ResourceCPU,
+				CPULimit:      limit.ResourceCPU,
+				MemoryUsage:   cache.ResourceMemory,
+				MemoryLimit:   limit.ResourceMemory,
+				Exceeded:      cache.Exceeded,
+				ExceededSince: cache.ExceededSince,
+				Project:       project,
+				AdminEmails:   adminEmails,
+			}
+		}
+	}
+
+	return res, nil
+}