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

Merge pull request #871 from porter-dev/master

Stage basic RBAC + GitHub app / account settings
jusrhee 4 лет назад
Родитель
Сommit
14ddc63cf8
100 измененных файлов с 3844 добавлено и 717 удалено
  1. 8 3
      .github/workflows/dev.yaml
  2. 7 2
      .github/workflows/staging.yaml
  3. 101 0
      api/types/policy.go
  4. 2 0
      cmd/app/main.go
  5. 8 6
      cmd/migrate/keyrotate/helpers_test.go
  6. 2 0
      cmd/migrate/main.go
  7. 12 4
      dashboard/src/components/Loading.tsx
  8. 4 1
      dashboard/src/components/RadioSelector.tsx
  9. 8 3
      dashboard/src/components/Table.tsx
  10. 59 0
      dashboard/src/components/UnauthorizedPage.tsx
  11. 4 1
      dashboard/src/main/MainWrapper.tsx
  12. 72 33
      dashboard/src/main/home/Home.tsx
  13. 55 18
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  14. 17 2
      dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx
  15. 28 21
      dashboard/src/main/home/cluster-dashboard/dashboard/NamespaceList.tsx
  16. 2 2
      dashboard/src/main/home/cluster-dashboard/dashboard/NodeList.tsx
  17. 26 13
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx
  18. 57 22
      dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx
  19. 19 5
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  20. 23 13
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  21. 9 3
      dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx
  22. 10 6
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ValuesYaml.tsx
  23. 12 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobList.tsx
  24. 16 9
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx
  25. 2 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  26. 18 14
      dashboard/src/main/home/dashboard/Dashboard.tsx
  27. 1 1
      dashboard/src/main/home/integrations/IntegrationList.tsx
  28. 1 5
      dashboard/src/main/home/integrations/Integrations.tsx
  29. 12 3
      dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx
  30. 266 0
      dashboard/src/main/home/modals/AccountSettingsModal.tsx
  31. 1 1
      dashboard/src/main/home/modals/ClusterInstructionsModal.tsx
  32. 1 1
      dashboard/src/main/home/modals/DeleteNamespaceModal.tsx
  33. 176 0
      dashboard/src/main/home/modals/EditInviteOrCollaboratorModal.tsx
  34. 1 1
      dashboard/src/main/home/modals/EnvEditorModal.tsx
  35. 1 1
      dashboard/src/main/home/modals/IntegrationsInstructionsModal.tsx
  36. 1 1
      dashboard/src/main/home/modals/IntegrationsModal.tsx
  37. 1 1
      dashboard/src/main/home/modals/LoadEnvGroupModal.tsx
  38. 1 1
      dashboard/src/main/home/modals/NamespaceModal.tsx
  39. 1 1
      dashboard/src/main/home/modals/UpdateClusterModal.tsx
  40. 46 7
      dashboard/src/main/home/navbar/Navbar.tsx
  41. 421 237
      dashboard/src/main/home/project-settings/InviteList.tsx
  42. 24 10
      dashboard/src/main/home/project-settings/ProjectSettings.tsx
  43. 29 22
      dashboard/src/main/home/sidebar/Sidebar.tsx
  44. 3 1
      dashboard/src/shared/Context.tsx
  45. 49 0
      dashboard/src/shared/api.tsx
  46. 51 0
      dashboard/src/shared/auth/AuthContext.tsx
  47. 54 0
      dashboard/src/shared/auth/AuthorizationHoc.tsx
  48. 62 0
      dashboard/src/shared/auth/RouteGuard.tsx
  49. 127 0
      dashboard/src/shared/auth/authorization-helpers.ts
  50. 28 0
      dashboard/src/shared/auth/types.ts
  51. 21 0
      dashboard/src/shared/auth/useAuth.ts
  52. 9 0
      dashboard/src/shared/hardcodedNameDict.tsx
  53. 1 0
      dashboard/webpack.config.js
  54. 2 0
      docker-compose.dev.yaml
  55. 1 0
      docker/Dockerfile
  56. 32 0
      docs/guides/authorization-and-team-management.md
  57. 23 0
      docs/guides/linking-github-account.md
  58. 14 4
      internal/config/config.go
  59. 8 6
      internal/forms/helper_test.go
  60. 2 0
      internal/forms/invite.go
  61. 3 16
      internal/forms/project.go
  62. 1 0
      internal/helm/agent.go
  63. 14 0
      internal/kubernetes/agent.go
  64. 3 3
      internal/kubernetes/config.go
  65. 31 0
      internal/kubernetes/local/kubeconfig.go
  66. 11 24
      internal/kubernetes/provisioner/provisioner.go
  67. 33 0
      internal/models/integrations/github_app.go
  68. 21 7
      internal/models/integrations/oauth.go
  69. 5 0
      internal/models/invite.go
  70. 0 8
      internal/models/project.go
  71. 3 2
      internal/models/role.go
  72. 3 0
      internal/models/user.go
  73. 24 0
      internal/oauth/config.go
  74. 92 0
      internal/repository/gorm/auth.go
  75. 16 12
      internal/repository/gorm/auth_test.go
  76. 39 6
      internal/repository/gorm/helpers_test.go
  77. 54 0
      internal/repository/gorm/project.go
  78. 117 1
      internal/repository/gorm/project_test.go
  79. 22 20
      internal/repository/gorm/repository.go
  80. 11 0
      internal/repository/gorm/user.go
  81. 34 0
      internal/repository/gorm/user_test.go
  82. 16 0
      internal/repository/integrations.go
  83. 121 2
      internal/repository/memory/auth.go
  84. 123 0
      internal/repository/memory/project.go
  85. 19 17
      internal/repository/memory/repository.go
  86. 19 0
      internal/repository/memory/user.go
  87. 4 0
      internal/repository/project.go
  88. 22 20
      internal/repository/repository.go
  89. 1 0
      internal/repository/user.go
  90. 73 15
      server/api/api.go
  91. 1 1
      server/api/dns_record_handler.go
  92. 210 0
      server/api/integration_handler.go
  93. 43 1
      server/api/invite_handler.go
  94. 7 5
      server/api/oauth_do_handler.go
  95. 98 5
      server/api/oauth_github_handler.go
  96. 196 0
      server/api/project_handler.go
  97. 25 12
      server/api/provision_handler.go
  98. 123 0
      server/api/release_handler.go
  99. 10 5
      server/middleware/auth.go
  100. 144 48
      server/router/router.go

+ 8 - 3
.github/workflows/dev.yaml

@@ -13,6 +13,12 @@ jobs:
           project_id: ${{ secrets.GCP_PROJECT_ID }}
           service_account_key: ${{ secrets.GCP_SA_KEY }}
           export_default_credentials: true
+      - name: Configure AWS Credentials
+        uses: aws-actions/configure-aws-credentials@v1
+        with:
+          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+          aws-region: ${{ secrets.AWS_REGION }}
       - name: Install kubectl
         uses: azure/setup-kubectl@v1
       - name: Log in to gcloud CLI
@@ -42,7 +48,6 @@ jobs:
           docker push gcr.io/porter-dev-273614/porter:dev
       - name: Deploy to cluster
         run: |
-          gcloud container clusters get-credentials \
-            dev --region us-central1 --project ${{ secrets.GCP_PROJECT_ID }}
+          aws eks --region ${{ secrets.AWS_REGION }} update-kubeconfig --name dev
             
-          kubectl rollout restart deployment/porter
+          kubectl rollout restart deployment/porter

+ 7 - 2
.github/workflows/staging.yaml

@@ -13,6 +13,12 @@ jobs:
           project_id: ${{ secrets.GCP_PROJECT_ID }}
           service_account_key: ${{ secrets.GCP_SA_KEY }}
           export_default_credentials: true
+      - name: Configure AWS Credentials
+        uses: aws-actions/configure-aws-credentials@v1
+        with:
+          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+          aws-region: ${{ secrets.AWS_REGION }}
       - name: Install kubectl
         uses: azure/setup-kubectl@v1
       - name: Log in to gcloud CLI
@@ -42,7 +48,6 @@ jobs:
           docker push gcr.io/porter-dev-273614/porter:staging
       - name: Deploy to cluster
         run: |
-          gcloud container clusters get-credentials \
-            staging --region us-central1 --project ${{ secrets.GCP_PROJECT_ID }}
+          aws eks --region ${{ secrets.AWS_REGION }} update-kubeconfig --name staging
             
           kubectl rollout restart deployment/porter

+ 101 - 0
api/types/policy.go

@@ -0,0 +1,101 @@
+package types
+
+type PermissionScope string
+
+const (
+	UserScope        PermissionScope = "user"
+	ProjectScope     PermissionScope = "project"
+	ClusterScope     PermissionScope = "cluster"
+	NamespaceScope   PermissionScope = "namespace"
+	SettingsScope    PermissionScope = "settings"
+	ApplicationScope PermissionScope = "application"
+)
+
+type NameOrUInt struct {
+	Name string `json:"name"`
+	UInt uint   `json:"uint"`
+}
+
+type PolicyDocument struct {
+	Scope     PermissionScope                     `json:"scope"`
+	Resources []NameOrUInt                        `json:"resources"`
+	Verbs     []APIVerb                           `json:"verbs"`
+	Children  map[PermissionScope]*PolicyDocument `json:"children"`
+}
+
+type ScopeTree map[PermissionScope]ScopeTree
+
+/* ScopeHeirarchy describes the scope tree:
+			Project
+		   /	   \
+		Cluster   Settings
+		/
+	Namespace
+       |
+	 Release
+*/
+var ScopeHeirarchy = ScopeTree{
+	ProjectScope: {
+		ClusterScope: {
+			NamespaceScope: {
+				ApplicationScope: {},
+			},
+		},
+		SettingsScope: {},
+	},
+}
+
+type Policy []*PolicyDocument
+
+type APIVerb string
+
+const (
+	APIVerbGet    APIVerb = "get"
+	APIVerbCreate APIVerb = "create"
+	APIVerbList   APIVerb = "list"
+	APIVerbUpdate APIVerb = "update"
+	APIVerbDelete APIVerb = "delete"
+)
+
+type APIVerbGroup []APIVerb
+
+func ReadVerbGroup() APIVerbGroup {
+	return []APIVerb{APIVerbGet, APIVerbList}
+}
+
+func ReadWriteVerbGroup() APIVerbGroup {
+	return []APIVerb{APIVerbGet, APIVerbList, APIVerbCreate, APIVerbUpdate, APIVerbDelete}
+}
+
+var AdminPolicy = []*PolicyDocument{
+	{
+		Scope: ProjectScope,
+		Verbs: ReadWriteVerbGroup(),
+	},
+}
+
+var DeveloperPolicy = []*PolicyDocument{
+	{
+		Scope: ProjectScope,
+		Verbs: ReadWriteVerbGroup(),
+		Children: map[PermissionScope]*PolicyDocument{
+			SettingsScope: {
+				Scope: SettingsScope,
+				Verbs: ReadVerbGroup(),
+			},
+		},
+	},
+}
+
+var ViewerPolicy = []*PolicyDocument{
+	{
+		Scope: ProjectScope,
+		Verbs: ReadVerbGroup(),
+		Children: map[PermissionScope]*PolicyDocument{
+			SettingsScope: {
+				Scope: SettingsScope,
+				Verbs: []APIVerb{},
+			},
+		},
+	},
+}

+ 2 - 0
cmd/app/main.go

@@ -72,6 +72,8 @@ func main() {
 		&ints.ClusterTokenCache{},
 		&ints.RegTokenCache{},
 		&ints.HelmRepoTokenCache{},
+		&ints.GithubAppInstallation{},
+		&ints.GithubAppOAuthIntegration{},
 	)
 
 	if err != nil {

+ 8 - 6
cmd/migrate/keyrotate/helpers_test.go

@@ -253,12 +253,14 @@ func initOAuthIntegration(tester *tester, t *testing.T) {
 	}
 
 	oauth := &ints.OAuthIntegration{
-		Client:       ints.OAuthGithub,
-		ProjectID:    tester.initProjects[0].ID,
-		UserID:       tester.initUsers[0].ID,
-		ClientID:     []byte("exampleclientid"),
-		AccessToken:  []byte("idtoken"),
-		RefreshToken: []byte("refreshtoken"),
+		SharedOAuthModel: ints.SharedOAuthModel{
+			ClientID:     []byte("exampleclientid"),
+			AccessToken:  []byte("idtoken"),
+			RefreshToken: []byte("refreshtoken"),
+		},
+		Client:    ints.OAuthGithub,
+		ProjectID: tester.initProjects[0].ID,
+		UserID:    tester.initUsers[0].ID,
 	}
 
 	oauth, err := tester.repo.OAuthIntegration.CreateOAuthIntegration(oauth)

+ 2 - 0
cmd/migrate/main.go

@@ -57,6 +57,8 @@ func main() {
 		&ints.ClusterTokenCache{},
 		&ints.RegTokenCache{},
 		&ints.HelmRepoTokenCache{},
+		&ints.GithubAppInstallation{},
+		&ints.GithubAppOAuthIntegration{},
 	)
 
 	if err != nil {

+ 12 - 4
dashboard/src/components/Loading.tsx

@@ -4,6 +4,8 @@ import loading from "assets/loading.gif";
 
 type PropsType = {
   offset?: string;
+  width?: string;
+  height?: string;
 };
 
 type StateType = {};
@@ -13,7 +15,11 @@ export default class Loading extends Component<PropsType, StateType> {
 
   render() {
     return (
-      <StyledLoading offset={this.props.offset}>
+      <StyledLoading
+        offset={this.props.offset}
+        width={this.props.width || "100%"}
+        height={this.props.height || "100%"}
+      >
         <Spinner src={loading} />
       </StyledLoading>
     );
@@ -24,11 +30,13 @@ const Spinner = styled.img`
   width: 20px;
 `;
 
+type StyleLoadingProps = PropsType;
+
 const StyledLoading = styled.div`
-  width: 100%;
-  height: 100%;
+  width: ${(props: StyleLoadingProps) => props.width};
+  height: ${(props: StyleLoadingProps) => props.height};
   display: flex;
   align-items: center;
   justify-content: center;
-  margin-top: ${(props: { offset?: string }) => props.offset};
+  margin-top: ${(props: StyleLoadingProps) => props.offset};
 `;

+ 4 - 1
dashboard/src/components/RadioSelector.tsx

@@ -17,7 +17,10 @@ export default class RadioSelector extends Component<PropsType, StateType> {
           (option: { label: string; value: string }, i: number) => {
             let selected = option.value === this.props.selected;
             return (
-              <RadioRow onClick={() => this.props.setSelected(option.value)}>
+              <RadioRow
+                key={option.value}
+                onClick={() => this.props.setSelected(option.value)}
+              >
                 <Indicator selected={selected}>
                   {selected && <Circle />}
                 </Indicator>

+ 8 - 3
dashboard/src/components/Table.tsx

@@ -31,6 +31,7 @@ export type TableProps = {
   onRowClick?: (row: Row) => void;
   isLoading: boolean;
   disableGlobalFilter?: boolean;
+  disableHover?: boolean;
 };
 
 const Table: React.FC<TableProps> = ({
@@ -39,6 +40,7 @@ const Table: React.FC<TableProps> = ({
   onRowClick,
   isLoading,
   disableGlobalFilter = false,
+  disableHover,
 }) => {
   const {
     getTableProps,
@@ -53,7 +55,7 @@ const Table: React.FC<TableProps> = ({
       columns: columnsData,
       data,
     },
-    useGlobalFilter
+    useGlobalFilter,
   );
 
   const renderRows = () => {
@@ -81,6 +83,7 @@ const Table: React.FC<TableProps> = ({
 
           return (
             <StyledTr
+              disableHover={disableHover}
               {...row.getRowProps()}
               enablePointer={!!onRowClick}
               onClick={() => onRowClick && onRowClick(row)}
@@ -161,6 +164,8 @@ export const StyledTd = styled.td`
 
 export const StyledTHead = styled.thead`
   width: 100%;
+  border-top: 1px solid #aaaabb22;
+  border-bottom: 1px solid #aaaabb22;
 `;
 
 export const StyledTh = styled.th`
@@ -205,8 +210,8 @@ const SearchRow = styled.div`
   min-width: 300px;
   max-width: min-content;
   background: #ffffff11;
-  margin-bottom: 7px;
-  margin-top: 7px;
+  margin-bottom: 15px;
+  margin-top: 0px;
   i {
     width: 18px;
     height: 18px;

+ 59 - 0
dashboard/src/components/UnauthorizedPage.tsx

@@ -0,0 +1,59 @@
+import React from "react";
+import styled from "styled-components";
+
+const UnauthorizedPage: React.FunctionComponent = () => (
+  <StyledUnauthorizedPage>
+    <Mega>
+      401
+      <Inside>You're not authorized to access this page</Inside>
+    </Mega>
+  </StyledUnauthorizedPage>
+);
+
+export default UnauthorizedPage;
+
+const StyledUnauthorizedPage = styled.div`
+  font-family: "Work Sans", sans-serif;
+  color: #6f6f6f;
+  font-size: 16px;
+  user-select: none;
+  padding-bottom: 20px;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+`;
+
+const Mega = styled.div`
+  font-size: 200px;
+  color: #ffffff06;
+  position: relative;
+  font-weight: bold;
+  text-align: center;
+
+  > i {
+    font-size: 23px;
+    margin-right: 12px;
+  }
+`;
+
+const Inside = styled.div`
+  position: absolute;
+  color: #6f6f6f;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-weight: 400;
+  font-size: 20px;
+
+  > i {
+    font-size: 23px;
+    margin-right: 12px;
+  }
+`;

+ 4 - 1
dashboard/src/main/MainWrapper.tsx

@@ -4,6 +4,7 @@ import { BrowserRouter } from "react-router-dom";
 import { ContextProvider } from "../shared/Context";
 import Main from "./Main";
 import { RouteComponentProps, withRouter } from "react-router";
+import AuthProvider from "shared/auth/AuthContext";
 
 type PropsType = RouteComponentProps & {};
 
@@ -14,7 +15,9 @@ class MainWrapper extends Component<PropsType, StateType> {
     let { history, location } = this.props;
     return (
       <ContextProvider history={history} location={location}>
-        <Main />
+        <AuthProvider>
+          <Main />
+        </AuthProvider>
       </ContextProvider>
     );
   }

+ 72 - 33
dashboard/src/main/home/Home.tsx

@@ -26,13 +26,31 @@ import ProjectSettings from "./project-settings/ProjectSettings";
 import Sidebar from "./sidebar/Sidebar";
 import PageNotFound from "components/PageNotFound";
 import DeleteNamespaceModal from "./modals/DeleteNamespaceModal";
-
-type PropsType = RouteComponentProps & {
-  logOut: () => void;
-  currentProject: ProjectType;
-  currentCluster: ClusterType;
-  currentRoute: PorterUrl;
-};
+import { fakeGuardedRoute } from "shared/auth/RouteGuard";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+import EditInviteOrCollaboratorModal from "./modals/EditInviteOrCollaboratorModal";
+import AccountSettingsModal from "./modals/AccountSettingsModal";
+// Guarded components
+const GuardedProjectSettings = fakeGuardedRoute("settings", "", [
+  "get",
+  "list",
+  "update",
+  "create",
+  "delete",
+])(ProjectSettings);
+
+const GuardedIntegrations = fakeGuardedRoute("integrations", "", [
+  "get",
+  "list",
+])(Integrations);
+
+type PropsType = RouteComponentProps &
+  WithAuthProps & {
+    logOut: () => void;
+    currentProject: ProjectType;
+    currentCluster: ClusterType;
+    currentRoute: PorterUrl;
+  };
 
 type StateType = {
   forceSidebar: boolean;
@@ -90,9 +108,6 @@ class Home extends Component<PropsType, StateType> {
   };
 
   getCapabilities = () => {
-    let { currentProject } = this.props;
-    if (!currentProject) return;
-
     api
       .getCapabilities("<token>", {}, {})
       .then((res) => {
@@ -336,9 +351,9 @@ class Home extends Component<PropsType, StateType> {
           </DashboardWrapper>
         );
       } else if (currentView === "integrations") {
-        return <Integrations />;
+        return <GuardedIntegrations />;
       } else if (currentView === "project-settings") {
-        return <ProjectSettings />;
+        return <GuardedProjectSettings />;
       }
       return <Templates />;
     } else if (currentView === "new-project") {
@@ -471,19 +486,22 @@ class Home extends Component<PropsType, StateType> {
             <ClusterInstructionsModal />
           </Modal>
         )}
-        {currentModal === "UpdateClusterModal" && (
-          <Modal
-            onRequestClose={() => setCurrentModal(null, null)}
-            width="565px"
-            height="275px"
-          >
-            <UpdateClusterModal
-              setRefreshClusters={(x: boolean) =>
-                this.setState({ forceRefreshClusters: x })
-              }
-            />
-          </Modal>
-        )}
+
+        {/* We should be careful, as this component is named Update but is for deletion */}
+        {this.props.isAuthorized("cluster", "", ["get", "delete"]) &&
+          currentModal === "UpdateClusterModal" && (
+            <Modal
+              onRequestClose={() => setCurrentModal(null, null)}
+              width="565px"
+              height="275px"
+            >
+              <UpdateClusterModal
+                setRefreshClusters={(x: boolean) =>
+                  this.setState({ forceRefreshClusters: x })
+                }
+              />
+            </Modal>
+          )}
         {currentModal === "IntegrationsModal" && (
           <Modal
             onRequestClose={() => setCurrentModal(null, null)}
@@ -502,22 +520,43 @@ class Home extends Component<PropsType, StateType> {
             <IntegrationsInstructionsModal />
           </Modal>
         )}
-        {currentModal === "NamespaceModal" && (
+        {this.props.isAuthorized("namespace", "", ["get", "create"]) &&
+          currentModal === "NamespaceModal" && (
+            <Modal
+              onRequestClose={() => setCurrentModal(null, null)}
+              width="600px"
+              height="220px"
+            >
+              <NamespaceModal />
+            </Modal>
+          )}
+        {this.props.isAuthorized("namespace", "", ["get", "delete"]) &&
+          currentModal === "DeleteNamespaceModal" && (
+            <Modal
+              onRequestClose={() => setCurrentModal(null, null)}
+              width="700px"
+              height="280px"
+            >
+              <DeleteNamespaceModal />
+            </Modal>
+          )}
+
+        {currentModal === "EditInviteOrCollaboratorModal" && (
           <Modal
             onRequestClose={() => setCurrentModal(null, null)}
             width="600px"
-            height="220px"
+            height="250px"
           >
-            <NamespaceModal />
+            <EditInviteOrCollaboratorModal />
           </Modal>
         )}
-        {currentModal === "DeleteNamespaceModal" && (
+        {currentModal === "AccountSettingsModal" && (
           <Modal
             onRequestClose={() => setCurrentModal(null, null)}
-            width="700px"
-            height="280px"
+            width="760px"
+            height="440px"
           >
-            <DeleteNamespaceModal />
+            <AccountSettingsModal />
           </Modal>
         )}
 
@@ -548,7 +587,7 @@ class Home extends Component<PropsType, StateType> {
 
 Home.contextType = Context;
 
-export default withRouter(Home);
+export default withRouter(withAuth(Home));
 
 const ViewWrapper = styled.div`
   height: 100%;

+ 55 - 18
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -17,18 +17,20 @@ import ChartList from "./chart/ChartList";
 import EnvGroupDashboard from "./env-groups/EnvGroupDashboard";
 import NamespaceSelector from "./NamespaceSelector";
 import SortSelector from "./SortSelector";
-import ExpandedChart from "./expanded-chart/ExpandedChart";
 import ExpandedChartWrapper from "./expanded-chart/ExpandedChartWrapper";
 import { RouteComponentProps, withRouter } from "react-router";
 
 import api from "shared/api";
 import DashboardRoutes from "./dashboard/Routes";
-
-type PropsType = RouteComponentProps & {
-  currentCluster: ClusterType;
-  setSidebar: (x: boolean) => void;
-  currentView: PorterUrl;
-};
+import GuardedRoute from "shared/auth/RouteGuard";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+
+type PropsType = RouteComponentProps &
+  WithAuthProps & {
+    currentCluster: ClusterType;
+    setSidebar: (x: boolean) => void;
+    currentView: PorterUrl;
+  };
 
 type StateType = {
   namespace: string;
@@ -128,14 +130,23 @@ class ClusterDashboard extends Component<PropsType, StateType> {
 
   renderBody = () => {
     let { currentCluster, currentView } = this.props;
+    const isAuthorizedToAdd = this.props.isAuthorized(
+      "namespace",
+      [],
+      ["get", "create"]
+    );
     return (
       <>
-        <ControlRow>
-          <Button
-            onClick={() => pushFiltered(this.props, "/launch", ["project_id"])}
-          >
-            <i className="material-icons">add</i> Launch Template
-          </Button>
+        <ControlRow hasMultipleChilds={isAuthorizedToAdd}>
+          {isAuthorizedToAdd && (
+            <Button
+              onClick={() =>
+                pushFiltered(this.props, "/launch", ["project_id"])
+              }
+            >
+              <i className="material-icons">add</i> Launch Template
+            </Button>
+          )}
           <SortFilterWrapper>
             <SortSelector
               setSortType={(sortType) => this.setState({ sortType })}
@@ -203,9 +214,30 @@ class ClusterDashboard extends Component<PropsType, StateType> {
             isMetricsInstalled={this.state.isMetricsInstalled}
           />
         </Route>
-        <Route path={["/jobs", "/applications", "/env-groups"]}>
+        <GuardedRoute
+          path={"/jobs"}
+          scope="job"
+          resource=""
+          verb={["get", "list"]}
+        >
           {this.renderContents()}
-        </Route>
+        </GuardedRoute>
+        <GuardedRoute
+          path={"/applications"}
+          scope="application"
+          resource=""
+          verb={["get", "list"]}
+        >
+          {this.renderContents()}
+        </GuardedRoute>
+        <GuardedRoute
+          path={"/env-groups"}
+          scope="env_group"
+          resource=""
+          verb={["get", "list"]}
+        >
+          {this.renderContents()}
+        </GuardedRoute>
         <Route path={["/cluster-dashboard"]}>
           <DashboardRoutes />
         </Route>
@@ -216,11 +248,16 @@ class ClusterDashboard extends Component<PropsType, StateType> {
 
 ClusterDashboard.contextType = Context;
 
-export default withRouter(ClusterDashboard);
+export default withRouter(withAuth(ClusterDashboard));
 
 const ControlRow = styled.div`
   display: flex;
-  justify-content: space-between;
+  justify-content: ${(props: { hasMultipleChilds: boolean }) => {
+    if (props.hasMultipleChilds) {
+      return "space-between";
+    }
+    return "flex-end";
+  }};
   align-items: center;
   margin-bottom: 35px;
   padding-left: 0px;
@@ -389,7 +426,7 @@ const TitleSection = styled.div`
     margin-left: 10px;
     cursor: pointer;
     font-size: 18px;
-    color: #858FAAaa;
+    color: #858faaaa;
     padding: 5px;
     border-radius: 100px;
     :hover {

+ 17 - 2
dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx

@@ -1,4 +1,4 @@
-import React, { useContext, useState } from "react";
+import React, { useContext, useEffect, useState } from "react";
 import styled from "styled-components";
 
 import { Context } from "shared/Context";
@@ -8,6 +8,7 @@ import NodeList from "./NodeList";
 
 import { NamespaceList } from "./NamespaceList";
 import ClusterSettings from "./ClusterSettings";
+import useAuth from "shared/auth/useAuth";
 
 type TabEnum = "nodes" | "settings" | "namespaces";
 
@@ -22,6 +23,9 @@ const tabOptions: {
 
 export const Dashboard: React.FunctionComponent = () => {
   const [currentTab, setCurrentTab] = useState<TabEnum>("nodes");
+  const [currentTabOptions, setCurrentTabOptions] = useState(tabOptions);
+  const [isAuthorized] = useAuth();
+
   const context = useContext(Context);
   const renderTab = () => {
     switch (currentTab) {
@@ -35,6 +39,17 @@ export const Dashboard: React.FunctionComponent = () => {
     }
   };
 
+  useEffect(() => {
+    setCurrentTabOptions(
+      tabOptions.filter((option) => {
+        if (option.value === "settings") {
+          return isAuthorized("cluster", "", ["get", "delete"]);
+        }
+        return true;
+      })
+    );
+  }, [isAuthorized]);
+
   return (
     <>
       <TitleSection>
@@ -56,7 +71,7 @@ export const Dashboard: React.FunctionComponent = () => {
       </InfoSection>
 
       <TabSelector
-        options={tabOptions}
+        options={currentTabOptions}
         currentTab={currentTab}
         setCurrentTab={(value: TabEnum) => setCurrentTab(value)}
       />

+ 28 - 21
dashboard/src/main/home/cluster-dashboard/dashboard/NamespaceList.tsx

@@ -4,6 +4,7 @@ import { Context } from "shared/Context";
 import { ClusterType, ProjectType } from "shared/types";
 import { pushFiltered } from "shared/routing";
 import { useHistory, useLocation } from "react-router";
+import useAuth from "shared/auth/useAuth";
 
 const OptionsDropdown: React.FC = ({ children }) => {
   const [isOpen, setIsOpen] = useState(false);
@@ -68,6 +69,8 @@ export const NamespaceList: React.FunctionComponent = () => {
     setCurrentModal("DeleteNamespaceModal", namespace);
   };
 
+  const [isAuthorized] = useAuth();
+
   const isAvailableForDeletion = (namespaceName: string) => {
     // Only the namespaces that doesn't start with kube- or has by name default will be
     // available for deletion (as those are the k8s namespaces)
@@ -109,7 +112,7 @@ export const NamespaceList: React.FunctionComponent = () => {
             (namespace) => namespace.metadata.name === data.Object.metadata.name
           );
           oldNamespaces.splice(oldNamespaceIndex, 1, data.Object);
-          return oldNamespaces;
+          return [...oldNamespaces];
         });
       }
     };
@@ -133,18 +136,20 @@ export const NamespaceList: React.FunctionComponent = () => {
   return (
     <NamespaceListWrapper>
       <ControlRow>
-        <Button
-          onClick={() =>
-            setCurrentModal(
-              "NamespaceModal",
-              namespaces.map((namespace) => ({
-                value: namespace.metadata.name,
-              }))
-            )
-          }
-        >
-          <i className="material-icons">add</i> Add namespace
-        </Button>
+        {isAuthorized("namespace", "", ["get", "create"]) && (
+          <Button
+            onClick={() =>
+              setCurrentModal(
+                "NamespaceModal",
+                namespaces.map((namespace) => ({
+                  value: namespace.metadata.name,
+                }))
+              )
+            }
+          >
+            <i className="material-icons">add</i> Add namespace
+          </Button>
+        )}
       </ControlRow>
       <NamespacesGrid>
         {sortedNamespaces.map((namespace) => {
@@ -165,14 +170,16 @@ export const NamespaceList: React.FunctionComponent = () => {
                   {namespace?.status?.phase}
                 </Status>
               </ContentContainer>
-              {isAvailableForDeletion(namespace?.metadata?.name) && (
-                <OptionsDropdown>
-                  <DropdownOption onClick={() => onDelete(namespace)}>
-                    <i className="material-icons-outlined">delete</i>
-                    <span>Delete</span>
-                  </DropdownOption>
-                </OptionsDropdown>
-              )}
+              {isAuthorized("namespace", "", ["get", "delete"]) &&
+                isAvailableForDeletion(namespace?.metadata?.name) &&
+                namespace?.status?.phase === "Active" && (
+                  <OptionsDropdown>
+                    <DropdownOption onClick={() => onDelete(namespace)}>
+                      <i className="material-icons-outlined">delete</i>
+                      <span>Delete</span>
+                    </DropdownOption>
+                  </OptionsDropdown>
+                )}
             </StyledCard>
           );
         })}

+ 2 - 2
dashboard/src/main/home/cluster-dashboard/dashboard/NodeList.tsx

@@ -18,11 +18,11 @@ const NodeList: React.FC = () => {
   const columns = useMemo<Column<any>[]>(
     () => [
       {
-        Header: "Node name",
+        Header: "Node Name",
         accessor: "name",
       },
       {
-        Header: "Machine type",
+        Header: "Machine Type",
         accessor: "machine_type",
       },
       {

+ 26 - 13
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx

@@ -14,10 +14,12 @@ import CreateEnvGroup from "./CreateEnvGroup";
 import ExpandedEnvGroup from "./ExpandedEnvGroup";
 import { RouteComponentProps, withRouter } from "react-router";
 import { pushQueryParams } from "shared/routing";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 
-type PropsType = RouteComponentProps & {
-  currentCluster: ClusterType;
-};
+type PropsType = RouteComponentProps &
+  WithAuthProps & {
+    currentCluster: ClusterType;
+  };
 
 type StateType = {
   expand: boolean;
@@ -59,16 +61,22 @@ class EnvGroupDashboard extends Component<PropsType, StateType> {
         />
       );
     } else {
+      const isAuthorizedToAdd = this.props.isAuthorized("env_group", "", [
+        "get",
+        "create",
+      ]);
       return (
         <>
-          <ControlRow>
-            <Button
-              onClick={() =>
-                this.setState({ createEnvMode: !this.state.createEnvMode })
-              }
-            >
-              <i className="material-icons">add</i> Create Env Group
-            </Button>
+          <ControlRow hasMultipleChilds={isAuthorizedToAdd}>
+            {isAuthorizedToAdd && (
+              <Button
+                onClick={() =>
+                  this.setState({ createEnvMode: !this.state.createEnvMode })
+                }
+              >
+                <i className="material-icons">add</i> Create Env Group
+              </Button>
+            )}
             <SortFilterWrapper>
               <SortSelector
                 setSortType={(sortType) => this.setState({ sortType })}
@@ -131,7 +139,7 @@ class EnvGroupDashboard extends Component<PropsType, StateType> {
 
 EnvGroupDashboard.contextType = Context;
 
-export default withRouter(EnvGroupDashboard);
+export default withRouter(withAuth(EnvGroupDashboard));
 
 const SortFilterWrapper = styled.div`
   width: 468px;
@@ -141,7 +149,12 @@ const SortFilterWrapper = styled.div`
 
 const ControlRow = styled.div`
   display: flex;
-  justify-content: space-between;
+  justify-content: ${(props: { hasMultipleChilds: boolean }) => {
+    if (props.hasMultipleChilds) {
+      return "space-between";
+    }
+    return "flex-end";
+  }};
   align-items: center;
   margin-bottom: 35px;
   padding-left: 0px;

+ 57 - 22
dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx

@@ -15,8 +15,9 @@ import TabRegion from "components/TabRegion";
 import EnvGroupArray, { KeyValueType } from "./EnvGroupArray";
 import Heading from "components/values-form/Heading";
 import Helper from "components/values-form/Helper";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 
-type PropsType = {
+type PropsType = WithAuthProps & {
   namespace: string;
   envGroup: any;
   currentCluster: ClusterType;
@@ -30,6 +31,7 @@ type StateType = {
   deleting: boolean;
   saveValuesStatus: string | null;
   envVariables: KeyValueType[];
+  tabOptions: { value: string; label: string }[];
 };
 
 const tabOptions = [
@@ -37,7 +39,7 @@ const tabOptions = [
   { value: "settings", label: "Settings" },
 ];
 
-export default class ExpandedEnvGroup extends Component<PropsType, StateType> {
+class ExpandedEnvGroup extends Component<PropsType, StateType> {
   state = {
     loading: true,
     currentTab: "environment",
@@ -45,6 +47,10 @@ export default class ExpandedEnvGroup extends Component<PropsType, StateType> {
     deleting: false,
     saveValuesStatus: null as string | null,
     envVariables: [] as KeyValueType[],
+    tabOptions: [
+      { value: "environment", label: "Environment Variables" },
+      { value: "settings", label: "Settings" },
+    ],
   };
 
   componentDidMount() {
@@ -63,6 +69,21 @@ export default class ExpandedEnvGroup extends Component<PropsType, StateType> {
     }
 
     this.setState({ envVariables });
+
+    // Filter the settings tab options as for now it only shows the delete button.
+    // In a future this should be removed and return to a constant if we want to show data
+    // inside the settings tab. (This is make to avoid confussion for the user)
+    this.setState((prevState) => {
+      return {
+        ...prevState,
+        tabOptions: prevState.tabOptions.filter((option) => {
+          if (option.value === "settings") {
+            return this.props.isAuthorized("env_group", "", ["get", "delete"]);
+          }
+          return true;
+        }),
+      };
+    });
   }
 
   handleUpdateValues = () => {
@@ -170,32 +191,44 @@ export default class ExpandedEnvGroup extends Component<PropsType, StateType> {
                 setValues={(x: any) => this.setState({ envVariables: x })}
                 fileUpload={true}
                 secretOption={true}
+                disabled={
+                  !this.props.isAuthorized("env_group", "", [
+                    "get",
+                    "create",
+                    "delete",
+                    "update",
+                  ])
+                }
               />
             </InnerWrapper>
-            <SaveButton
-              text="Update"
-              onClick={() => this.handleUpdateValues()}
-              status={this.state.saveValuesStatus}
-              makeFlush={true}
-            />
+            {this.props.isAuthorized("env_group", "", ["get", "update"]) && (
+              <SaveButton
+                text="Update"
+                onClick={() => this.handleUpdateValues()}
+                status={this.state.saveValuesStatus}
+                makeFlush={true}
+              />
+            )}
           </TabWrapper>
         );
       default:
         return (
           <TabWrapper>
-            <InnerWrapper full={true}>
-              <Heading>Manage Environment Group</Heading>
-              <Helper>
-                Permanently delete this set of environment variables. This
-                action cannot be undone.
-              </Helper>
-              <Button
-                color="#b91133"
-                onClick={() => this.setState({ showDeleteOverlay: true })}
-              >
-                Delete {name}
-              </Button>
-            </InnerWrapper>
+            {this.props.isAuthorized("env_group", "", ["get", "delete"]) && (
+              <InnerWrapper full={true}>
+                <Heading>Manage Environment Group</Heading>
+                <Helper>
+                  Permanently delete this set of environment variables. This
+                  action cannot be undone.
+                </Helper>
+                <Button
+                  color="#b91133"
+                  onClick={() => this.setState({ showDeleteOverlay: true })}
+                >
+                  Delete {name}
+                </Button>
+              </InnerWrapper>
+            )}
           </TabWrapper>
         );
     }
@@ -292,7 +325,7 @@ export default class ExpandedEnvGroup extends Component<PropsType, StateType> {
           <TabRegion
             currentTab={this.state.currentTab}
             setCurrentTab={(x: string) => this.setState({ currentTab: x })}
-            options={tabOptions}
+            options={this.state.tabOptions}
             color={null}
           >
             {this.renderTabContents()}
@@ -305,6 +338,8 @@ export default class ExpandedEnvGroup extends Component<PropsType, StateType> {
 
 ExpandedEnvGroup.contextType = Context;
 
+export default withAuth(ExpandedEnvGroup);
+
 const Button = styled.button`
   height: 35px;
   font-size: 13px;

+ 19 - 5
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -26,8 +26,9 @@ import ListSection from "./ListSection";
 import StatusSection from "./status/StatusSection";
 import SettingsSection from "./SettingsSection";
 import ChartList from "../chart/ChartList";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 
-type PropsType = {
+type PropsType = WithAuthProps & {
   namespace: string;
   currentChart: ChartType;
   currentCluster: ClusterType;
@@ -58,7 +59,7 @@ type StateType = {
   newestImage: string;
 };
 
-export default class ExpandedChart extends Component<PropsType, StateType> {
+class ExpandedChart extends Component<PropsType, StateType> {
   state = {
     currentChart: this.props.currentChart,
     loading: true,
@@ -444,7 +445,13 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
         );
       case "values":
         return (
-          <ValuesYaml currentChart={chart} refreshChart={this.refreshChart} />
+          <ValuesYaml
+            currentChart={chart}
+            refreshChart={this.refreshChart}
+            disabled={
+              !this.props.isAuthorized("application", "", ["get", "update"])
+            }
+          />
         );
       default:
     }
@@ -474,7 +481,9 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
     }
 
     // Settings tab is always last
-    tabOptions.push({ label: "Settings", value: "settings" });
+    if (this.props.isAuthorized("application", "", ["get", "delete"])) {
+      tabOptions.push({ label: "Settings", value: "settings" });
+    }
 
     // Filter tabs if previewing an old revision or updating the chart version
     if (this.state.isPreview || this.state.isUpdatingChart) {
@@ -787,7 +796,10 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
           </HeaderWrapper>
           <BodyWrapper>
             <FormWrapper
-              isReadOnly={this.state.imageIsPlaceholder}
+              isReadOnly={
+                this.state.imageIsPlaceholder ||
+                !this.props.isAuthorized("application", "", ["get", "update"])
+              }
               formData={this.state.formData}
               tabOptions={this.state.tabOptions}
               isInModal={true}
@@ -817,6 +829,8 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
 
 ExpandedChart.contextType = Context;
 
+export default withAuth(ExpandedChart);
+
 const TextWrap = styled.div``;
 
 const Header = styled.div`

+ 23 - 13
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx

@@ -17,8 +17,9 @@ import JobList from "./jobs/JobList";
 import SettingsSection from "./SettingsSection";
 import FormWrapper from "components/values-form/FormWrapper";
 import { PlaceHolder } from "brace";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 
-type PropsType = {
+type PropsType = WithAuthProps & {
   namespace: string;
   currentChart: ChartType;
   currentCluster: ClusterType;
@@ -43,7 +44,7 @@ type StateType = {
   valuesToOverride: any;
 };
 
-export default class ExpandedJobChart extends Component<PropsType, StateType> {
+class ExpandedJobChart extends Component<PropsType, StateType> {
   state = {
     currentChart: this.props.currentChart,
     imageIsPlaceholder: false,
@@ -457,15 +458,17 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
         );
       case "settings":
         return (
-          <SettingsSection
-            showSource={true}
-            currentChart={this.state.currentChart}
-            refreshChart={() => this.refreshChart(0)}
-            setShowDeleteOverlay={(x: boolean) =>
-              this.setState({ showDeleteOverlay: x })
-            }
-            saveButtonText="Save Config"
-          />
+          this.props.isAuthorized("job", "", ["get", "delete"]) && (
+            <SettingsSection
+              showSource={true}
+              currentChart={this.state.currentChart}
+              refreshChart={() => this.refreshChart(0)}
+              setShowDeleteOverlay={(x: boolean) =>
+                this.setState({ showDeleteOverlay: x })
+              }
+              saveButtonText="Save Config"
+            />
+          )
         );
       default:
     }
@@ -494,7 +497,9 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
       });
     }
 
-    tabOptions.push({ label: "Settings", value: "settings" });
+    if (this.props.isAuthorized("job", "", ["get", "delete"])) {
+      tabOptions.push({ label: "Settings", value: "settings" });
+    }
 
     // Filter tabs if previewing an old revision
     this.setState({ tabOptions });
@@ -612,7 +617,10 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
 
           <BodyWrapper>
             <FormWrapper
-              isReadOnly={this.state.imageIsPlaceholder}
+              isReadOnly={
+                this.state.imageIsPlaceholder ||
+                !this.props.isAuthorized("job", "", ["get", "update"])
+              }
               valuesToOverride={this.state.valuesToOverride}
               clearValuesToOverride={() =>
                 this.setState({ valuesToOverride: {} })
@@ -637,6 +645,8 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
 
 ExpandedJobChart.contextType = Context;
 
+export default withAuth(ExpandedJobChart);
+
 const TextWrap = styled.div``;
 
 const Header = styled.div`

+ 9 - 3
dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx

@@ -7,8 +7,9 @@ import { Context } from "shared/Context";
 import { ChartType, StorageType } from "shared/types";
 
 import ConfirmOverlay from "components/ConfirmOverlay";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 
-type PropsType = {
+type PropsType = WithAuthProps & {
   showRevisions: boolean;
   toggleShowRevisions: () => void;
   chart: ChartType;
@@ -31,7 +32,7 @@ type StateType = {
 };
 
 // TODO: handle refresh when new revision is generated from an old revision
-export default class RevisionSection extends Component<PropsType, StateType> {
+class RevisionSection extends Component<PropsType, StateType> {
   state = {
     revisions: [] as ChartType[],
     rollbackRevision: null as number | null,
@@ -227,7 +228,10 @@ export default class RevisionSection extends Component<PropsType, StateType> {
           <Td>v{revision.chart.metadata.version}</Td>
           <Td>
             <RollbackButton
-              disabled={isCurrent}
+              disabled={
+                isCurrent ||
+                !this.props.isAuthorized("application", "", ["get", "update"])
+              }
               onClick={() =>
                 this.setState({ rollbackRevision: revision.version })
               }
@@ -341,6 +345,8 @@ export default class RevisionSection extends Component<PropsType, StateType> {
 
 RevisionSection.contextType = Context;
 
+export default withAuth(RevisionSection);
+
 const TableWrapper = styled.div`
   padding-bottom: 20px;
 `;

+ 10 - 6
dashboard/src/main/home/cluster-dashboard/expanded-chart/ValuesYaml.tsx

@@ -12,6 +12,7 @@ import SaveButton from "components/SaveButton";
 type PropsType = {
   currentChart: ChartType;
   refreshChart: () => void;
+  disabled?: boolean;
 };
 
 type StateType = {
@@ -89,14 +90,17 @@ export default class ValuesYaml extends Component<PropsType, StateType> {
           <YamlEditor
             value={this.state.values}
             onChange={(e: any) => this.setState({ values: e })}
+            readOnly={this.props.disabled}
           />
         </Wrapper>
-        <SaveButton
-          text="Update Values"
-          onClick={this.handleSaveValues}
-          status={this.state.saveValuesStatus}
-          makeFlush={true}
-        />
+        {!this.props.disabled && (
+          <SaveButton
+            text="Update Values"
+            onClick={this.handleSaveValues}
+            status={this.state.saveValuesStatus}
+            makeFlush={true}
+          />
+        )}
       </StyledValuesYaml>
     );
   }

+ 12 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobList.tsx

@@ -6,8 +6,9 @@ import _ from "lodash";
 import { Context } from "shared/Context";
 import JobResource from "./JobResource";
 import ConfirmOverlay from "components/ConfirmOverlay";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 
-type PropsType = {
+type PropsType = WithAuthProps & {
   jobs: any[];
   setJobs: (job: any) => void;
 };
@@ -17,7 +18,7 @@ type StateType = {
   deletionJob: any;
 };
 
-export default class JobList extends Component<PropsType, StateType> {
+class JobList extends Component<PropsType, StateType> {
   state = {
     deletionCandidate: null as any,
     deletionJob: null as any,
@@ -43,6 +44,13 @@ export default class JobList extends Component<PropsType, StateType> {
                 deleting={
                   this.state.deletionJob?.metadata?.name == job.metadata?.name
                 }
+                readOnly={
+                  !this.props.isAuthorized("job", "", [
+                    "get",
+                    "update",
+                    "delete",
+                  ])
+                }
               />
             );
           })}
@@ -100,6 +108,8 @@ export default class JobList extends Component<PropsType, StateType> {
 
 JobList.contextType = Context;
 
+export default withAuth(JobList);
+
 const Placeholder = styled.div`
   width: 100%;
   height: 100%;

+ 16 - 9
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx

@@ -14,6 +14,7 @@ type PropsType = {
   job: any;
   handleDelete: () => void;
   deleting: boolean;
+  readOnly?: boolean;
 };
 
 type StateType = {
@@ -243,6 +244,10 @@ export default class JobResource extends Component<PropsType, StateType> {
   };
 
   renderStopButton = () => {
+    if (this.props.readOnly) {
+      return null;
+    }
+
     if (!this.props.job.status?.succeeded && !this.props.job.status?.failed) {
       // look for a sidecar container
       if (this.props.job?.spec?.template?.spec?.containers.length == 2) {
@@ -281,15 +286,17 @@ export default class JobResource extends Component<PropsType, StateType> {
               {this.renderStatus()}
               <MaterialIconTray disabled={false}>
                 {this.renderStopButton()}
-                <i
-                  className="material-icons"
-                  onClick={(e) => {
-                    e.stopPropagation();
-                    this.props.handleDelete();
-                  }}
-                >
-                  delete
-                </i>
+                {!this.props.readOnly && (
+                  <i
+                    className="material-icons"
+                    onClick={(e) => {
+                      e.stopPropagation();
+                      this.props.handleDelete();
+                    }}
+                  >
+                    delete
+                  </i>
+                )}
                 <i className="material-icons" onClick={this.expandJob}>
                   {this.state.expanded ? "expand_less" : "expand_more"}
                 </i>

+ 2 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx

@@ -139,6 +139,8 @@ export default class Logs extends Component<PropsType, StateType> {
     if (prevState.currentTab !== this.state.currentTab) {
       let { selectedPod } = this.props;
 
+      this.ws?.close();
+
       this.setState({ logs: [] });
 
       if (this.state.currentTab == "Application") {

+ 18 - 14
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -14,11 +14,13 @@ import Provisioner from "../provisioner/Provisioner";
 import FormDebugger from "components/values-form/FormDebugger";
 
 import { pushQueryParams, pushFiltered } from "shared/routing";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 
-type PropsType = RouteComponentProps & {
-  projectId: number | null;
-  setRefreshClusters: (x: boolean) => void;
-};
+type PropsType = RouteComponentProps &
+  WithAuthProps & {
+    projectId: number | null;
+    setRefreshClusters: (x: boolean) => void;
+  };
 
 // TODO: rethink this list, should be coupled with tabOptions
 const tabOptionStrings = ["overview", "create-cluster", "provisioner"];
@@ -126,11 +128,13 @@ class Dashboard extends Component<PropsType, StateType> {
     let { currentProject, capabilities } = this.context;
     let { onShowProjectSettings } = this;
 
-    let tabOptions = [
-      { label: "Project Overview", value: "overview" },
-      { label: "Create a Cluster", value: "create-cluster" },
-      { label: "Provisioner Status", value: "provisioner" },
-    ];
+    let tabOptions = [{ label: "Project Overview", value: "overview" }];
+
+    if (this.props.isAuthorized("cluster", "", ["get", "create"])) {
+      tabOptions.push({ label: "Create a Cluster", value: "create-cluster" });
+    }
+
+    tabOptions.push({ label: "Provisioner Status", value: "provisioner" });
 
     if (!capabilities?.provisioner) {
       tabOptions = [{ label: "Project Overview", value: "overview" }];
@@ -154,9 +158,9 @@ class Dashboard extends Component<PropsType, StateType> {
                     </Overlay>
                   </DashboardIcon>
                   <Title>{currentProject && currentProject.name}</Title>
-                  {this.context.currentProject.roles.filter((obj: any) => {
+                  {this.context.currentProject?.roles?.filter((obj: any) => {
                     return obj.user_id === this.context.user.userId;
-                  })[0].kind === "admin" && (
+                  })[0].kind === "admin" || (
                     <i
                       className="material-icons"
                       onClick={onShowProjectSettings}
@@ -195,7 +199,7 @@ class Dashboard extends Component<PropsType, StateType> {
 
 Dashboard.contextType = Context;
 
-export default withRouter(Dashboard);
+export default withRouter(withAuth(Dashboard));
 
 const DashboardWrapper = styled.div`
   padding-bottom: 100px;
@@ -318,8 +322,8 @@ const TitleSection = styled.div`
   > i {
     margin-left: 10px;
     cursor: pointer;
-    font-size 18px;
-    color: #858FAAaa;
+    font-size: 18px;
+    color: #858faaaa;
     padding: 5px;
     border-radius: 100px;
     :hover {

+ 1 - 1
dashboard/src/main/home/integrations/IntegrationList.tsx

@@ -134,7 +134,7 @@ export default class IntegrationList extends Component<PropsType, StateType> {
             label={label}
             toggleCollapse={(e: MouseEvent) => this.toggleDisplay(e, i)}
             triggerDelete={(e: MouseEvent) => this.triggerDelete(e, i, item_id)}
-          ></IntegrationRow>
+          />
         );
       });
     } else if (integrations && integrations.length > 0) {

+ 1 - 5
dashboard/src/main/home/integrations/Integrations.tsx

@@ -72,11 +72,7 @@ class Integrations extends Component<PropsType, StateType> {
             if (!IntegrationCategoryStrings.includes(currentCategory)) {
               pushFiltered(this.props, "/integrations", ["project_id"]);
             }
-            return (
-              <IntegrationCategories
-                category={currentCategory}
-              ></IntegrationCategories>
-            );
+            return <IntegrationCategories category={currentCategory} />;
           }}
         />
         <Route>

+ 12 - 3
dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx

@@ -19,8 +19,9 @@ import Helper from "components/values-form/Helper";
 import FormWrapper from "components/values-form/FormWrapper";
 import Selector from "components/Selector";
 import Loading from "components/Loading";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 
-type PropsType = {
+type PropsType = WithAuthProps & {
   onSubmit: (x?: any) => void;
   hasSource: boolean;
   setPage: (x: string) => void;
@@ -44,7 +45,7 @@ type StateType = {
   namespaceOptions: { label: string; value: string }[];
 };
 
-export default class SettingsPage extends Component<PropsType, StateType> {
+class SettingsPage extends Component<PropsType, StateType> {
   state = {
     tabOptions: [] as ChoiceType[],
     currentTab: "",
@@ -152,6 +153,9 @@ export default class SettingsPage extends Component<PropsType, StateType> {
               clusterId: this.context.currentCluster.id,
               isLaunch: true,
             }}
+            isReadOnly={
+              !this.props.isAuthorized("namespace", "", ["get", "create"])
+            }
             onSubmit={onSubmit}
           />
         </>
@@ -261,7 +265,10 @@ export default class SettingsPage extends Component<PropsType, StateType> {
               refreshOptions={() => {
                 this.updateNamespaces(this.context.currentCluster.id);
               }}
-              addButton={true}
+              addButton={this.props.isAuthorized("namespace", "", [
+                "get",
+                "create",
+              ])}
               activeValue={selectedNamespace}
               setActiveValue={setSelectedNamespace}
               options={this.state.namespaceOptions}
@@ -279,6 +286,8 @@ export default class SettingsPage extends Component<PropsType, StateType> {
 
 SettingsPage.contextType = Context;
 
+export default withAuth(SettingsPage);
+
 const LoadingWrapper = styled.div`
   margin-top: 80px;
 `;

+ 266 - 0
dashboard/src/main/home/modals/AccountSettingsModal.tsx

@@ -0,0 +1,266 @@
+import React, { useContext, useEffect, useState } from "react";
+import styled from "styled-components";
+
+import close from "assets/close.png";
+import github from "assets/github.png";
+
+import { Context } from "../../../shared/Context";
+import api from "../../../shared/api";
+import Loading from "../../../components/Loading";
+import Heading from "components/values-form/Heading";
+import Helper from "components/values-form/Helper";
+
+import TabSelector from "components/TabSelector";
+
+interface GithubAppAccessData {
+  has_access: boolean;
+  username?: string;
+  accounts?: string[];
+}
+
+const tabOptions = [{ label: "Integrations", value: "integrations" }];
+
+const AccountSettingsModal = () => {
+  const { setCurrentModal } = useContext(Context);
+  const [accessLoading, setAccessLoading] = useState(true);
+  const [accessError, setAccessError] = useState(false);
+  const [accessData, setAccessData] = useState<GithubAppAccessData>({
+    has_access: false,
+  });
+  const [currentTab, setCurrentTab] = useState("integrations");
+
+  useEffect(() => {
+    api
+      .getGithubAccess("<token>", {}, {})
+      .then(({ data }) => {
+        setAccessData(data);
+        setAccessLoading(false);
+      })
+      .catch(() => {
+        setAccessError(true);
+        setAccessLoading(false);
+      });
+  }, []);
+
+  return (
+    <>
+      <CloseButton
+        onClick={() => {
+          setCurrentModal(null, null);
+        }}
+      >
+        <CloseButtonImg src={close} />
+      </CloseButton>
+      <ModalTitle>
+        Account Settings
+      </ModalTitle>
+
+      <TabSelector
+        options={tabOptions}
+        currentTab={currentTab}
+        setCurrentTab={(value: string) =>
+          setCurrentTab(value)
+        }
+      />
+
+      <Heading>
+        <GitIcon src={github} /> Github
+      </Heading>
+      {accessError ? (
+        <Placeholder>An error has occured.</Placeholder>
+      ) : accessLoading ? (
+        <LoadingWrapper>
+          {" "}
+          <Loading />
+        </LoadingWrapper>
+      ) : (
+        <>
+          {/* Will be styled (and show what account is connected) later */}
+          {accessData.has_access ? (
+            <Placeholder>
+              <User>
+                You are currently authorized as <B>{accessData.username}</B> and have access to:
+              </User>
+              {!accessData.accounts || accessData.accounts?.length == 0 ? (
+                <ListWrapper>
+                  <Helper>
+                    No connected repositories found.
+                    <A href={"/api/integrations/github-app/install"}>
+                      Install Porter in your repositories
+                    </A>
+                  </Helper>
+                </ListWrapper>
+              ) : (
+                <>
+                  <List>
+                    {accessData.accounts.map((name, i) => {
+                      return (
+                        <React.Fragment key={i}>
+                          <Row isLastItem={i === accessData.accounts.length - 1}>
+                            <i className="material-icons">bookmark</i>
+                            {name}
+                          </Row>
+                        </React.Fragment>
+                      );
+                    })}
+                  </List>
+                  <br />
+                  Don't see the right repos?{" "}
+                  <A href={"/api/integrations/github-app/install"}>
+                    Install Porter in more repositories
+                  </A>
+                </>
+              )}
+            </Placeholder>
+          ) : (
+            <ListWrapper>
+              <Helper>
+                No github integration detected. You can
+                <A href={"/api/integrations/github-app/authorize"}>
+                  connect your GitHub account
+                </A>
+              </Helper>
+            </ListWrapper>
+          )}
+        </>
+      )}
+    </>
+  );
+};
+
+export default AccountSettingsModal;
+
+const B = styled.b`
+  color: #ffffff;
+`;
+
+const User = styled.div`
+  margin-top: 14px;
+  font-size: 13px;
+`;
+
+const ListWrapper = styled.div`
+  width: 100%;
+  height: 200px;
+  background: #ffffff11;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 5px;
+  margin-top: 20px;
+  padding: 40px;
+`;
+
+const List = styled.div`
+  width: 100%;
+  background: #ffffff11;
+  border-radius: 5px;
+  margin-top: 20px;
+  border: 1px solid #ffffff44;
+  max-height: 200px;
+  overflow-y: auto;
+`;
+
+const Row = styled.div<{ isLastItem?: boolean }>`
+  width: 100%;
+  height: 35px;
+  color: #ffffff55;
+  display: flex;
+  align-items: center;
+  border-bottom: ${props => props.isLastItem ? "" : "1px solid #ffffff44"};
+  > i {
+    font-size: 17px;
+    margin-left: 10px;
+    margin-right: 12px;
+    color: #ffffff44;
+  }
+`;
+
+const GitIcon = styled.img`
+  width: 15px;
+  height: 15px;
+  margin-right: 10px;
+  filter: brightness(120%);
+  margin-left: 1px;
+`;
+
+const ModalTitle = styled.div`
+  margin: 0px 0px 13px;
+  display: flex;
+  flex: 1;
+  font-family: Work Sans, sans-serif;
+  font-size: 18px;
+  color: #ffffff;
+  user-select: none;
+  font-weight: 700;
+  align-items: center;
+  position: relative;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+
+  > i {
+    background: none;
+    border-radius: 3px;
+    display: flex;
+    font-size: 18px;
+    margin-top: 1px;
+    margin-right: 10px;
+    padding: 1px;
+    align-items: center;
+    justify-content: center;
+    color: #ffffffaa;
+    border: 0;
+  }
+`;
+
+const Subtitle = styled.div`
+  margin-top: 23px;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #aaaabb;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  margin-bottom: -10px;
+`;
+
+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 A = styled.a`
+  color: #8590ff;
+  text-decoration: underline;
+  margin-left: 5px;
+  cursor: pointer;
+`;
+
+const LoadingWrapper = styled.div`
+  height: 50px;
+`;
+
+const Placeholder = styled.div`
+  color: #aaaabb;
+  font-size: 13px;
+  margin-left: 0px;
+  line-height: 1.6em;
+  user-select: none;
+`;

+ 1 - 1
dashboard/src/main/home/modals/ClusterInstructionsModal.tsx

@@ -224,7 +224,7 @@ const ModalTitle = styled.div`
   margin: 0px 0px 13px;
   display: flex;
   flex: 1;
-  font-family: "Assistant";
+  font-family: Work Sans, sans-serif;
   font-size: 18px;
   color: #ffffff;
   user-select: none;

+ 1 - 1
dashboard/src/main/home/modals/DeleteNamespaceModal.tsx

@@ -136,7 +136,7 @@ const ModalTitle = styled.div`
   margin: 0px 0px 13px;
   display: flex;
   flex: 1;
-  font-family: "Assistant";
+  font-family: Work Sans, sans-serif;
   font-size: 18px;
   color: #ffffff;
   user-select: none;

+ 176 - 0
dashboard/src/main/home/modals/EditInviteOrCollaboratorModal.tsx

@@ -0,0 +1,176 @@
+import React, { useContext, useEffect, useState } from "react";
+import styled from "styled-components";
+import close from "assets/close.png";
+import SaveButton from "components/SaveButton";
+import { Context } from "shared/Context";
+import RadioSelector from "components/RadioSelector";
+import api from "shared/api";
+import { setTimeout } from "timers";
+
+const EditCollaboratorModal = () => {
+  const {
+    setCurrentModal,
+    currentModalData: { user, isInvite, refetchCallerData },
+    currentProject: { id: project_id },
+  } = useContext(Context);
+  const [status, setStatus] = useState<undefined | string>();
+  const [selectedRole, setSelectedRole] = useState("");
+  const [roleList, setRoleList] = useState([]);
+
+  useEffect(() => {
+    api
+      .getAvailableRoles("<token>", {}, { project_id })
+      .then(({ data }: { data: string[] }) => {
+        const availableRoleList = data?.map((role) => ({
+          value: role,
+          label: capitalizeFirstLetter(role),
+        }));
+        setRoleList(availableRoleList);
+        setSelectedRole(user?.kind || "developer");
+      });
+  }, []);
+
+  const capitalizeFirstLetter = (string: string) => {
+    return string.charAt(0).toUpperCase() + string.slice(1);
+  };
+
+  const handleUpdate = () => {
+    if (isInvite) {
+      updateInvite();
+    } else {
+      updateCollaborator();
+    }
+  };
+
+  const updateCollaborator = async () => {
+    setStatus("loading");
+    try {
+      await api.updateCollaborator(
+        "<token>",
+        { kind: selectedRole },
+        { project_id, user_id: user.id }
+      );
+      setStatus("successful");
+      refetchCallerData().then(() => {
+        setTimeout(() => setCurrentModal(null, null), 500);
+      });
+    } catch (error) {
+      setStatus("error");
+    }
+  };
+
+  const updateInvite = async () => {
+    setStatus("loading");
+    try {
+      await api.updateInvite(
+        "<token>",
+        { kind: selectedRole },
+        { project_id, invite_id: user.id }
+      );
+      setStatus("successful");
+      refetchCallerData().then(() => {
+        setTimeout(() => setCurrentModal(null, null), 500);
+      });
+    } catch (error) {
+      setStatus("error");
+    }
+  };
+
+  return (
+    <StyledUpdateProjectModal>
+      <CloseButton
+        onClick={() => {
+          setCurrentModal(null, null);
+        }}
+      >
+        <CloseButtonImg src={close} />
+      </CloseButton>
+
+      <ModalTitle>
+        Update {isInvite ? "Invite for" : "Collaborator"} {user?.email}
+      </ModalTitle>
+      <Subtitle>Specify a different role for this user.</Subtitle>
+      <RoleSelectorWrapper>
+        <RadioSelector
+          selected={selectedRole}
+          setSelected={setSelectedRole}
+          options={roleList}
+        />
+      </RoleSelectorWrapper>
+
+      <SaveButton
+        text={`Update ${isInvite ? "Invite" : "Collaborator"}`}
+        color="#616FEEcc"
+        onClick={() => handleUpdate()}
+        status={status}
+      />
+    </StyledUpdateProjectModal>
+  );
+};
+
+export default EditCollaboratorModal;
+
+const RoleSelectorWrapper = styled.div`
+  font-size: 14px;
+  margin-top: 25px;
+`;
+
+const Subtitle = styled.div`
+  margin-top: 23px;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #aaaabb;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  margin-bottom: -10px;
+`;
+
+const ModalTitle = styled.div`
+  margin: 0px 0px 13px;
+  display: flex;
+  flex: 1;
+  font-family: "Assistant";
+  font-size: 18px;
+  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 StyledUpdateProjectModal = styled.div`
+  width: 100%;
+  position: absolute;
+  left: 0;
+  top: 0;
+  height: 100%;
+  padding: 25px 30px;
+  overflow: hidden;
+  border-radius: 6px;
+  background: #202227;
+`;

+ 1 - 1
dashboard/src/main/home/modals/EnvEditorModal.tsx

@@ -123,7 +123,7 @@ const ModalTitle = styled.div`
   margin: 0px 0px 13px;
   display: flex;
   flex: 1;
-  font-family: "Assistant";
+  font-family: Work Sans, sans-serif;
   font-size: 18px;
   color: #ffffff;
   user-select: none;

+ 1 - 1
dashboard/src/main/home/modals/IntegrationsInstructionsModal.tsx

@@ -152,7 +152,7 @@ const ModalTitle = styled.div`
   margin: 0px 0px 13px;
   display: flex;
   flex: 1;
-  font-family: "Assistant";
+  font-family: Work Sans, sans-serif;
   font-size: 18px;
   color: #ffffff;
   user-select: none;

+ 1 - 1
dashboard/src/main/home/modals/IntegrationsModal.tsx

@@ -140,7 +140,7 @@ const ModalTitle = styled.div`
   margin: 0px 0px 13px;
   display: flex;
   flex: 1;
-  font-family: "Assistant";
+  font-family: Work Sans, sans-serif;
   font-size: 18px;
   color: #ffffff;
   user-select: none;

+ 1 - 1
dashboard/src/main/home/modals/LoadEnvGroupModal.tsx

@@ -189,7 +189,7 @@ const ModalTitle = styled.div`
   margin: 0px 0px 13px;
   display: flex;
   flex: 1;
-  font-family: "Assistant";
+  font-family: Work Sans, sans-serif;
   font-size: 18px;
   color: #ffffff;
   user-select: none;

+ 1 - 1
dashboard/src/main/home/modals/NamespaceModal.tsx

@@ -162,7 +162,7 @@ const ModalTitle = styled.div`
   margin: 0px 0px 13px;
   display: flex;
   flex: 1;
-  font-family: "Assistant";
+  font-family: Work Sans, sans-serif;
   font-size: 18px;
   color: #ffffff;
   user-select: none;

+ 1 - 1
dashboard/src/main/home/modals/UpdateClusterModal.tsx

@@ -267,7 +267,7 @@ const ModalTitle = styled.div`
   margin: 0px 0px 13px;
   display: flex;
   flex: 1;
-  font-family: "Assistant";
+  font-family: Work Sans, sans-serif;
   font-size: 18px;
   color: #ffffff;
   user-select: none;

+ 46 - 7
dashboard/src/main/home/navbar/Navbar.tsx

@@ -5,19 +5,24 @@ import api from "shared/api";
 import { Context } from "shared/Context";
 
 import Feedback from "./Feedback";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+import { Select, MenuItem } from "@material-ui/core";
+import { AuthContext } from "shared/auth/AuthContext";
 
-type PropsType = {
+type PropsType = WithAuthProps & {
   logOut: () => void;
   currentView: string;
 };
 
 type StateType = {
   showDropdown: boolean;
+  currentPolicy: string;
 };
 
-export default class Navbar extends Component<PropsType, StateType> {
+class Navbar extends Component<PropsType, StateType> {
   state = {
     showDropdown: false,
+    currentPolicy: "admin",
   };
 
   renderSettingsDropdown = () => {
@@ -31,9 +36,19 @@ export default class Navbar extends Component<PropsType, StateType> {
             <DropdownLabel>
               {this.context.user && this.context.user.email}
             </DropdownLabel>
-            <LogOutButton onClick={this.props.logOut}>
+            <UserDropdownButton
+              onClick={() =>
+                this.context.setCurrentModal("AccountSettingsModal", {})
+              }
+            >
+              <SettingsIcon>
+                <i className="material-icons">settings</i>
+              </SettingsIcon>
+              Account Settings
+            </UserDropdownButton>
+            <UserDropdownButton onClick={this.props.logOut}>
               <i className="material-icons">keyboard_return</i> Log Out
-            </LogOutButton>
+            </UserDropdownButton>
           </Dropdown>
         </>
       );
@@ -56,8 +71,7 @@ export default class Navbar extends Component<PropsType, StateType> {
             this.setState({ showDropdown: !this.state.showDropdown })
           }
         >
-          <I className="material-icons-outlined">account_circle</I>
-          {this.context.user?.email}
+          <I className="material-icons">account_circle</I>
           {this.renderSettingsDropdown()}
         </NavButton>
       </StyledNavbar>
@@ -67,10 +81,35 @@ export default class Navbar extends Component<PropsType, StateType> {
 
 Navbar.contextType = Context;
 
+export default withAuth(Navbar);
+
+const SettingsIcon = styled.div`
+  > i {
+    background: none;
+    border-radius: 3px;
+    display: flex;
+    font-size: 15px;
+    top: 11px;
+    margin-right: 10px;
+    padding: 1px;
+    align-items: center;
+    justify-content: center;
+    color: #ffffffaa;
+    border: 0;
+  }
+`;
+
 const I = styled.i`
   margin-right: 7px;
 `;
 
+const PolicySelector = styled(Select)`
+  height: 30px;
+  width: 100px;
+  margin-right: 15px;
+  color: white !important;
+`;
+
 const CloseOverlay = styled.div`
   position: fixed;
   width: 100vw;
@@ -81,7 +120,7 @@ const CloseOverlay = styled.div`
   cursor: default;
 `;
 
-const LogOutButton = styled.button`
+const UserDropdownButton = styled.button`
   padding: 13px;
   height: 40px;
   font-size: 13px;

+ 421 - 237
dashboard/src/main/home/project-settings/InviteList.tsx

@@ -1,4 +1,10 @@
-import React, { Component } from "react";
+import React, {
+  Component,
+  useState,
+  useEffect,
+  useContext,
+  useMemo,
+} from "react";
 import styled from "styled-components";
 
 import { InviteType } from "shared/types";
@@ -10,244 +16,438 @@ import InputRow from "components/values-form/InputRow";
 import Helper from "components/values-form/Helper";
 import Heading from "components/values-form/Heading";
 import CopyToClipboard from "components/CopyToClipboard";
+import { Column } from "react-table";
+import Table from "components/Table";
+import RadioSelector from "components/RadioSelector";
 
-type PropsType = {};
+type Props = {};
 
-type StateType = {
-  loading: boolean;
-  invites: InviteType[];
+export type Collaborator = {
+  id: string;
+  user_id: string;
+  project_id: string;
   email: string;
-  invalidEmail: boolean;
-  isHTTPS: boolean;
+  kind: string;
 };
 
-const dummyInvites = [];
-
-export default class InviteList extends Component<PropsType, StateType> {
-  state = {
-    loading: true,
-    invites: [] as InviteType[],
-    email: "",
-    invalidEmail: false,
-    isHTTPS: window.location.protocol === "https:",
+const InvitePage: React.FunctionComponent<Props> = ({}) => {
+  const { currentProject, setCurrentModal, user } = useContext(Context);
+  const [isLoading, setIsLoading] = useState(true);
+  const [invites, setInvites] = useState<Array<InviteType>>([]);
+  const [email, setEmail] = useState("");
+  const [role, setRole] = useState("developer");
+  const [roleList, setRoleList] = useState([]);
+  const [isInvalidEmail, setIsInvalidEmail] = useState(false);
+  const [isHTTPS] = useState(() => window.location.protocol === "https:");
+
+  useEffect(() => {
+    api
+      .getAvailableRoles("<token>", {}, { project_id: currentProject.id })
+      .then(({ data }: { data: string[] }) => {
+        const availableRoleList = data?.map((role) => ({
+          value: role,
+          label: capitalizeFirstLetter(role),
+        }));
+        setRoleList(availableRoleList);
+        setRole("developer");
+      });
+
+    getData();
+  }, [currentProject]);
+
+  const capitalizeFirstLetter = (string: string) => {
+    return string.charAt(0).toUpperCase() + string.slice(1);
   };
 
-  componentDidMount() {
-    this.getInviteData();
-  }
-
-  getInviteData = () => {
-    let { currentProject } = this.context;
-
-    this.setState({ loading: true });
-    api
-      .getInvites(
+  const getData = async () => {
+    setIsLoading(true);
+    let invites = [];
+    try {
+      const response = await api.getInvites(
         "<token>",
         {},
         {
           id: currentProject.id,
         }
-      )
-      .then((res) => this.setState({ invites: res.data, loading: false }))
-      .catch((err) => console.log(err));
+      );
+      invites = response.data.filter((i: InviteType) => !i.accepted);
+    } catch (err) {
+      console.log(err);
+    }
+    let collaborators: any = [];
+    try {
+      const response = await api.getCollaborators(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+        }
+      );
+      collaborators = parseCollaboratorsResponse(response.data);
+    } catch (err) {
+      console.log(err);
+    }
+    setInvites([...invites, ...collaborators]);
+    setIsLoading(false);
   };
 
-  validateEmail = () => {
-    var regex = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
-    if (regex.test(this.state.email.toLowerCase())) {
-      this.setState({ invalidEmail: false });
-      this.createInvite();
-    } else {
-      this.setState({ invalidEmail: true });
-    }
+  const parseCollaboratorsResponse = (
+    collaborators: Array<Collaborator>
+  ): Array<InviteType> => {
+    return (
+      collaborators
+        // Parse role id to number
+        .map((c) => ({ ...c, id: Number(c.id) }))
+        // Sort them so the owner will be first allways
+        .sort((curr, prev) => curr.id - prev.id)
+        // Remove the owner from list
+        .slice(1)
+        // Parse the remainings to InviteType
+        .map((c) => ({
+          email: c.email,
+          expired: false,
+          id: Number(c.user_id),
+          kind: c.kind,
+          accepted: true,
+          token: "",
+        }))
+    );
   };
 
-  createInvite = () => {
-    let { currentProject } = this.context;
+  const createInvite = () => {
     api
-      .createInvite(
-        "<token>",
-        { email: this.state.email },
-        { id: currentProject.id }
-      )
-      .then((_) => {
-        this.getInviteData();
-        this.setState({ email: "" });
+      .createInvite("<token>", { email, kind: role }, { id: currentProject.id })
+      .then(() => {
+        getData();
+        setEmail("");
       })
       .catch((err) => console.log(err));
   };
 
-  deleteInvite = (index: number) => {
-    let { currentProject } = this.context;
+  const deleteInvite = (inviteId: number) => {
     api
       .deleteInvite(
         "<token>",
         {},
         {
           id: currentProject.id,
-          invId: this.state.invites[index].id,
+          invId: inviteId,
         }
       )
-      .then(this.getInviteData)
+      .then(getData)
       .catch((err) => console.log(err));
   };
 
-  replaceInvite = (index: number) => {
-    let { currentProject } = this.context;
+  const replaceInvite = (
+    inviteEmail: string,
+    inviteId: number,
+    kind: string
+  ) => {
     api
       .createInvite(
         "<token>",
-        { email: this.state.invites[index].email },
+        { email: inviteEmail, kind },
         { id: currentProject.id }
       )
-      .then((_) =>
+      .then(() =>
         api.deleteInvite(
           "<token>",
           {},
           {
             id: currentProject.id,
-            invId: this.state.invites[index].id,
+            invId: inviteId,
           }
         )
       )
-      .then(this.getInviteData)
+      .then(getData)
       .catch((err) => console.log(err));
   };
 
-  getInviteUrl = (index: number) => {
-    let { currentProject } = this.context;
-    return `${this.state.isHTTPS ? "https://" : ""}${
-      window.location.host
-    }/api/projects/${currentProject.id}/invites/${
-      this.state.invites[index].token
-    }`;
+  const validateEmail = () => {
+    const regex = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
+    if (!regex.test(email.toLowerCase())) {
+      setIsInvalidEmail(true);
+      return;
+    }
+
+    setIsInvalidEmail(false);
+    createInvite();
   };
 
-  renderInvitations = () => {
-    let { currentProject } = this.context;
-    if (this.state.loading) {
-      return <Loading />;
-    } else {
-      var invContent: any[] = [];
-      var collabList: any[] = [];
-      this.state.invites.sort((a: any, b: any) => (a.email > b.email ? 1 : -1));
-      this.state.invites.sort((a: any, b: any) =>
-        a.accepted > b.accepted ? 1 : -1
+  const openEditModal = (user: any) => {
+    if (setCurrentModal) {
+      setCurrentModal("EditInviteOrCollaboratorModal", {
+        user,
+        isInvite: user.status !== "accepted",
+        refetchCallerData: getData,
+      });
+    }
+  };
+
+  const removeCollaborator = (user_id: number) => {
+    try {
+      api.removeCollaborator(
+        "<token>",
+        {},
+        { project_id: currentProject.id, user_id }
       );
-      for (let i = 0; i < this.state.invites.length; i++) {
-        if (this.state.invites[i].accepted) {
-          collabList.push(
-            <Tr key={i}>
-              <MailTd isTop={i === 0}>{this.state.invites[i].email}</MailTd>
-              <LinkTd isTop={i === 0}></LinkTd>
-              <Td isTop={i === 0}>
-                <CopyButton invis={true}>Remove</CopyButton>
-              </Td>
-            </Tr>
+      getData();
+    } catch (error) {
+      console.log(error);
+    }
+  };
+
+  const columns = useMemo<
+    Column<{
+      email: string;
+      id: number;
+      status: string;
+      invite_link: string;
+      kind: string;
+    }>[]
+  >(
+    () => [
+      {
+        Header: "User",
+        accessor: "email",
+      },
+      {
+        Header: "Role",
+        accessor: "kind",
+        Cell: ({ row }) => {
+          return <Role>{row.values.kind || "Admin"}</Role>;
+        },
+      },
+      {
+        Header: "Status",
+        accessor: "status",
+        Cell: ({ row }) => {
+          return (
+            <Status status={row.values.status}>{row.values.status}</Status>
           );
-        } else if (this.state.invites[i].expired) {
-          invContent.push(
-            <Tr key={i}>
-              <MailTd isTop={i === 0}>{this.state.invites[i].email}</MailTd>
-              <LinkTd isTop={i === 0}>
-                <Rower>
-                  Link Expired.
-                  <NewLinkButton onClick={() => this.replaceInvite(i)}>
-                    <u>Generate a new link</u>
-                  </NewLinkButton>
-                </Rower>
-              </LinkTd>
-              <Td isTop={i === 0}>
-                <CopyButton onClick={() => this.deleteInvite(i)}>
-                  Delete Invite
-                </CopyButton>
-              </Td>
-            </Tr>
+        },
+      },
+      {
+        Header: "",
+        accessor: "invite_link",
+        Cell: ({ row }) => {
+          if (row.values.status === "expired") {
+            return (
+              <NewLinkButton
+                onClick={() =>
+                  replaceInvite(
+                    row.values.email,
+                    row.values.id,
+                    row.values.kind
+                  )
+                }
+              >
+                <u>Generate a new link</u>
+              </NewLinkButton>
+            );
+          }
+          if (row.values.status === "accepted") {
+            return "";
+          }
+
+          return (
+            <>
+              <CopyToClipboard as={Url} text={row.values.invite_link}>
+                <span>{row.values.invite_link}</span>
+                <i className="material-icons-outlined">content_copy</i>
+              </CopyToClipboard>
+            </>
           );
-        } else {
-          invContent.push(
-            <Tr key={i}>
-              <MailTd isTop={i === 0}>{this.state.invites[i].email}</MailTd>
-              <LinkTd isTop={i === 0}>
-                <Rower>
-                  <ShareLink
-                    disabled={true}
-                    type="string"
-                    value={`${this.state.isHTTPS ? "https://" : ""}${
-                      window.location.host
-                    }/api/projects/${currentProject.id}/invites/${
-                      this.state.invites[i].token
-                    }`}
-                    placeholder="Unable to retrieve link"
-                  />
-                  <CopyToClipboard
-                    as={CopyButton}
-                    text={this.getInviteUrl(i)}
-                    onError={() => console.log("Couldn't copy to clipboard")}
-                  >
-                    Copy Link
-                  </CopyToClipboard>
-                </Rower>
-              </LinkTd>
-              <Td isTop={i === 0}>
-                <CopyButton onClick={() => this.deleteInvite(i)}>
-                  Delete Invite
-                </CopyButton>
-              </Td>
-            </Tr>
+        },
+      },
+      {
+        id: "edit_action",
+        Cell: ({ row }: any) => {
+          return <></>;
+        },
+      },
+      {
+        id: "remove_invite_action",
+        Cell: ({ row }: any) => {
+          if (row.values.status === "accepted") {
+            return (
+              <Flex>
+                <SettingsButton
+                  invis={row.original.currentUser}
+                  onClick={() => openEditModal(row.original)}
+                >
+                  <i className="material-icons">more_vert</i>
+                </SettingsButton>
+                <DeleteButton
+                  invis={row.original.currentUser}
+                  onClick={() => removeCollaborator(row.original.id)}
+                >
+                  <i className="material-icons">delete</i>
+                </DeleteButton>
+              </Flex>
+            );
+          }
+          return (
+            <Flex>
+              <SettingsButton
+                invis={row.original.currentUser}
+                onClick={() => openEditModal(row.original)}
+              >
+                <i className="material-icons">more_vert</i>
+              </SettingsButton>
+              <DeleteButton
+                invis={row.original.currentUser}
+                onClick={() => deleteInvite(row.original.id)}
+              >
+                <i className="material-icons">delete</i>
+              </DeleteButton>
+            </Flex>
           );
+        },
+      },
+    ],
+    []
+  );
+
+  const data = useMemo(() => {
+    const inviteList = [...invites];
+    inviteList.sort((a: any, b: any) => (a.email > b.email ? 1 : -1));
+    inviteList.sort((a: any, b: any) => (a.accepted > b.accepted ? 1 : -1));
+    const buildInviteLink = (token: string) => `
+      ${isHTTPS ? "https://" : ""}${window.location.host}/api/projects/${
+      currentProject.id
+    }/invites/${token}
+    `;
+
+    const mappedInviteList = inviteList.map(
+      ({ accepted, expired, token, ...rest }) => {
+        const currentUser: boolean = user.email === rest.email;
+        if (accepted) {
+          return {
+            status: "accepted",
+            invite_link: buildInviteLink(token),
+            currentUser,
+            ...rest,
+          };
+        }
+
+        if (!accepted && expired) {
+          return {
+            status: "expired",
+            invite_link: buildInviteLink(token),
+            currentUser,
+            ...rest,
+          };
         }
+
+        return {
+          status: "pending",
+          invite_link: buildInviteLink(token),
+          currentUser,
+          ...rest,
+        };
       }
+    );
 
-      return (
-        <>
-          <Heading>Invites & Collaborators</Heading>
-          <Helper>Manage pending invites and view collaborators.</Helper>
-          {invContent.length > 0 || collabList.length > 0 ? (
-            <Table>
-              <tbody>
-                {invContent}
-                {collabList}
-              </tbody>
-            </Table>
-          ) : (
-            <Placeholder>
-              This project currently has no invites or collaborators.
-            </Placeholder>
-          )}
-        </>
-      );
-    }
-  };
+    return mappedInviteList || [];
+  }, [invites, currentProject?.id, window?.location?.host, isHTTPS, user?.id]);
 
-  render() {
-    return (
-      <>
-        <Heading isAtTop={true}>Share Project</Heading>
-        <Helper>Generate a project invite for another admin user.</Helper>
-        <DarkMatter />
+  return (
+    <>
+      <Heading isAtTop={true}>Share Project</Heading>
+      <Helper>Generate a project invite for another user.</Helper>
+      <InputRowWrapper>
         <InputRow
-          value={this.state.email}
+          value={email}
           type="text"
-          setValue={(x: string) => this.setState({ email: x })}
-          width="calc(100%)"
+          setValue={(newEmail: string) => setEmail(newEmail)}
+          width="100%"
           placeholder="ex: mrp@getporter.dev"
         />
-        <ButtonWrapper>
-          <InviteButton disabled={false} onClick={() => this.validateEmail()}>
-            Create Invite
-          </InviteButton>
-          {this.state.invalidEmail && (
-            <Invalid>Invalid email address. Please try again.</Invalid>
-          )}
-        </ButtonWrapper>
-        {this.renderInvitations()}
-      </>
-    );
+      </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>
+      {isLoading && <Loading height={"30%"} />}
+      {data?.length && !isLoading ? (
+        <Table
+          columns={columns}
+          data={data}
+          disableHover={true}
+          isLoading={false}
+          disableGlobalFilter={true}
+        />
+      ) : (
+        !isLoading && (
+          <Placeholder>
+            This project currently has no invites or collaborators.
+          </Placeholder>
+        )
+      )}
+    </>
+  );
+};
+
+export default InvitePage;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+  width: 70px;
+  float: right;
+  justify-content: space-between;
+`;
+
+const DeleteButton = styled.div`
+  display: flex;
+  visibility: ${(props: { invis?: boolean }) =>
+    props.invis ? "hidden" : "visible"};
+  align-items: center;
+  justify-content: center;
+  width: 30px;
+  float: right;
+  height: 30px;
+  :hover {
+    background: #ffffff11;
+    border-radius: 20px;
+    cursor: pointer;
   }
-}
 
-InviteList.contextType = Context;
+  > i {
+    font-size: 20px;
+    color: #ffffff44;
+    border-radius: 20px;
+  }
+`;
+
+const SettingsButton = styled(DeleteButton)`
+  margin-right: -60px;
+`;
+
+const Role = styled.div`
+  text-transform: capitalize;
+  margin-right: 50px;
+`;
+
+const RoleSelectorWrapper = styled.div`
+  font-size: 14px;
+`;
 
 const Placeholder = styled.div`
   width: 100%;
@@ -267,9 +467,8 @@ const ButtonWrapper = styled.div`
   align-items: center;
 `;
 
-const DarkMatter = styled.div`
-  width: 100%;
-  margin-top: -10px;
+const InputRowWrapper = styled.div`
+  width: 40%;
 `;
 
 const CopyButton = styled.div`
@@ -278,16 +477,17 @@ const CopyButton = styled.div`
   color: #ffffff;
   font-weight: 400;
   font-size: 13px;
-  margin-left: 12px;
+  margin: 8px 0 8px 12px;
   float: right;
+  display: flex;
+  justify-content: center;
+  align-items: center;
   width: 120px;
-  padding-top: 7px;
-  padding-bottom: 6px;
   cursor: pointer;
+  height: 30px;
   border-radius: 5px;
   border: 1px solid #ffffff20;
   background-color: #ffffff10;
-  text-align: center;
   overflow: hidden;
   transition: all 0.1s ease-out;
   :hover {
@@ -299,6 +499,9 @@ const CopyButton = styled.div`
 const NewLinkButton = styled(CopyButton)`
   border: none;
   width: auto;
+  float: none;
+  display: block;
+  margin: unset;
   background-color: transparent;
   :hover {
     border: none;
@@ -336,67 +539,28 @@ const InviteButton = styled.div<{ disabled: boolean }>`
   margin-bottom: 10px;
 `;
 
-const Rower = styled.div`
+const Url = styled.a`
+  max-width: 300px;
+  font-size: 13px;
+  user-select: text;
+  font-weight: 400;
   display: flex;
-  flex-direction: row;
   align-items: center;
-`;
-
-const ShareLink = styled.input`
-  outline: none;
-  border: none;
-  font-size: 13px;
-  background: none;
-  width: 60%;
-  color: #74a5f7;
-  margin-left: -10px;
-  padding: 5px 10px;
-  height: 30px;
-  text-overflow: ellipsis;
-  border-radius: 3px;
-  ::placeholder,
-  ::-webkit-input-placeholder {
-    color: #fa0a26;
-    font-weight: 600;
+  justify-content: center;
+  > i {
+    margin-left: 10px;
+    font-size: 15px;
   }
-`;
-
-const Table = styled.table`
-  width: 100%;
-  border-spacing: 0px;
-  border: 1px solid #ffffff55;
-  margin-top: 22px;
-  border-radius: 5px;
-  background: #ffffff11;
-  color: #ffffff;
-  font-weight: 400;
-  font-size: 13px;
-`;
 
-const Td = styled.td`
-  white-space: nowrap;
-  padding: 6px 0px;
-  border-top: ${(props: { isTop: boolean }) =>
-    props.isTop ? "none" : "1px solid #ffffff55"};
-  &:last-child {
-    padding-right: 16px;
+  > span {
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
   }
-`;
-
-const Tr = styled.tr``;
-
-const MailTd = styled(Td)`
-  padding: 0 12px;
-  max-width: 186px;
-  min-width: 186px;
-  overflow: hidden;
-  color: #aaaabb;
-  text-overflow: ellipsis;
-`;
 
-const LinkTd = styled(Td)`
-  width: calc(100% - 40px);
-  padding-left: 40px;
+  :hover {
+    cursor: pointer;
+  }
 `;
 
 const Invalid = styled.div`
@@ -405,3 +569,23 @@ const Invalid = styled.div`
   font-size: 13px;
   font-family: "Work Sans", sans-serif;
 `;
+
+const Status = styled.div<{ status: "accepted" | "expired" | "pending" }>`
+  padding: 5px 10px;
+  margin-right: 12px;
+  background: ${(props) => {
+    if (props.status === "accepted") return "#38a88a";
+    if (props.status === "expired") return "#cc3d42";
+    if (props.status === "pending") return "#ffffff11";
+  }};
+  font-size: 13px;
+  border-radius: 3px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  max-height: 25px;
+  max-width: 80px;
+  text-transform: capitalize;
+  font-weight: 400;
+  user-select: none;
+`;

+ 24 - 10
dashboard/src/main/home/project-settings/ProjectSettings.tsx

@@ -3,37 +3,49 @@ import styled from "styled-components";
 
 import { Context } from "shared/Context";
 
-import InviteList from "./InviteList";
+import InvitePage from "./InviteList";
 import TabRegion from "components/TabRegion";
 import Heading from "components/values-form/Heading";
 import Helper from "components/values-form/Helper";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 
-type PropsType = {};
+type PropsType = WithAuthProps & {};
 
 type StateType = {
   projectName: string;
   currentTab: string;
+  tabOptions: { value: string; label: string }[];
 };
 
-const tabOptions = [
-  { value: "manage-access", label: "Manage Access" },
-  { value: "additional-settings", label: "Additional Settings" },
-];
-
-export default class ProjectSettings extends Component<PropsType, StateType> {
+class ProjectSettings extends Component<PropsType, StateType> {
   state = {
     projectName: "",
     currentTab: "manage-access",
+    tabOptions: [] as { value: string; label: string }[],
   };
 
   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: "additional-settings",
+        label: "Additional Settings",
+      });
+    }
+
+    this.setState({ tabOptions });
   }
 
   renderTabContents = () => {
+    if (!this.props.isAuthorized("settings", "", ["get", "delete"])) {
+      return <InvitePage />;
+    }
+
     if (this.state.currentTab === "manage-access") {
-      return <InviteList />;
+      return <InvitePage />;
     } else {
       return (
         <>
@@ -85,7 +97,7 @@ export default class ProjectSettings extends Component<PropsType, StateType> {
         <TabRegion
           currentTab={this.state.currentTab}
           setCurrentTab={(x: string) => this.setState({ currentTab: x })}
-          options={tabOptions}
+          options={this.state.tabOptions}
         >
           {this.renderTabContents()}
         </TabRegion>
@@ -96,6 +108,8 @@ export default class ProjectSettings extends Component<PropsType, StateType> {
 
 ProjectSettings.contextType = Context;
 
+export default withAuth(ProjectSettings);
+
 const Warning = styled.div`
   font-size: 13px;
   color: ${(props: { highlight: boolean; makeFlush?: boolean }) =>

+ 29 - 22
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -16,14 +16,16 @@ import ProjectSectionContainer from "./ProjectSectionContainer";
 import loading from "assets/loading.gif";
 import { RouteComponentProps, withRouter } from "react-router";
 import { pushFiltered, pushQueryParams } from "shared/routing";
-
-type PropsType = RouteComponentProps & {
-  forceSidebar: boolean;
-  setWelcome: (x: boolean) => void;
-  currentView: string;
-  forceRefreshClusters: boolean;
-  setRefreshClusters: (x: boolean) => void;
-};
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+
+type PropsType = RouteComponentProps &
+  WithAuthProps & {
+    forceSidebar: boolean;
+    setWelcome: (x: boolean) => void;
+    currentView: string;
+    forceRefreshClusters: boolean;
+    setRefreshClusters: (x: boolean) => void;
+  };
 
 type StateType = {
   showSidebar: boolean;
@@ -231,18 +233,23 @@ class Sidebar extends Component<PropsType, StateType> {
             <Img src={rocket} />
             Launch
           </NavButton>
-          <NavButton
-            selected={currentView === "integrations"}
-            onClick={() =>
-              pushFiltered(this.props, "/integrations", ["project_id"])
-            }
-          >
-            <Img src={integrations} />
-            Integrations
-          </NavButton>
-          {this.context.currentProject.roles.filter((obj: any) => {
-            return obj.user_id === this.context.user.userId;
-          })[0].kind === "admin" && (
+
+          {this.props.isAuthorized("integrations", "", ["get"]) && (
+            <NavButton
+              selected={currentView === "integrations"}
+              onClick={() =>
+                pushFiltered(this.props, "/integrations", ["project_id"])
+              }
+            >
+              <Img src={integrations} />
+              Integrations
+            </NavButton>
+          )}
+          {this.props.isAuthorized("settings", "", [
+            "get",
+            "update",
+            "delete",
+          ]) && (
             <NavButton
               onClick={() =>
                 pushFiltered(this.props, "/project-settings", ["project_id"])
@@ -313,7 +320,7 @@ class Sidebar extends Component<PropsType, StateType> {
 
 Sidebar.contextType = Context;
 
-export default withRouter(Sidebar);
+export default withRouter(withAuth(Sidebar));
 
 const BranchPad = styled.div`
   width: 20px;
@@ -572,7 +579,7 @@ const Tooltip = styled.div`
   flex: 1;
   color: white;
   font-size: 12px;
-  font-family: "Assistant", sans-serif;
+  font-family: Work Sans, sans-serif;
   outline: 1px solid #ffffff55;
   opacity: 0;
   animation: faded-in 0.2s 0.15s;

+ 3 - 1
dashboard/src/shared/Context.tsx

@@ -86,9 +86,11 @@ class ContextProvider extends Component<PropsType, StateType> {
     },
     currentProject: null,
     setCurrentProject: (currentProject: ProjectType, callback?: any) => {
-      pushQueryParams(this.props, { project_id: currentProject.id.toString() });
       if (currentProject) {
         localStorage.setItem("currentProject", currentProject.id.toString());
+        pushQueryParams(this.props, {
+          project_id: currentProject.id.toString(),
+        });
       } else {
         localStorage.removeItem("currentProject");
       }

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

@@ -150,6 +150,7 @@ const createGKE = baseApi<
 const createInvite = baseApi<
   {
     email: string;
+    kind: string;
   },
   {
     id: number;
@@ -732,6 +733,10 @@ const linkGithubProject = baseApi<
   return `/api/oauth/projects/${pathParams.project_id}/github`;
 });
 
+const getGithubAccess = baseApi<{}, {}>("GET", () => {
+  return `/api/integrations/github-app/access`;
+});
+
 const logInUser = baseApi<{
   email: string;
   password: string;
@@ -917,6 +922,43 @@ const stopJob = baseApi<
   return `/api/projects/${id}/k8s/jobs/${namespace}/${name}/stop?cluster_id=${cluster_id}`;
 });
 
+const getAvailableRoles = baseApi<{}, { project_id: number }>(
+  "GET",
+  ({ project_id }) => `/api/projects/${project_id}/roles`
+);
+
+const updateInvite = baseApi<
+  { kind: string },
+  { project_id: number; invite_id: number }
+>(
+  "POST",
+  ({ project_id, invite_id }) =>
+    `/api/projects/${project_id}/invites/${invite_id}`
+);
+
+const getCollaborators = baseApi<{}, { project_id: number }>(
+  "GET",
+  ({ project_id }) => `/api/projects/${project_id}/collaborators`
+);
+
+const updateCollaborator = baseApi<
+  { kind: string },
+  { project_id: number; user_id: number }
+>(
+  "POST",
+  ({ project_id, user_id }) => `/api/projects/${project_id}/roles/${user_id}`
+);
+
+const removeCollaborator = baseApi<{}, { project_id: number; user_id: number }>(
+  "DELETE",
+  ({ project_id, user_id }) => `/api/projects/${project_id}/roles/${user_id}`
+);
+
+const getPolicyDocument = baseApi<{}, { project_id: number }>(
+  "GET",
+  ({ project_id }) => `/api/projects/${project_id}/policy`
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -996,6 +1038,7 @@ export default {
   getTemplates,
   getUser,
   linkGithubProject,
+  getGithubAccess,
   listConfigMaps,
   logInUser,
   logOutUser,
@@ -1009,4 +1052,10 @@ export default {
   upgradeChartValues,
   deleteJob,
   stopJob,
+  updateInvite,
+  getAvailableRoles,
+  getCollaborators,
+  updateCollaborator,
+  removeCollaborator,
+  getPolicyDocument,
 };

+ 51 - 0
dashboard/src/shared/auth/AuthContext.tsx

@@ -0,0 +1,51 @@
+import React, { useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { POLICY_HIERARCHY_TREE, populatePolicy } from "./authorization-helpers";
+import { PolicyDocType } from "./types";
+
+type AuthContext = {
+  currentPolicy: PolicyDocType;
+};
+
+export const AuthContext = React.createContext<AuthContext>({} as AuthContext);
+
+const AuthProvider: React.FC = ({ children }) => {
+  const { user, currentProject } = useContext(Context);
+  const [currentPolicy, setCurrentPolicy] = useState(null);
+
+  useEffect(() => {
+    let isSubscribed = true;
+    if (!user) {
+      setCurrentPolicy(null);
+    } else {
+      api
+        .getPolicyDocument("<token>", {}, { project_id: currentProject?.id })
+        .then((res) => {
+          if (!isSubscribed) {
+            return;
+          }
+          const currentPolicy = res.data[0];
+          setCurrentPolicy(
+            populatePolicy(
+              currentPolicy,
+              POLICY_HIERARCHY_TREE,
+              currentPolicy.scope,
+              currentPolicy.verbs
+            )
+          );
+        });
+    }
+    return () => {
+      isSubscribed = false;
+    };
+  }, [user, currentProject?.id]);
+
+  return (
+    <AuthContext.Provider value={{ currentPolicy }}>
+      {children}
+    </AuthContext.Provider>
+  );
+};
+
+export default AuthProvider;

+ 54 - 0
dashboard/src/shared/auth/AuthorizationHoc.tsx

@@ -0,0 +1,54 @@
+import React, { useCallback } from "react";
+import { useContext } from "react";
+import { AuthContext } from "./AuthContext";
+import { isAuthorized } from "./authorization-helpers";
+import { ScopeType, Verbs } from "./types";
+
+export const GuardedComponent = <ComponentProps extends object>(
+  scope: ScopeType,
+  resource: string,
+  verb: Verbs | Array<Verbs>
+) => (Component: any) => (props: ComponentProps) => {
+  const authContext = useContext(AuthContext);
+
+  if (isAuthorized(authContext.currentPolicy, scope, resource, verb)) {
+    return <Component {...props} />;
+  }
+
+  return null;
+};
+
+export type WithAuthProps = {
+  isAuthorized: (
+    scope: ScopeType,
+    resource: string | Array<string>,
+    verb: Verbs | Array<Verbs>
+  ) => boolean;
+};
+
+export function withAuth<P>(
+  // Then we need to type the incoming component.
+  // This creates a union type of whatever the component
+  // already accepts AND our extraInfo prop
+  WrappedComponent: React.ComponentType<P & WithAuthProps>
+) {
+  const displayName = `withAuth(${
+    WrappedComponent.displayName || WrappedComponent.name
+  })`;
+
+  const C = (props: P) => {
+    const authContext = useContext(AuthContext);
+
+    const isAuth = useCallback(
+      (scope: ScopeType, resource: string, verb: Verbs | Array<Verbs>) =>
+        isAuthorized(authContext.currentPolicy, scope, resource, verb),
+      [authContext.currentPolicy]
+    );
+    // At this point, the props being passed in are the original props the component expects.
+    return <WrappedComponent {...props} isAuthorized={isAuth} />;
+  };
+
+  C.displayName = displayName;
+  C.WrappedComponent = WrappedComponent;
+  return C;
+}

+ 62 - 0
dashboard/src/shared/auth/RouteGuard.tsx

@@ -0,0 +1,62 @@
+import UnauthorizedPage from "components/UnauthorizedPage";
+import React, { useMemo, useContext } from "react";
+import { Redirect, Route, RouteProps } from "react-router";
+import { AuthContext } from "./AuthContext";
+import { isAuthorized } from "./authorization-helpers";
+import { ScopeType, Verbs } from "./types";
+
+import Loading from "components/Loading";
+
+type GuardedRouteProps = {
+  scope: ScopeType;
+  resource: string;
+  verb: Verbs | Array<Verbs>;
+};
+
+const GuardedRoute: React.FC<RouteProps & GuardedRouteProps> = ({
+  component: Component,
+  scope,
+  resource,
+  verb,
+  children,
+  ...rest
+}) => {
+  const { currentPolicy } = useContext(AuthContext);
+  const auth = useMemo(() => {
+    return isAuthorized(currentPolicy, scope, resource, verb);
+  }, [currentPolicy, scope, resource, verb]);
+
+  const render = (props: any) => {
+    if (!currentPolicy) {
+      return <div> Loading </div>;
+    }
+    if (auth) {
+      return children || <Component {...props} />;
+    }
+    return <UnauthorizedPage />;
+  };
+
+  return <Route {...rest} render={render} />;
+};
+
+export const fakeGuardedRoute = <ComponentProps extends object>(
+  scope: string,
+  resource: string,
+  verb: Verbs | Array<Verbs>
+) => (Component: any) => (props: ComponentProps) => {
+  const { currentPolicy } = useContext(AuthContext);
+  const auth = useMemo(() => {
+    return isAuthorized(currentPolicy, scope, resource, verb);
+  }, [currentPolicy, scope, resource, verb]);
+
+  if (!currentPolicy) {
+    return <Loading />;
+  }
+  if (auth) {
+    return <Component {...props} />;
+  }
+
+  return <UnauthorizedPage />;
+};
+
+export default GuardedRoute;

+ 127 - 0
dashboard/src/shared/auth/authorization-helpers.ts

@@ -0,0 +1,127 @@
+import { HIERARCHY_TREE, PolicyDocType, ScopeType, Verbs } from "./types";
+
+export const ADMIN_POLICY_MOCK: PolicyDocType = {
+  scope: "project",
+  verbs: ["get", "list", "create", "update", "delete"],
+};
+
+export const DEV_POLICY_MOCK: PolicyDocType = {
+  scope: "project",
+  verbs: ["get", "list", "create", "update", "delete"],
+  resources: [],
+  children: {
+    settings: {
+      scope: "settings",
+      verbs: ["get", "list"],
+      resources: [],
+    },
+  },
+};
+
+export const VIEWER_POLICY_MOCK: PolicyDocType = {
+  scope: "project",
+  verbs: ["get", "list"],
+  resources: [],
+  children: {
+    integrations: {
+      scope: "integrations",
+      verbs: [],
+      resources: [],
+    },
+    settings: {
+      scope: "settings",
+      verbs: [],
+      resources: [],
+    },
+  },
+};
+
+export const POLICY_HIERARCHY_TREE: HIERARCHY_TREE = {
+  project: {
+    cluster: {
+      namespace: {
+        application: {},
+        job: {},
+        env_group: {},
+      },
+    },
+    settings: {},
+    integrations: {},
+  },
+};
+
+export const isAuthorized = (
+  policy: PolicyDocType,
+  scope: string,
+  resource: string | Array<string>,
+  verb: Verbs | Array<Verbs>
+): boolean => {
+  if (!policy) {
+    return false;
+  }
+
+  if (policy?.scope === scope) {
+    let isResourceIncluded = false;
+    if (policy.resources.length === 0) {
+      isResourceIncluded = true;
+    } else if (typeof resource === "string") {
+      isResourceIncluded = policy.resources.includes(resource);
+    } else {
+      isResourceIncluded = resource.every((r) => policy.resources.includes(r));
+    }
+
+    return (
+      isResourceIncluded &&
+      (typeof verb === "string"
+        ? policy.verbs.includes(verb)
+        : verb.every((v) => policy.verbs.includes(v)))
+    );
+  } else {
+    const isValid =
+      policy?.children &&
+      Object.values(policy.children).reduce((prev, currentPol) => {
+        if (isAuthorized(currentPol, scope, resource, verb)) {
+          return true;
+        } else {
+          return prev || false;
+        }
+      }, false);
+
+    return !!isValid;
+  }
+};
+
+export const populatePolicy = (
+  currPolicy: PolicyDocType,
+  tree: HIERARCHY_TREE,
+  currScope: ScopeType,
+  parentVerbs: Array<Verbs>
+) => {
+  const currTree = tree[currScope];
+  const treeKeys = Object.keys(currTree) as Array<ScopeType>;
+
+  currPolicy.children = currPolicy?.children || {};
+  currPolicy.resources = currPolicy?.resources || [];
+
+  for (const child of treeKeys) {
+    let childPolicy = currPolicy?.children && currPolicy?.children[child];
+    if (!childPolicy) {
+      childPolicy = {
+        scope: child,
+        verbs: parentVerbs,
+        resources: [],
+        children: {},
+      };
+    }
+    childPolicy.resources = childPolicy?.resources || [];
+    childPolicy.children = childPolicy?.children || {};
+    currPolicy.children[child] = populatePolicy(
+      childPolicy,
+      currTree,
+      childPolicy.scope,
+      currPolicy.verbs
+    );
+  }
+
+  return currPolicy;
+};

+ 28 - 0
dashboard/src/shared/auth/types.ts

@@ -0,0 +1,28 @@
+export type ScopeType =
+  | "project"
+  | "cluster"
+  | "settings"
+  | "namespace"
+  | "application"
+  | "env_group"
+  | "job"
+  | "integrations";
+
+export type Verbs = "get" | "list" | "create" | "update" | "delete";
+
+export interface PolicyDocType {
+  scope: ScopeType;
+  verbs: Array<Verbs>;
+  resources?: string[];
+  children?: Partial<Record<ScopeType, PolicyDocType>>;
+}
+
+export enum ScopeTypeEnum {
+  PROJECT = "project",
+  CLUSTER = "cluster",
+  SETTINGS = "settings",
+  NAMESPACE = "namespace",
+  APPLICATION = "application",
+}
+
+export type HIERARCHY_TREE = { [key: string]: any };

+ 21 - 0
dashboard/src/shared/auth/useAuth.ts

@@ -0,0 +1,21 @@
+import { useCallback, useContext } from "react";
+import { AuthContext } from "./AuthContext";
+import { isAuthorized } from "./authorization-helpers";
+import { ScopeType, Verbs } from "./types";
+
+const useAuth = () => {
+  const authContext = useContext(AuthContext);
+
+  const isAuth = useCallback(
+    (
+      scope: ScopeType,
+      resource: string | string[],
+      verb: Verbs | Array<Verbs>
+    ) => isAuthorized(authContext.currentPolicy, scope, resource, verb),
+    [authContext.currentPolicy]
+  );
+
+  return [isAuth];
+};
+
+export default useAuth;

+ 9 - 0
dashboard/src/shared/hardcodedNameDict.tsx

@@ -1,8 +1,11 @@
 const hardcodedNames: { [key: string]: string } = {
+  agones: "Agones System",
   docker: "Docker",
   "https-issuer": "HTTPS Issuer",
   metabase: "Metabase",
   mongodb: "MongoDB",
+  datadog: "Datadog",
+  "wallarm-ingress": "Wallarm Ingress",
   mysql: "MySQL",
   postgresql: "PostgreSQL",
   redis: "Redis",
@@ -25,6 +28,12 @@ const hardcodedIcons: { [key: string]: string } = {
     "https://pbs.twimg.com/profile_images/961380992727465985/4unoiuHt.jpg",
   mongodb:
     "https://bitnami.com/assets/stacks/mongodb/img/mongodb-stack-220x234.png",
+  datadog:
+    "https://datadog-live.imgix.net/img/dd_logo_70x75.png",
+  wallarm:
+    "https://assets.website-files.com/5fe3434623c64c793987363d/6006cb97f71f76f8a5e85a32_Frame%201923.png",
+  agones:
+    "https://avatars.githubusercontent.com/u/36940055?v=4",
   mysql: "https://www.mysql.com/common/logos/logo-mysql-170x115.png",
   postgresql:
     "https://bitnami.com/assets/stacks/postgresql/img/postgresql-stack-110x117.png",

+ 1 - 0
dashboard/webpack.config.js

@@ -55,6 +55,7 @@ module.exports = () => {
     },
     devServer: {
       historyApiFallback: true,
+      disableHostCheck: true,
     },
     plugins: [
       new HtmlWebpackPlugin({

+ 2 - 0
docker-compose.dev.yaml

@@ -24,6 +24,8 @@ services:
       - ./cmd:/porter/cmd
       - ./internal:/porter/internal
       - ./server:/porter/server
+      - ./api:/porter/api
+      - ./docker/kubeconfig.yaml:/porter/kubeconfig.yaml
   postgres:
     image: postgres:latest
     container_name: postgres

+ 1 - 0
docker/Dockerfile

@@ -11,6 +11,7 @@ COPY go.mod go.sum ./
 COPY /cmd ./cmd
 COPY /internal ./internal
 COPY /server ./server
+COPY /api ./api
 
 RUN --mount=type=cache,target=$GOPATH/pkg/mod \
     go mod download

+ 32 - 0
docs/guides/authorization-and-team-management.md

@@ -0,0 +1,32 @@
+Porter supports setting basic authorization permissions via for other members in a Porter project. At the moment, there are 3 roles that can be assigned in a Porter project:
+
+- **Admin:** read/write access to all resources, ability to delete the project and manage team members.
+- **Developer:** read/write access to applications, jobs, environment groups, cluster data, and integrations.
+- **Viewer:** read access to applications, jobs, environment groups, and cluster data.
+
+# Adding Collaborators
+
+To add a new collaborator to a Porter project, you must be logged in with an **Admin** role. As an admin, you will see a **Settings** tab in the sidebar. Navigate to **Settings** and input the email of the user you would like to add. This will generate an invitation link for the user, which expires in 24 hours. The user will get an email to join the Porter project, but if the email is not delivered, you can copy the invite link and send it to them directly.
+
+![image](https://user-images.githubusercontent.com/23369263/125147098-b00f3100-e0ff-11eb-8579-cc28c1a0badc.png)
+
+**DISCLAIMER**
+
+The user has to register or access porter with the same email that the invitation was sent to.
+If not, it will not be able to look your project.
+
+# Changing Collaborator Permissions
+
+To change an invite or collaborator role, you must be logged in with an **Admin** role. As an admin, you will se a **Settings** tab in the sidebar. Navigate to **Settings** and lookup on the table the invite/collaborator that you want to change it's role, then click the icon with three dots on the row. This will open a pop up that will allow you to select the new role for that invite/collaborator.
+
+You will note that the user that created the project will not be displayed on the table, and you cannot change your own permissions.
+
+![image](https://user-images.githubusercontent.com/23369263/125147141-ea78ce00-e0ff-11eb-9e8b-a3f126874d12.png)
+
+![image](https://user-images.githubusercontent.com/23369263/125147157-0aa88d00-e100-11eb-8d78-1cf34397cd26.png)
+
+# Removing Collaborators
+
+To remove an invite or a collaborator, you must be logged in with an **Admin** role. As an admin, you will se a **Settings** tab in the sidebar. Navigate to **Settings** and lookup on the table the invite/collaborator that you want to remove then click the trash icon to remove the user from the project or delete the invite.
+
+![image](https://user-images.githubusercontent.com/23369263/125147206-3d528580-e100-11eb-9a58-51885ab8b298.png)

+ 23 - 0
docs/guides/linking-github-account.md

@@ -0,0 +1,23 @@
+# Configuring Github Access
+
+> 🚧
+>
+> **Note:** Porter currently uses a Github OAuth App to authenticate and gain access to repositories. This mechanism will be phased out over the next few weeks to transition to the authentication method below. After this, all old applications will still work as intended but new applications will need to be authenticated through a GitHub App.
+
+Porter uses a GitHub App to authorize and gain access to your GitHub repositories. In order to be able to deploy applications through GitHub repositories, you must first authorize the Porter GitHub App to have access to them.
+
+## Step 1: Authorize the Porter Application
+
+On your home page, click select "Account Settings" through the dropdown on the top right and click "connect your GitHub account" in the popup that opens:
+
+![image](https://user-images.githubusercontent.com/25856165/125105942-0acb6d00-e0ad-11eb-8254-6660d390daea.png)
+
+Then, follow the GitHub steps to authorize the application.
+
+## Step 2: Install App in your repositories
+
+Once the Porter Github App is authorized, you can see a list of accounts and organization the Porter has access to through the same popup:
+
+![image](https://user-images.githubusercontent.com/25856165/125106692-ee7c0000-e0ad-11eb-9c79-44714f898aa5.png)
+
+You can install the app into more repositories by clicking on "Install Porter in more repositories". Note that if you are part of an organization, Porter will show you access to every repository that the app is installed into regardless of who it was installed by. So, if your organization does not grant you access to install applications, having an admin install the application into the appropriate repositories is sufficient.

+ 14 - 4
internal/config/config.go

@@ -41,6 +41,11 @@ type ServerConf struct {
 	GithubClientSecret string `env:"GITHUB_CLIENT_SECRET"`
 	GithubLoginEnabled bool   `env:"GITHUB_LOGIN_ENABLED,default=true"`
 
+	GithubAppClientID      string `env:"GITHUB_APP_CLIENT_ID"`
+	GithubAppClientSecret  string `env:"GITHUB_APP_CLIENT_SECRET"`
+	GithubAppName          string `env:"GITHUB_APP_NAME"`
+	GithubAppWebhookSecret string `env:"GITHUB_APP_WEBHOOK_SECRET"`
+
 	GoogleClientID         string `env:"GOOGLE_CLIENT_ID"`
 	GoogleClientSecret     string `env:"GOOGLE_CLIENT_SECRET"`
 	GoogleRestrictedDomain string `env:"GOOGLE_RESTRICTED_DOMAIN"`
@@ -52,10 +57,15 @@ type ServerConf struct {
 	SendgridProjectInviteTemplateID string `env:"SENDGRID_INVITE_TEMPLATE_ID"`
 	SendgridSenderEmail             string `env:"SENDGRID_SENDER_EMAIL"`
 
-	DOClientID          string `env:"DO_CLIENT_ID"`
-	DOClientSecret      string `env:"DO_CLIENT_SECRET"`
-	ProvisionerImageTag string `env:"PROV_IMAGE_TAG,default=latest"`
-	SegmentClientKey    string `env:"SEGMENT_CLIENT_KEY"`
+	DOClientID                 string `env:"DO_CLIENT_ID"`
+	DOClientSecret             string `env:"DO_CLIENT_SECRET"`
+	ProvisionerImageTag        string `env:"PROV_IMAGE_TAG,default=latest"`
+	ProvisionerImagePullSecret string `env:"PROV_IMAGE_PULL_SECRET"`
+	SegmentClientKey           string `env:"SEGMENT_CLIENT_KEY"`
+
+	ProvisionerCluster string `env:"PROVISIONER_CLUSTER"`
+	IngressCluster     string `env:"INGRESS_CLUSTER"`
+	SelfKubeconfig     string `env:"SELF_KUBECONFIG"`
 }
 
 // DBConf is the database configuration: if generated from environment variables,

+ 8 - 6
internal/forms/helper_test.go

@@ -200,12 +200,14 @@ func initOAuthIntegration(tester *tester, t *testing.T) {
 	}
 
 	oauth := &ints.OAuthIntegration{
-		Client:       ints.OAuthGithub,
-		ProjectID:    tester.initProjects[0].ID,
-		UserID:       tester.initUsers[0].ID,
-		ClientID:     []byte("exampleclientid"),
-		AccessToken:  []byte("idtoken"),
-		RefreshToken: []byte("refreshtoken"),
+		SharedOAuthModel: ints.SharedOAuthModel{
+			ClientID:     []byte("exampleclientid"),
+			AccessToken:  []byte("idtoken"),
+			RefreshToken: []byte("refreshtoken"),
+		},
+		Client:    ints.OAuthGithub,
+		ProjectID: tester.initProjects[0].ID,
+		UserID:    tester.initUsers[0].ID,
 	}
 
 	oauth, err := tester.repo.OAuthIntegration.CreateOAuthIntegration(oauth)

+ 2 - 0
internal/forms/invite.go

@@ -11,6 +11,7 @@ import (
 // invite to a project
 type CreateInvite struct {
 	Email     string `json:"email" form:"required"`
+	Kind      string `json:"kind" form:"required"`
 	ProjectID uint   `form:"required"`
 }
 
@@ -21,6 +22,7 @@ func (ci *CreateInvite) ToInvite() (*models.Invite, error) {
 
 	return &models.Invite{
 		Email:     ci.Email,
+		Kind:      ci.Kind,
 		Expiry:    &expiry,
 		ProjectID: ci.ProjectID,
 		Token:     oauth.CreateRandomState(),

+ 3 - 16
internal/forms/project.go

@@ -3,7 +3,6 @@ package forms
 import (
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
-	"gorm.io/gorm"
 )
 
 // WriteProjectForm is a generic form for write operations to the Project model
@@ -24,20 +23,8 @@ func (cpf *CreateProjectForm) ToProject(_ repository.ProjectRepository) (*models
 	}, nil
 }
 
-// CreateProjectRoleForm represents the accepted values for creating a project
+// UpdateProjectRoleForm represents the accepted values for updating a project
 // role
-type CreateProjectRoleForm struct {
-	WriteProjectForm
-	ID    uint          `json:"project_id" form:"required"`
-	Roles []models.Role `json:"roles"`
-}
-
-// ToProject converts the form to a gorm project model
-func (cprf *CreateProjectRoleForm) ToProject(_ repository.ProjectRepository) (*models.Project, error) {
-	return &models.Project{
-		Model: gorm.Model{
-			ID: cprf.ID,
-		},
-		Roles: cprf.Roles,
-	}, nil
+type UpdateProjectRoleForm struct {
+	Kind string `json:"kind"`
 }

+ 1 - 0
internal/helm/agent.go

@@ -168,6 +168,7 @@ func (a *Agent) InstallChart(
 
 	cmd.ReleaseName = conf.Name
 	cmd.Namespace = conf.Namespace
+	cmd.Timeout = 300
 
 	if err := checkIfInstallable(conf.Chart); err != nil {
 		return nil, err

+ 14 - 0
internal/kubernetes/agent.go

@@ -834,6 +834,7 @@ func (a *Agent) ProvisionECR(
 	pgConf *config.DBConf,
 	redisConf *config.RedisConf,
 	provImageTag string,
+	provImagePullSecret string,
 ) (*batchv1.Job, error) {
 	id := infra.GetUniqueName()
 	prov := &provisioner.Conf{
@@ -844,6 +845,7 @@ func (a *Agent) ProvisionECR(
 		Redis:               redisConf,
 		Postgres:            pgConf,
 		ProvisionerImageTag: provImageTag,
+		ImagePullSecret:     provImagePullSecret,
 		LastApplied:         infra.LastApplied,
 		AWS: &aws.Conf{
 			AWSRegion:          awsConf.AWSRegion,
@@ -869,6 +871,7 @@ func (a *Agent) ProvisionEKS(
 	pgConf *config.DBConf,
 	redisConf *config.RedisConf,
 	provImageTag string,
+	provImagePullSecret string,
 ) (*batchv1.Job, error) {
 	id := infra.GetUniqueName()
 	prov := &provisioner.Conf{
@@ -879,6 +882,7 @@ func (a *Agent) ProvisionEKS(
 		Redis:               redisConf,
 		Postgres:            pgConf,
 		ProvisionerImageTag: provImageTag,
+		ImagePullSecret:     provImagePullSecret,
 		LastApplied:         infra.LastApplied,
 		AWS: &aws.Conf{
 			AWSRegion:          awsConf.AWSRegion,
@@ -904,6 +908,7 @@ func (a *Agent) ProvisionGCR(
 	pgConf *config.DBConf,
 	redisConf *config.RedisConf,
 	provImageTag string,
+	provImagePullSecret string,
 ) (*batchv1.Job, error) {
 	id := infra.GetUniqueName()
 	prov := &provisioner.Conf{
@@ -914,6 +919,7 @@ func (a *Agent) ProvisionGCR(
 		Redis:               redisConf,
 		Postgres:            pgConf,
 		ProvisionerImageTag: provImageTag,
+		ImagePullSecret:     provImagePullSecret,
 		LastApplied:         infra.LastApplied,
 		GCP: &gcp.Conf{
 			GCPRegion:    gcpConf.GCPRegion,
@@ -936,6 +942,7 @@ func (a *Agent) ProvisionGKE(
 	pgConf *config.DBConf,
 	redisConf *config.RedisConf,
 	provImageTag string,
+	provImagePullSecret string,
 ) (*batchv1.Job, error) {
 	id := infra.GetUniqueName()
 	prov := &provisioner.Conf{
@@ -946,6 +953,7 @@ func (a *Agent) ProvisionGKE(
 		Redis:               redisConf,
 		Postgres:            pgConf,
 		ProvisionerImageTag: provImageTag,
+		ImagePullSecret:     provImagePullSecret,
 		LastApplied:         infra.LastApplied,
 		GCP: &gcp.Conf{
 			GCPRegion:    gcpConf.GCPRegion,
@@ -972,6 +980,7 @@ func (a *Agent) ProvisionDOCR(
 	pgConf *config.DBConf,
 	redisConf *config.RedisConf,
 	provImageTag string,
+	provImagePullSecret string,
 ) (*batchv1.Job, error) {
 	// get the token
 	oauthInt, err := repo.OAuthIntegration.ReadOAuthIntegration(
@@ -997,6 +1006,7 @@ func (a *Agent) ProvisionDOCR(
 		Redis:               redisConf,
 		Postgres:            pgConf,
 		ProvisionerImageTag: provImageTag,
+		ImagePullSecret:     provImagePullSecret,
 		LastApplied:         infra.LastApplied,
 		DO: &do.Conf{
 			DOToken: tok,
@@ -1022,6 +1032,7 @@ func (a *Agent) ProvisionDOKS(
 	pgConf *config.DBConf,
 	redisConf *config.RedisConf,
 	provImageTag string,
+	provImagePullSecret string,
 ) (*batchv1.Job, error) {
 	// get the token
 	oauthInt, err := repo.OAuthIntegration.ReadOAuthIntegration(
@@ -1048,6 +1059,7 @@ func (a *Agent) ProvisionDOKS(
 		Postgres:            pgConf,
 		LastApplied:         infra.LastApplied,
 		ProvisionerImageTag: provImageTag,
+		ImagePullSecret:     provImagePullSecret,
 		DO: &do.Conf{
 			DOToken: tok,
 		},
@@ -1069,6 +1081,7 @@ func (a *Agent) ProvisionTest(
 	pgConf *config.DBConf,
 	redisConf *config.RedisConf,
 	provImageTag string,
+	provImagePullSecret string,
 ) (*batchv1.Job, error) {
 	id := infra.GetUniqueName()
 
@@ -1080,6 +1093,7 @@ func (a *Agent) ProvisionTest(
 		Redis:               redisConf,
 		Postgres:            pgConf,
 		ProvisionerImageTag: provImageTag,
+		ImagePullSecret:     provImagePullSecret,
 	}
 
 	return a.provision(prov, infra, repo)

+ 3 - 3
internal/kubernetes/config.go

@@ -85,7 +85,7 @@ func GetAgentInClusterConfig() (*Agent, error) {
 		return nil, err
 	}
 
-	restClientGetter := newRESTClientGetterFromInClusterConfig(conf)
+	restClientGetter := NewRESTClientGetterFromInClusterConfig(conf)
 	clientset, err := kubernetes.NewForConfig(conf)
 
 	return &Agent{restClientGetter, clientset}, nil
@@ -386,9 +386,9 @@ func (conf *OutOfClusterConfig) setTokenCache(token string, expiry time.Time) er
 	return err
 }
 
-// newRESTClientGetterFromInClusterConfig returns a RESTClientGetter using
+// NewRESTClientGetterFromInClusterConfig returns a RESTClientGetter using
 // default values set from the *rest.Config
-func newRESTClientGetterFromInClusterConfig(conf *rest.Config) genericclioptions.RESTClientGetter {
+func NewRESTClientGetterFromInClusterConfig(conf *rest.Config) genericclioptions.RESTClientGetter {
 	cfs := genericclioptions.NewConfigFlags(false)
 
 	cfs.ClusterName = &conf.ServerName

+ 31 - 0
internal/kubernetes/local/kubeconfig.go

@@ -9,6 +9,7 @@ import (
 
 	"github.com/porter-dev/porter/internal/kubernetes"
 
+	k8s "k8s.io/client-go/kubernetes"
 	"k8s.io/client-go/tools/clientcmd"
 	"k8s.io/client-go/tools/clientcmd/api"
 	clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
@@ -58,6 +59,36 @@ func GetKubeconfigFromHost(kubeconfigPath string, contexts []string) ([]byte, er
 	return clientcmd.Write(strippedRawConf)
 }
 
+// GetSelfAgentFromFileConfig reads a kubeconfig from a local file and generates an
+// Agent from that kubeconfig
+func GetSelfAgentFromFileConfig(kubeconfigPath string) (*kubernetes.Agent, error) {
+	configBytes, err := GetKubeconfigFromHost(kubeconfigPath, []string{})
+
+	if err != nil {
+		return nil, err
+	}
+
+	cmdConf, err := clientcmd.NewClientConfigFromBytes(configBytes)
+
+	if err != nil {
+		return nil, err
+	}
+
+	restConf, err := cmdConf.ClientConfig()
+
+	if err != nil {
+		return nil, err
+	}
+
+	restClientGetter := kubernetes.NewRESTClientGetterFromInClusterConfig(restConf)
+	clientset, err := k8s.NewForConfig(restConf)
+
+	return &kubernetes.Agent{
+		RESTClientGetter: restClientGetter,
+		Clientset:        clientset,
+	}, nil
+}
+
 // ResolveKubeconfigPath finds the path to a kubeconfig, first searching for the
 // passed string, then in the home directory, then as an env variable.
 func ResolveKubeconfigPath(kubeconfigPath string) (string, error) {

+ 11 - 24
internal/kubernetes/provisioner/provisioner.go

@@ -45,6 +45,7 @@ type Conf struct {
 	Postgres            *config.DBConf
 	Operation           ProvisionerOperation
 	ProvisionerImageTag string
+	ImagePullSecret     string
 	LastApplied         []byte
 
 	// provider-specific configurations
@@ -290,6 +291,14 @@ func (conf *Conf) GetProvisionerJobTemplate() (*batchv1.Job, error) {
 		env = conf.DOKS.AttachDOKSEnv(env)
 	}
 
+	imagePullSecrets := []v1.LocalObjectReference{}
+
+	if conf.ImagePullSecret != "" {
+		imagePullSecrets = append(imagePullSecrets, v1.LocalObjectReference{
+			Name: conf.ImagePullSecret,
+		})
+	}
+
 	return &batchv1.Job{
 		ObjectMeta: metav1.ObjectMeta{
 			Name:      conf.Name,
@@ -304,7 +313,8 @@ func (conf *Conf) GetProvisionerJobTemplate() (*batchv1.Job, error) {
 					Labels: labels,
 				},
 				Spec: v1.PodSpec{
-					RestartPolicy: v1.RestartPolicyNever,
+					RestartPolicy:    v1.RestartPolicyNever,
+					ImagePullSecrets: imagePullSecrets,
 					Containers: []v1.Container{
 						{
 							Name:            "provisioner",
@@ -312,24 +322,6 @@ func (conf *Conf) GetProvisionerJobTemplate() (*batchv1.Job, error) {
 							ImagePullPolicy: v1.PullAlways,
 							Args:            args,
 							Env:             env,
-							VolumeMounts: []v1.VolumeMount{
-								v1.VolumeMount{
-									MountPath: "/.terraform/plugin-cache",
-									Name:      "tf-cache",
-									ReadOnly:  true,
-								},
-							},
-						},
-					},
-					Volumes: []v1.Volume{
-						v1.Volume{
-							Name: "tf-cache",
-							VolumeSource: v1.VolumeSource{
-								PersistentVolumeClaim: &v1.PersistentVolumeClaimVolumeSource{
-									ClaimName: "tf-cache-pvc",
-									ReadOnly:  true,
-								},
-							},
 						},
 					},
 				},
@@ -431,11 +423,6 @@ func (conf *Conf) addTFEnv(env []v1.EnvVar) []v1.EnvVar {
 		Value: "./terraform",
 	})
 
-	env = append(env, v1.EnvVar{
-		Name:  "TF_PLUGIN_CACHE_DIR",
-		Value: "/.terraform/plugin-cache",
-	})
-
 	env = append(env, v1.EnvVar{
 		Name:  "TF_PORTER_BACKEND",
 		Value: "postgres",

+ 33 - 0
internal/models/integrations/github_app.go

@@ -0,0 +1,33 @@
+package integrations
+
+import "gorm.io/gorm"
+
+// GithubAppInstallation is an instance of the porter github app
+// we need to store account/installation id pairs in order to authenticate as the installation
+type GithubAppInstallation struct {
+	gorm.Model
+
+	// Can belong to either a user or an organization
+	AccountID int64 `json:"account_id" gorm:"unique"`
+
+	// Installation ID (used for authentication)
+	InstallationID int64 `json:"installation_id"`
+}
+
+type GithubAppInstallationExternal struct {
+	ID uint `json:"id"`
+
+	// Can belong to either a user or an organization
+	AccountID int64 `json:"account_id"`
+
+	// Installation ID (used for authentication)
+	InstallationID int64 `json:"installation_id"`
+}
+
+func (r *GithubAppInstallation) Externalize() *GithubAppInstallationExternal {
+	return &GithubAppInstallationExternal{
+		ID:             r.ID,
+		AccountID:      r.AccountID,
+		InstallationID: r.InstallationID,
+	}
+}

+ 21 - 7
internal/models/integrations/oauth.go

@@ -14,10 +14,23 @@ const (
 	OAuthGoogle       OAuthIntegrationClient = "google"
 )
 
+// SharedOAuthModel stores general fields needed for OAuth Integration
+type SharedOAuthModel struct {
+	// The ID issued to the client
+	ClientID []byte `json:"client-id"`
+
+	// The end-users's access token
+	AccessToken []byte `json:"access-token"`
+
+	// The end-user's refresh token
+	RefreshToken []byte `json:"refresh-token"`
+}
+
 // OAuthIntegration is an auth mechanism that uses oauth
 // https://tools.ietf.org/html/rfc6749
 type OAuthIntegration struct {
 	gorm.Model
+	SharedOAuthModel
 
 	// The name of the auth mechanism
 	Client OAuthIntegrationClient `json:"client"`
@@ -31,15 +44,16 @@ type OAuthIntegration struct {
 	// ------------------------------------------------------------------
 	// All fields encrypted before storage.
 	// ------------------------------------------------------------------
+}
 
-	// The ID issued to the client
-	ClientID []byte `json:"client-id"`
-
-	// The end-users's access token
-	AccessToken []byte `json:"access-token"`
+// GithubAppOAuthIntegration is the model used for storing github app oauth data
+// Unlike the above, this model is tied to a specific user, not a project
+type GithubAppOAuthIntegration struct {
+	gorm.Model
+	SharedOAuthModel
 
-	// The end-user's refresh token
-	RefreshToken []byte `json:"refresh-token"`
+	// The id of the user that linked this auth mechanism
+	UserID uint `json:"user_id"`
 }
 
 // OAuthIntegrationExternal is an OAuthIntegration to be shared over REST

+ 5 - 0
internal/models/invite.go

@@ -14,6 +14,9 @@ type Invite struct {
 	Expiry *time.Time
 	Email  string
 
+	// Kind is the role kind that this refers to
+	Kind string
+
 	ProjectID uint
 	UserID    uint
 }
@@ -25,6 +28,7 @@ type InviteExternal struct {
 	Expired  bool   `json:"expired"`
 	Email    string `json:"email"`
 	Accepted bool   `json:"accepted"`
+	Kind     string `json:"kind"`
 }
 
 // Externalize generates an external Invite to be shared over REST
@@ -35,6 +39,7 @@ func (i *Invite) Externalize() *InviteExternal {
 		Email:    i.Email,
 		Expired:  i.IsExpired(),
 		Accepted: i.IsAccepted(),
+		Kind:     i.Kind,
 	}
 }
 

+ 0 - 8
internal/models/project.go

@@ -45,18 +45,11 @@ type Project struct {
 type ProjectExternal struct {
 	ID       uint              `json:"id"`
 	Name     string            `json:"name"`
-	Roles    []RoleExternal    `json:"roles"`
 	GitRepos []GitRepoExternal `json:"git_repos,omitempty"`
 }
 
 // Externalize generates an external Project to be shared over REST
 func (p *Project) Externalize() *ProjectExternal {
-	roles := make([]RoleExternal, 0)
-
-	for _, role := range p.Roles {
-		roles = append(roles, *role.Externalize())
-	}
-
 	repos := make([]GitRepoExternal, 0)
 
 	for _, repo := range p.GitRepos {
@@ -66,7 +59,6 @@ func (p *Project) Externalize() *ProjectExternal {
 	return &ProjectExternal{
 		ID:       p.ID,
 		Name:     p.Name,
-		Roles:    roles,
 		GitRepos: repos,
 	}
 }

+ 3 - 2
internal/models/role.go

@@ -6,8 +6,9 @@ import (
 
 // The roles available for a project
 const (
-	RoleAdmin  string = "admin"
-	RoleViewer string = "viewer"
+	RoleAdmin     string = "admin"
+	RoleDeveloper string = "developer"
+	RoleViewer    string = "viewer"
 )
 
 // Role type that extends gorm.Model

+ 3 - 0
internal/models/user.go

@@ -12,6 +12,9 @@ type User struct {
 	Password      string `json:"password"`
 	EmailVerified bool   `json:"email_verified"`
 
+	// ID of oauth integration for github connection (optional)
+	GithubAppIntegrationID uint
+
 	// The github user id used for login (optional)
 	GithubUserID int64
 	GoogleUserID string

+ 24 - 0
internal/oauth/config.go

@@ -18,6 +18,13 @@ type Config struct {
 	BaseURL      string
 }
 
+// GithubAppConf is standard oauth2 config but it need to keeps track of the app name and webhook secret
+type GithubAppConf struct {
+	AppName       string
+	WebhookSecret string
+	oauth2.Config
+}
+
 func NewGithubClient(cfg *Config) *oauth2.Config {
 	return &oauth2.Config{
 		ClientID:     cfg.ClientID,
@@ -31,6 +38,23 @@ func NewGithubClient(cfg *Config) *oauth2.Config {
 	}
 }
 
+func NewGithubAppClient(cfg *Config, name string, secret string) *GithubAppConf {
+	return &GithubAppConf{
+		AppName:       name,
+		WebhookSecret: secret,
+		Config: oauth2.Config{
+			ClientID:     cfg.ClientID,
+			ClientSecret: cfg.ClientSecret,
+			Endpoint: oauth2.Endpoint{
+				AuthURL:  "https://github.com/login/oauth/authorize",
+				TokenURL: "https://github.com/login/oauth/access_token",
+			},
+			RedirectURL: cfg.BaseURL + "/api/oauth/github-app/callback",
+			Scopes:      cfg.Scopes,
+		},
+	}
+}
+
 func NewDigitalOceanClient(cfg *Config) *oauth2.Config {
 	return &oauth2.Config{
 		ClientID:     cfg.ClientID,

+ 92 - 0
internal/repository/gorm/auth.go

@@ -1087,3 +1087,95 @@ func (repo *AWSIntegrationRepository) DecryptAWSIntegrationData(
 
 	return nil
 }
+
+// GithubAppInstallationRepository implements repository.GithubAppInstallationRepository
+type GithubAppInstallationRepository struct {
+	db *gorm.DB
+}
+
+// NewGithubAppInstallationRepository creates a new GithubAppInstallationRepository
+func NewGithubAppInstallationRepository(db *gorm.DB) repository.GithubAppInstallationRepository {
+	return &GithubAppInstallationRepository{db}
+}
+
+// CreateGithubAppInstallation creates a new GithubAppInstallation instance
+func (repo *GithubAppInstallationRepository) CreateGithubAppInstallation(am *ints.GithubAppInstallation) (*ints.GithubAppInstallation, error) {
+	if err := repo.db.Create(am).Error; err != nil {
+		return nil, err
+	}
+	return am, nil
+}
+
+// ReadGithubAppInstallation finds a GithubAppInstallation by id
+func (repo *GithubAppInstallationRepository) ReadGithubAppInstallation(id uint) (*ints.GithubAppInstallation, error) {
+	ret := &ints.GithubAppInstallation{}
+
+	if err := repo.db.Where("id = ?", id).First(&ret).Error; err != nil {
+		return nil, err
+	}
+
+	return ret, nil
+}
+
+// ReadGithubAppInstallationByAccountID finds a GithubAppInstallation by an account ID
+func (repo *GithubAppInstallationRepository) ReadGithubAppInstallationByAccountID(accountID int64) (*ints.GithubAppInstallation, error) {
+
+	ret := &ints.GithubAppInstallation{}
+
+	if err := repo.db.Where("account_id = ?", accountID).First(&ret).Error; err != nil {
+		return nil, err
+	}
+
+	return ret, nil
+}
+
+// ReadGithubAppInstallationByAccountIDs finds all instances of GithubInstallations given a list of account IDs
+// note that if there is not Installation for a given ID, no error will be generated
+func (repo *GithubAppInstallationRepository) ReadGithubAppInstallationByAccountIDs(accountIDs []int64) ([]*ints.GithubAppInstallation, error) {
+	ret := make([]*ints.GithubAppInstallation, 0)
+
+	if err := repo.db.Where("account_id IN ?", accountIDs).Find(&ret).Error; err != nil {
+		return nil, err
+	}
+
+	return ret, nil
+}
+
+// DeleteGithubAppInstallationByAccountID deletes a GithubAppInstallation given an account ID
+// note that this deletion is done with db.Unscoped(), so the record is actually deleted
+func (repo *GithubAppInstallationRepository) DeleteGithubAppInstallationByAccountID(accountID int64) error {
+	if err := repo.db.Unscoped().Where("account_id = ?", accountID).Delete(&ints.GithubAppInstallation{}).Error; err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// GithubAppOAuthIntegrationRepository implements repository.GithubAppOAuthIntegrationRepository
+type GithubAppOAuthIntegrationRepository struct {
+	db *gorm.DB
+}
+
+// NewGithubAppOAuthIntegrationRepository creates a GithubAppOAuthIntegrationRepository
+func NewGithubAppOAuthIntegrationRepository(db *gorm.DB) repository.GithubAppOAuthIntegrationRepository {
+	return &GithubAppOAuthIntegrationRepository{db}
+}
+
+// CreateGithubAppOAuthIntegration creates a new GithubAppOAuthIntegration
+func (repo *GithubAppOAuthIntegrationRepository) CreateGithubAppOAuthIntegration(am *ints.GithubAppOAuthIntegration) (*ints.GithubAppOAuthIntegration, error) {
+	if err := repo.db.Create(am).Error; err != nil {
+		return nil, err
+	}
+	return am, nil
+}
+
+// ReadGithubAppOauthIntegration finds a GithubAppOauthIntegration by id
+func (repo *GithubAppOAuthIntegrationRepository) ReadGithubAppOauthIntegration(id uint) (*ints.GithubAppOAuthIntegration, error) {
+	ret := &ints.GithubAppOAuthIntegration{}
+
+	if err := repo.db.Where("id = ?", id).First(&ret).Error; err != nil {
+		return nil, err
+	}
+
+	return ret, nil
+}

+ 16 - 12
internal/repository/gorm/auth_test.go

@@ -285,12 +285,14 @@ func TestCreateOAuthIntegration(t *testing.T) {
 	defer cleanup(tester, t)
 
 	oauth := &ints.OAuthIntegration{
-		Client:       ints.OAuthGithub,
-		ProjectID:    tester.initProjects[0].ID,
-		UserID:       tester.initUsers[0].ID,
-		ClientID:     []byte("exampleclientid"),
-		AccessToken:  []byte("idtoken"),
-		RefreshToken: []byte("refreshtoken"),
+		SharedOAuthModel: ints.SharedOAuthModel{
+			ClientID:     []byte("exampleclientid"),
+			AccessToken:  []byte("idtoken"),
+			RefreshToken: []byte("refreshtoken"),
+		},
+		Client:    ints.OAuthGithub,
+		ProjectID: tester.initProjects[0].ID,
+		UserID:    tester.initUsers[0].ID,
 	}
 
 	expOAuth := *oauth
@@ -345,12 +347,14 @@ func TestListOAuthIntegrationsByProjectID(t *testing.T) {
 
 	// make sure data is correct
 	expOAuth := ints.OAuthIntegration{
-		Client:       ints.OAuthGithub,
-		ProjectID:    tester.initProjects[0].ID,
-		UserID:       tester.initUsers[0].ID,
-		ClientID:     []byte("exampleclientid"),
-		AccessToken:  []byte("idtoken"),
-		RefreshToken: []byte("refreshtoken"),
+		SharedOAuthModel: ints.SharedOAuthModel{
+			ClientID:     []byte("exampleclientid"),
+			AccessToken:  []byte("idtoken"),
+			RefreshToken: []byte("refreshtoken"),
+		},
+		Client:    ints.OAuthGithub,
+		ProjectID: tester.initProjects[0].ID,
+		UserID:    tester.initUsers[0].ID,
 	}
 
 	oauth := oauths[0]

+ 39 - 6
internal/repository/gorm/helpers_test.go

@@ -72,6 +72,7 @@ func setupTestEnv(tester *tester, t *testing.T) {
 		&ints.ClusterTokenCache{},
 		&ints.RegTokenCache{},
 		&ints.HelmRepoTokenCache{},
+		&ints.GithubAppInstallation{},
 	)
 
 	if err != nil {
@@ -113,6 +114,36 @@ func initUser(tester *tester, t *testing.T) {
 	tester.initUsers = append(tester.initUsers, user)
 }
 
+func initMultiUser(tester *tester, t *testing.T) {
+	t.Helper()
+
+	user := &models.User{
+		Email:    "example@example.com",
+		Password: "hello1234",
+	}
+
+	user, err := tester.repo.User.CreateUser(user)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initUsers = append(tester.initUsers, user)
+
+	user = &models.User{
+		Email:    "example2@example.com",
+		Password: "hello1234",
+	}
+
+	user, err = tester.repo.User.CreateUser(user)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initUsers = append(tester.initUsers, user)
+}
+
 func initProject(tester *tester, t *testing.T) {
 	t.Helper()
 
@@ -242,12 +273,14 @@ func initOAuthIntegration(tester *tester, t *testing.T) {
 	}
 
 	oauth := &ints.OAuthIntegration{
-		Client:       ints.OAuthGithub,
-		ProjectID:    tester.initProjects[0].ID,
-		UserID:       tester.initUsers[0].ID,
-		ClientID:     []byte("exampleclientid"),
-		AccessToken:  []byte("idtoken"),
-		RefreshToken: []byte("refreshtoken"),
+		SharedOAuthModel: ints.SharedOAuthModel{
+			ClientID:     []byte("exampleclientid"),
+			AccessToken:  []byte("idtoken"),
+			RefreshToken: []byte("refreshtoken"),
+		},
+		Client:    ints.OAuthGithub,
+		ProjectID: tester.initProjects[0].ID,
+		UserID:    tester.initUsers[0].ID,
 	}
 
 	oauth, err := tester.repo.OAuthIntegration.CreateOAuthIntegration(oauth)

+ 54 - 0
internal/repository/gorm/project.go

@@ -41,6 +41,22 @@ func (repo *ProjectRepository) CreateProjectRole(project *models.Project, role *
 	return role, nil
 }
 
+func (repo *ProjectRepository) UpdateProjectRole(projID uint, role *models.Role) (*models.Role, error) {
+	foundRole := &models.Role{}
+
+	if err := repo.db.Where("project_id = ? AND user_id = ?", projID, role.UserID).First(&foundRole).Error; err != nil {
+		return nil, err
+	}
+
+	role.ID = foundRole.ID
+
+	if err := repo.db.Save(&role).Error; err != nil {
+		return nil, err
+	}
+
+	return role, nil
+}
+
 // ReadProject gets a projects specified by a unique id
 func (repo *ProjectRepository) ReadProject(id uint) (*models.Project, error) {
 	project := &models.Project{}
@@ -52,6 +68,18 @@ func (repo *ProjectRepository) ReadProject(id uint) (*models.Project, error) {
 	return project, nil
 }
 
+// ReadProject gets a projects specified by a unique id
+func (repo *ProjectRepository) ReadProjectRole(projID, userID uint) (*models.Role, error) {
+	// find the role
+	role := &models.Role{}
+
+	if err := repo.db.Where("project_id = ? AND user_id = ?", projID, userID).First(&role).Error; err != nil {
+		return nil, err
+	}
+
+	return role, nil
+}
+
 // ListProjectsByUserID lists projects where a user has an associated role
 func (repo *ProjectRepository) ListProjectsByUserID(userID uint) ([]*models.Project, error) {
 	projects := make([]*models.Project, 0)
@@ -65,6 +93,17 @@ func (repo *ProjectRepository) ListProjectsByUserID(userID uint) ([]*models.Proj
 	return projects, nil
 }
 
+// ReadProject gets a projects specified by a unique id
+func (repo *ProjectRepository) ListProjectRoles(projID uint) ([]models.Role, error) {
+	project := &models.Project{}
+
+	if err := repo.db.Preload("Roles").Where("id = ?", projID).First(&project).Error; err != nil {
+		return nil, err
+	}
+
+	return project.Roles, nil
+}
+
 // DeleteProject deletes a project (marking deleted in the db)
 func (repo *ProjectRepository) DeleteProject(project *models.Project) (*models.Project, error) {
 	if err := repo.db.Delete(&project).Error; err != nil {
@@ -72,3 +111,18 @@ func (repo *ProjectRepository) DeleteProject(project *models.Project) (*models.P
 	}
 	return project, nil
 }
+
+func (repo *ProjectRepository) DeleteProjectRole(projID, userID uint) (*models.Role, error) {
+	// find the role
+	role := &models.Role{}
+
+	if err := repo.db.Where("project_id = ? AND user_id = ?", projID, userID).First(&role).Error; err != nil {
+		return nil, err
+	}
+
+	if err := repo.db.Delete(&role).Error; err != nil {
+		return nil, err
+	}
+
+	return role, nil
+}

+ 117 - 1
internal/repository/gorm/project_test.go

@@ -108,6 +108,72 @@ func TestCreateProjectRole(t *testing.T) {
 	}
 }
 
+func TestUpdateProjectRole(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_update_proj_role.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initUser(tester, t)
+	initProjectRole(tester, t)
+	defer cleanup(tester, t)
+
+	role := &models.Role{
+		Kind:      models.RoleViewer,
+		UserID:    tester.initUsers[0].Model.ID,
+		ProjectID: tester.initProjects[0].Model.ID,
+	}
+
+	role, err := tester.repo.Project.UpdateProjectRole(tester.initProjects[0].Model.ID, role)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	proj, err := tester.repo.Project.ReadProject(tester.initProjects[0].Model.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure IDs are correct
+	if proj.Model.ID != 1 {
+		t.Errorf("incorrect project ID: expected %d, got %d\n", 1, proj.Model.ID)
+	}
+
+	if len(proj.Roles) != 1 {
+		t.Fatalf("project roles incorrect length: expected %d, got %d\n", 1, len(proj.Roles))
+	}
+
+	if proj.Roles[0].Model.ID != 1 {
+		t.Fatalf("incorrect role ID: expected %d, got %d\n", 1, proj.Roles[0].Model.ID)
+	}
+
+	// make sure data is correct
+	expProj := &models.Project{
+		Name: "project-test",
+		Roles: []models.Role{
+			{
+				Kind:      models.RoleViewer,
+				UserID:    1,
+				ProjectID: 1,
+			},
+		},
+	}
+
+	copyProj := proj
+
+	// reset fields for reflect.DeepEqual
+	copyProj.Model = orm.Model{}
+	copyProj.Roles[0].Model = orm.Model{}
+
+	if diff := deep.Equal(copyProj, expProj); diff != nil {
+		t.Errorf("incorrect project")
+		t.Error(diff)
+	}
+}
+
 func TestListProjectsByUserID(t *testing.T) {
 	tester := &tester{
 		dbFileName: "./list_projects_user_id.db",
@@ -172,7 +238,7 @@ func TestListProjectsByUserID(t *testing.T) {
 
 func TestDeleteProject(t *testing.T) {
 	tester := &tester{
-		dbFileName: "./porter_create_proj_role.db",
+		dbFileName: "./porter_delete_proj.db",
 	}
 
 	setupTestEnv(tester, t)
@@ -192,3 +258,53 @@ func TestDeleteProject(t *testing.T) {
 		t.Fatalf("read should have returned record not found: returned %v\n", err)
 	}
 }
+
+func TestDeleteProjectRole(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_delete_proj_role.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initUser(tester, t)
+	initProjectRole(tester, t)
+	defer cleanup(tester, t)
+
+	_, err := tester.repo.Project.DeleteProjectRole(tester.initProjects[0].Model.ID, tester.initUsers[0].Model.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// attempt to read the project and ensure that the error is gorm.ErrRecordNotFound
+	proj, err := tester.repo.Project.ReadProject(tester.initProjects[0].Model.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure IDs are correct
+	if proj.Model.ID != 1 {
+		t.Errorf("incorrect project ID: expected %d, got %d\n", 1, proj.Model.ID)
+	}
+
+	if len(proj.Roles) != 0 {
+		t.Fatalf("project roles incorrect length: expected %d, got %d\n", 0, len(proj.Roles))
+	}
+
+	// make sure data is correct
+	expProj := &models.Project{
+		Name:  "project-test",
+		Roles: []models.Role{},
+	}
+
+	copyProj := proj
+
+	// reset fields for reflect.DeepEqual
+	copyProj.Model = orm.Model{}
+
+	if diff := deep.Equal(copyProj, expProj); diff != nil {
+		t.Errorf("incorrect project")
+		t.Error(diff)
+	}
+}

+ 22 - 20
internal/repository/gorm/repository.go

@@ -9,25 +9,27 @@ import (
 // gorm.DB for querying the database
 func NewRepository(db *gorm.DB, key *[32]byte) *repository.Repository {
 	return &repository.Repository{
-		User:             NewUserRepository(db),
-		Session:          NewSessionRepository(db),
-		Project:          NewProjectRepository(db),
-		Release:          NewReleaseRepository(db),
-		GitRepo:          NewGitRepoRepository(db, key),
-		Cluster:          NewClusterRepository(db, key),
-		HelmRepo:         NewHelmRepoRepository(db, key),
-		Registry:         NewRegistryRepository(db, key),
-		Infra:            NewInfraRepository(db, key),
-		GitActionConfig:  NewGitActionConfigRepository(db),
-		Invite:           NewInviteRepository(db),
-		AuthCode:         NewAuthCodeRepository(db),
-		DNSRecord:        NewDNSRecordRepository(db),
-		PWResetToken:     NewPWResetTokenRepository(db),
-		KubeIntegration:  NewKubeIntegrationRepository(db, key),
-		BasicIntegration: NewBasicIntegrationRepository(db, key),
-		OIDCIntegration:  NewOIDCIntegrationRepository(db, key),
-		OAuthIntegration: NewOAuthIntegrationRepository(db, key),
-		GCPIntegration:   NewGCPIntegrationRepository(db, key),
-		AWSIntegration:   NewAWSIntegrationRepository(db, key),
+		User:                      NewUserRepository(db),
+		Session:                   NewSessionRepository(db),
+		Project:                   NewProjectRepository(db),
+		Release:                   NewReleaseRepository(db),
+		GitRepo:                   NewGitRepoRepository(db, key),
+		Cluster:                   NewClusterRepository(db, key),
+		HelmRepo:                  NewHelmRepoRepository(db, key),
+		Registry:                  NewRegistryRepository(db, key),
+		Infra:                     NewInfraRepository(db, key),
+		GitActionConfig:           NewGitActionConfigRepository(db),
+		Invite:                    NewInviteRepository(db),
+		AuthCode:                  NewAuthCodeRepository(db),
+		DNSRecord:                 NewDNSRecordRepository(db),
+		PWResetToken:              NewPWResetTokenRepository(db),
+		KubeIntegration:           NewKubeIntegrationRepository(db, key),
+		BasicIntegration:          NewBasicIntegrationRepository(db, key),
+		OIDCIntegration:           NewOIDCIntegrationRepository(db, key),
+		OAuthIntegration:          NewOAuthIntegrationRepository(db, key),
+		GCPIntegration:            NewGCPIntegrationRepository(db, key),
+		AWSIntegration:            NewAWSIntegrationRepository(db, key),
+		GithubAppInstallation:     NewGithubAppInstallationRepository(db),
+		GithubAppOAuthIntegration: NewGithubAppOAuthIntegrationRepository(db),
 	}
 }

+ 11 - 0
internal/repository/gorm/user.go

@@ -35,6 +35,17 @@ func (repo *UserRepository) ReadUser(id uint) (*models.User, error) {
 	return user, nil
 }
 
+// ListUsersByIDs finds all users matching ids
+func (repo *UserRepository) ListUsersByIDs(ids []uint) ([]*models.User, error) {
+	users := make([]*models.User, 0)
+
+	if err := repo.db.Model(&models.User{}).Where("id IN (?)", ids).Find(&users).Error; err != nil {
+		return nil, err
+	}
+
+	return users, nil
+}
+
 // ReadUserByEmail finds a single user based on their unique email
 func (repo *UserRepository) ReadUserByEmail(email string) (*models.User, error) {
 	user := &models.User{}

+ 34 - 0
internal/repository/gorm/user_test.go

@@ -7,6 +7,40 @@ import (
 	"github.com/porter-dev/porter/internal/models"
 )
 
+func TestListUsersByIDs(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_list_users_by_ids.db",
+	}
+
+	setupTestEnv(tester, t)
+	initMultiUser(tester, t)
+	defer cleanup(tester, t)
+
+	users, err := tester.repo.User.ListUsersByIDs([]uint{1, 2})
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	if diff := deep.Equal(tester.initUsers, users); diff != nil {
+		t.Errorf("users not equal:")
+		t.Error(diff)
+	}
+
+	users, err = tester.repo.User.ListUsersByIDs([]uint{1})
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	expUsers := []*models.User{tester.initUsers[0]}
+
+	if diff := deep.Equal(expUsers, users); diff != nil {
+		t.Errorf("users not equal:")
+		t.Error(diff)
+	}
+}
+
 func TestReadUserByGithubUserID(t *testing.T) {
 	tester := &tester{
 		dbFileName: "./porter_read_user_github.db",

+ 16 - 0
internal/repository/integrations.go

@@ -37,6 +37,13 @@ type OAuthIntegrationRepository interface {
 	UpdateOAuthIntegration(am *ints.OAuthIntegration) (*ints.OAuthIntegration, error)
 }
 
+// GithubAppOAuthIntegrationRepository represents the set of queries on the oauth
+// mechanism
+type GithubAppOAuthIntegrationRepository interface {
+	CreateGithubAppOAuthIntegration(am *ints.GithubAppOAuthIntegration) (*ints.GithubAppOAuthIntegration, error)
+	ReadGithubAppOauthIntegration(id uint) (*ints.GithubAppOAuthIntegration, error)
+}
+
 // AWSIntegrationRepository represents the set of queries on the AWS auth
 // mechanism
 type AWSIntegrationRepository interface {
@@ -53,3 +60,12 @@ type GCPIntegrationRepository interface {
 	ReadGCPIntegration(id uint) (*ints.GCPIntegration, error)
 	ListGCPIntegrationsByProjectID(projectID uint) ([]*ints.GCPIntegration, error)
 }
+
+// GithubAppInstallationRepository represents the set of queries for github app installations
+type GithubAppInstallationRepository interface {
+	CreateGithubAppInstallation(am *ints.GithubAppInstallation) (*ints.GithubAppInstallation, error)
+	ReadGithubAppInstallation(id uint) (*ints.GithubAppInstallation, error)
+	ReadGithubAppInstallationByAccountID(accountID int64) (*ints.GithubAppInstallation, error)
+	ReadGithubAppInstallationByAccountIDs(accountIDs []int64) ([]*ints.GithubAppInstallation, error)
+	DeleteGithubAppInstallationByAccountID(accountID int64) error
+}

+ 121 - 2
internal/repository/memory/auth.go

@@ -2,7 +2,6 @@ package test
 
 import (
 	"errors"
-
 	"github.com/porter-dev/porter/internal/repository"
 	"gorm.io/gorm"
 
@@ -220,7 +219,7 @@ func (repo *OAuthIntegrationRepository) CreateOAuthIntegration(
 	am *ints.OAuthIntegration,
 ) (*ints.OAuthIntegration, error) {
 	if !repo.canQuery {
-		return nil, errors.New("Cannot write database")
+		return nil, errors.New("cannot write database")
 	}
 
 	repo.oIntegrations = append(repo.oIntegrations, am)
@@ -427,3 +426,123 @@ func (repo *GCPIntegrationRepository) ListGCPIntegrationsByProjectID(
 
 	return res, nil
 }
+
+// GithubAppInstallationRepository implements repository.GithubAppInstallationRepository
+type GithubAppInstallationRepository struct {
+	canQuery               bool
+	githubAppInstallations []*ints.GithubAppInstallation
+}
+
+func NewGithubAppInstallationRepository(canQuery bool) repository.GithubAppInstallationRepository {
+	return &GithubAppInstallationRepository{
+		canQuery,
+		[]*ints.GithubAppInstallation{},
+	}
+}
+
+func (repo *GithubAppInstallationRepository) CreateGithubAppInstallation(am *ints.GithubAppInstallation) (*ints.GithubAppInstallation, error) {
+	if !repo.canQuery {
+		return nil, errors.New("cannot write database")
+	}
+
+	repo.githubAppInstallations = append(repo.githubAppInstallations, am)
+	am.ID = uint(len(repo.githubAppInstallations))
+
+	return am, nil
+}
+
+func (repo *GithubAppInstallationRepository) ReadGithubAppInstallation(id uint) (*ints.GithubAppInstallation, error) {
+	if !repo.canQuery {
+		return nil, errors.New("cannot write database")
+	}
+
+	if int(id-1) >= len(repo.githubAppInstallations) || repo.githubAppInstallations[id-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	return repo.githubAppInstallations[int(id-1)], nil
+}
+
+func (repo *GithubAppInstallationRepository) ReadGithubAppInstallationByAccountID(accountID int64) (*ints.GithubAppInstallation, error) {
+
+	if !repo.canQuery {
+		return nil, errors.New("cannot write database")
+	}
+
+	for _, installation := range repo.githubAppInstallations {
+		if installation != nil && installation.AccountID == accountID {
+			return installation, nil
+		}
+	}
+
+	return nil, gorm.ErrRecordNotFound
+}
+
+func (repo *GithubAppInstallationRepository) ReadGithubAppInstallationByAccountIDs(accountIDs []int64) ([]*ints.GithubAppInstallation, error) {
+
+	if !repo.canQuery {
+		return nil, errors.New("cannot write database")
+	}
+
+	ret := make([]*ints.GithubAppInstallation, 0)
+
+	for _, installation := range repo.githubAppInstallations {
+		// O(n^2) can be made into O(n) if this is too slow
+		for _, id := range accountIDs {
+			if installation.AccountID == id {
+				ret = append(ret, installation)
+			}
+		}
+	}
+
+	return ret, nil
+}
+
+func (repo *GithubAppInstallationRepository) DeleteGithubAppInstallationByAccountID(accountID int64) error {
+	if !repo.canQuery {
+		return errors.New("cannot write database")
+	}
+
+	for i, installation := range repo.githubAppInstallations {
+		if installation != nil && installation.AccountID == accountID {
+			repo.githubAppInstallations[i] = nil
+		}
+	}
+
+	return nil
+}
+
+type GithubAppOAuthIntegrationRepository struct {
+	canQuery                   bool
+	githubAppOauthIntegrations []*ints.GithubAppOAuthIntegration
+}
+
+func NewGithubAppOAuthIntegrationRepository(canQuery bool) repository.GithubAppOAuthIntegrationRepository {
+	return &GithubAppOAuthIntegrationRepository{
+		canQuery,
+		[]*ints.GithubAppOAuthIntegration{},
+	}
+}
+
+func (repo *GithubAppOAuthIntegrationRepository) CreateGithubAppOAuthIntegration(am *ints.GithubAppOAuthIntegration) (*ints.GithubAppOAuthIntegration, error) {
+	if !repo.canQuery {
+		return nil, errors.New("cannot write database")
+	}
+
+	repo.githubAppOauthIntegrations = append(repo.githubAppOauthIntegrations, am)
+	am.ID = uint(len(repo.githubAppOauthIntegrations))
+
+	return am, nil
+}
+
+func (repo *GithubAppOAuthIntegrationRepository) ReadGithubAppOauthIntegration(id uint) (*ints.GithubAppOAuthIntegration, error) {
+	if !repo.canQuery {
+		return nil, errors.New("cannot write database")
+	}
+
+	if int(id-1) >= len(repo.githubAppOauthIntegrations) || repo.githubAppOauthIntegrations[id-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	return repo.githubAppOauthIntegrations[int(id-1)], nil
+}

+ 123 - 0
internal/repository/memory/project.go

@@ -51,6 +51,41 @@ func (repo *ProjectRepository) CreateProjectRole(project *models.Project, role *
 	return role, nil
 }
 
+// CreateProjectRole appends a role to the existing array of roles
+func (repo *ProjectRepository) UpdateProjectRole(projID uint, role *models.Role) (*models.Role, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	var foundProject *models.Project
+
+	// find all roles matching
+	for _, project := range repo.projects {
+		if project.ID == projID {
+			foundProject = project
+		}
+	}
+
+	if foundProject == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	var index int
+
+	for i, _role := range foundProject.Roles {
+		if _role.UserID == role.UserID {
+			index = i
+		}
+	}
+
+	if index == 0 {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	foundProject.Roles[index] = *role
+	return role, nil
+}
+
 // ReadProject gets a projects specified by a unique id
 func (repo *ProjectRepository) ReadProject(id uint) (*models.Project, error) {
 	if !repo.canQuery {
@@ -65,6 +100,42 @@ func (repo *ProjectRepository) ReadProject(id uint) (*models.Project, error) {
 	return repo.projects[index], nil
 }
 
+// ReadProjectRole gets a role specified by a project ID and user ID
+func (repo *ProjectRepository) ReadProjectRole(projID, userID uint) (*models.Role, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	var foundProject *models.Project
+
+	// find all roles matching
+	for _, project := range repo.projects {
+		if project.ID == projID {
+			foundProject = project
+		}
+	}
+
+	if foundProject == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	var index int
+
+	for i, _role := range foundProject.Roles {
+		if _role.UserID == userID {
+			index = i
+		}
+	}
+
+	if index == 0 {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	res := foundProject.Roles[index]
+
+	return &res, nil
+}
+
 // ListProjectsByUserID lists projects where a user has an associated role
 func (repo *ProjectRepository) ListProjectsByUserID(userID uint) ([]*models.Project, error) {
 	if !repo.canQuery {
@@ -85,6 +156,22 @@ func (repo *ProjectRepository) ListProjectsByUserID(userID uint) ([]*models.Proj
 	return resp, nil
 }
 
+// ListProjectRoles returns a list of roles for the project
+func (repo *ProjectRepository) ListProjectRoles(projID uint) ([]models.Role, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	if int(projID-1) >= len(repo.projects) || repo.projects[projID-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(projID - 1)
+	repo.projects[index] = nil
+
+	return repo.projects[index].Roles, nil
+}
+
 // DeleteProject removes a project
 func (repo *ProjectRepository) DeleteProject(project *models.Project) (*models.Project, error) {
 	if !repo.canQuery {
@@ -100,3 +187,39 @@ func (repo *ProjectRepository) DeleteProject(project *models.Project) (*models.P
 
 	return project, nil
 }
+
+func (repo *ProjectRepository) DeleteProjectRole(projID, userID uint) (*models.Role, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	var foundProject *models.Project
+
+	// find all roles matching
+	for _, project := range repo.projects {
+		if project.ID == projID {
+			foundProject = project
+		}
+	}
+
+	if foundProject == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	var index int
+
+	for i, _role := range foundProject.Roles {
+		if _role.UserID == userID {
+			index = i
+		}
+	}
+
+	if index == 0 {
+		return nil, gorm.ErrRecordNotFound
+	}
+	res := foundProject.Roles[index]
+
+	foundProject.Roles = append(foundProject.Roles[:index], foundProject.Roles[index+1:]...)
+
+	return &res, nil
+}

+ 19 - 17
internal/repository/memory/repository.go

@@ -8,22 +8,24 @@ import (
 // and accepts a parameter that can trigger read/write errors
 func NewRepository(canQuery bool) *repository.Repository {
 	return &repository.Repository{
-		User:             NewUserRepository(canQuery),
-		Session:          NewSessionRepository(canQuery),
-		Project:          NewProjectRepository(canQuery),
-		Cluster:          NewClusterRepository(canQuery),
-		HelmRepo:         NewHelmRepoRepository(canQuery),
-		Registry:         NewRegistryRepository(canQuery),
-		GitRepo:          NewGitRepoRepository(canQuery),
-		Invite:           NewInviteRepository(canQuery),
-		AuthCode:         NewAuthCodeRepository(canQuery),
-		DNSRecord:        NewDNSRecordRepository(canQuery),
-		PWResetToken:     NewPWResetTokenRepository(canQuery),
-		KubeIntegration:  NewKubeIntegrationRepository(canQuery),
-		BasicIntegration: NewBasicIntegrationRepository(canQuery),
-		OIDCIntegration:  NewOIDCIntegrationRepository(canQuery),
-		OAuthIntegration: NewOAuthIntegrationRepository(canQuery),
-		GCPIntegration:   NewGCPIntegrationRepository(canQuery),
-		AWSIntegration:   NewAWSIntegrationRepository(canQuery),
+		User:                      NewUserRepository(canQuery),
+		Session:                   NewSessionRepository(canQuery),
+		Project:                   NewProjectRepository(canQuery),
+		Cluster:                   NewClusterRepository(canQuery),
+		HelmRepo:                  NewHelmRepoRepository(canQuery),
+		Registry:                  NewRegistryRepository(canQuery),
+		GitRepo:                   NewGitRepoRepository(canQuery),
+		Invite:                    NewInviteRepository(canQuery),
+		AuthCode:                  NewAuthCodeRepository(canQuery),
+		DNSRecord:                 NewDNSRecordRepository(canQuery),
+		PWResetToken:              NewPWResetTokenRepository(canQuery),
+		KubeIntegration:           NewKubeIntegrationRepository(canQuery),
+		BasicIntegration:          NewBasicIntegrationRepository(canQuery),
+		OIDCIntegration:           NewOIDCIntegrationRepository(canQuery),
+		OAuthIntegration:          NewOAuthIntegrationRepository(canQuery),
+		GCPIntegration:            NewGCPIntegrationRepository(canQuery),
+		AWSIntegration:            NewAWSIntegrationRepository(canQuery),
+		GithubAppInstallation:     NewGithubAppInstallationRepository(canQuery),
+		GithubAppOAuthIntegration: NewGithubAppOAuthIntegrationRepository(canQuery),
 	}
 }

+ 19 - 0
internal/repository/memory/user.go

@@ -56,6 +56,25 @@ func (repo *UserRepository) ReadUser(id uint) (*models.User, error) {
 	return repo.users[index], nil
 }
 
+func (repo *UserRepository) ListUsersByIDs(ids []uint) ([]*models.User, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	resp := make([]*models.User, 0)
+
+	// find all roles matching
+	for _, user := range repo.users {
+		for _, userID := range ids {
+			if userID == user.ID {
+				resp = append(resp, user)
+			}
+		}
+	}
+
+	return resp, nil
+}
+
 // ReadUserByEmail finds a single user based on their unique email
 func (repo *UserRepository) ReadUserByEmail(email string) (*models.User, error) {
 	if !repo.canQuery {

+ 4 - 0
internal/repository/project.go

@@ -11,7 +11,11 @@ type WriteProject func(project *models.Project) (*models.Project, error)
 type ProjectRepository interface {
 	CreateProject(project *models.Project) (*models.Project, error)
 	CreateProjectRole(project *models.Project, role *models.Role) (*models.Role, error)
+	UpdateProjectRole(projID uint, role *models.Role) (*models.Role, error)
 	ReadProject(id uint) (*models.Project, error)
+	ReadProjectRole(projID, userID uint) (*models.Role, error)
+	ListProjectRoles(projID uint) ([]models.Role, error)
 	ListProjectsByUserID(userID uint) ([]*models.Project, error)
 	DeleteProject(project *models.Project) (*models.Project, error)
+	DeleteProjectRole(projID, userID uint) (*models.Role, error)
 }

+ 22 - 20
internal/repository/repository.go

@@ -2,24 +2,26 @@ package repository
 
 // Repository collects the repositories for each model
 type Repository struct {
-	User             UserRepository
-	Project          ProjectRepository
-	Release          ReleaseRepository
-	Session          SessionRepository
-	GitRepo          GitRepoRepository
-	Cluster          ClusterRepository
-	HelmRepo         HelmRepoRepository
-	Registry         RegistryRepository
-	Infra            InfraRepository
-	GitActionConfig  GitActionConfigRepository
-	Invite           InviteRepository
-	AuthCode         AuthCodeRepository
-	DNSRecord        DNSRecordRepository
-	PWResetToken     PWResetTokenRepository
-	KubeIntegration  KubeIntegrationRepository
-	BasicIntegration BasicIntegrationRepository
-	OIDCIntegration  OIDCIntegrationRepository
-	OAuthIntegration OAuthIntegrationRepository
-	GCPIntegration   GCPIntegrationRepository
-	AWSIntegration   AWSIntegrationRepository
+	User                      UserRepository
+	Project                   ProjectRepository
+	Release                   ReleaseRepository
+	Session                   SessionRepository
+	GitRepo                   GitRepoRepository
+	Cluster                   ClusterRepository
+	HelmRepo                  HelmRepoRepository
+	Registry                  RegistryRepository
+	Infra                     InfraRepository
+	GitActionConfig           GitActionConfigRepository
+	Invite                    InviteRepository
+	AuthCode                  AuthCodeRepository
+	DNSRecord                 DNSRecordRepository
+	PWResetToken              PWResetTokenRepository
+	KubeIntegration           KubeIntegrationRepository
+	BasicIntegration          BasicIntegrationRepository
+	OIDCIntegration           OIDCIntegrationRepository
+	OAuthIntegration          OAuthIntegrationRepository
+	GCPIntegration            GCPIntegrationRepository
+	AWSIntegration            AWSIntegrationRepository
+	GithubAppInstallation     GithubAppInstallationRepository
+	GithubAppOAuthIntegration GithubAppOAuthIntegrationRepository
 }

+ 1 - 0
internal/repository/user.go

@@ -15,6 +15,7 @@ type UserRepository interface {
 	ReadUserByEmail(email string) (*models.User, error)
 	ReadUserByGithubUserID(id int64) (*models.User, error)
 	ReadUserByGoogleUserID(id string) (*models.User, error)
+	ListUsersByIDs(ids []uint) ([]*models.User, error)
 	UpdateUser(user *models.User) (*models.User, error)
 	DeleteUser(user *models.User) (*models.User, error)
 }

+ 73 - 15
server/api/api.go

@@ -10,6 +10,7 @@ import (
 	vr "github.com/go-playground/validator/v10"
 	"github.com/porter-dev/porter/internal/auth/sessionstore"
 	"github.com/porter-dev/porter/internal/auth/token"
+	"github.com/porter-dev/porter/internal/kubernetes/local"
 	"github.com/porter-dev/porter/internal/oauth"
 	"golang.org/x/oauth2"
 	"gorm.io/gorm"
@@ -67,7 +68,8 @@ type App struct {
 	TestAgents *TestAgents
 
 	// An in-cluster agent if service is running in cluster
-	InClusterAgent *kubernetes.Agent
+	ProvisionerAgent *kubernetes.Agent
+	IngressAgent     *kubernetes.Agent
 
 	// redis client for redis connection
 	RedisConf *config.RedisConf
@@ -81,6 +83,7 @@ type App struct {
 	// oauth-specific clients
 	GithubUserConf    *oauth2.Config
 	GithubProjectConf *oauth2.Config
+	GithubAppConf     *oauth.GithubAppConf
 	DOConf            *oauth2.Config
 	GoogleUserConf    *oauth2.Config
 
@@ -140,22 +143,12 @@ func New(conf *AppConfig) (*App, error) {
 	}
 
 	app.Store = store
-
-	// if application is running in-cluster, set provisioning capabilities
-	if kubernetes.IsInCluster() {
-		app.Capabilities.Provisioning = true
-
-		agent, err := kubernetes.GetAgentInClusterConfig()
-
-		if err != nil {
-			return nil, fmt.Errorf("could not get in-cluster agent: %v", err)
-		}
-
-		app.InClusterAgent = agent
-	}
-
 	sc := conf.ServerConf
 
+	// get the InClusterAgent from either a file-based kubeconfig or the in-cluster agent
+	app.assignProvisionerAgent(&sc)
+	app.assignIngressAgent(&sc)
+
 	// if server config contains OAuth client info, create clients
 	if sc.GithubClientID != "" && sc.GithubClientSecret != "" {
 		app.Capabilities.Github = true
@@ -177,6 +170,15 @@ func New(conf *AppConfig) (*App, error) {
 		app.Capabilities.GithubLogin = sc.GithubLoginEnabled
 	}
 
+	if sc.GithubAppClientID != "" && sc.GithubAppClientSecret != "" && sc.GithubAppName != "" && sc.GithubAppWebhookSecret != "" {
+		app.GithubAppConf = oauth.NewGithubAppClient(&oauth.Config{
+			ClientID:     sc.GithubAppClientID,
+			ClientSecret: sc.GithubAppClientSecret,
+			Scopes:       []string{"read:user"},
+			BaseURL:      sc.ServerURL,
+		}, sc.GithubAppName, sc.GithubAppWebhookSecret)
+	}
+
 	if sc.GoogleClientID != "" && sc.GoogleClientSecret != "" {
 		app.Capabilities.GoogleLogin = true
 
@@ -217,6 +219,62 @@ func New(conf *AppConfig) (*App, error) {
 	return app, nil
 }
 
+func (app *App) assignProvisionerAgent(sc *config.ServerConf) error {
+	if sc.ProvisionerCluster == "kubeconfig" && sc.SelfKubeconfig != "" {
+		app.Capabilities.Provisioning = true
+
+		agent, err := local.GetSelfAgentFromFileConfig(sc.SelfKubeconfig)
+
+		if err != nil {
+			return fmt.Errorf("could not get in-cluster agent: %v", err)
+		}
+
+		app.ProvisionerAgent = agent
+
+		return nil
+	} else if sc.ProvisionerCluster == "kubeconfig" {
+		return fmt.Errorf(`"kubeconfig" cluster option requires path to kubeconfig`)
+	}
+
+	app.Capabilities.Provisioning = true
+
+	agent, err := kubernetes.GetAgentInClusterConfig()
+
+	if err != nil {
+		return fmt.Errorf("could not get in-cluster agent: %v", err)
+	}
+
+	app.ProvisionerAgent = agent
+
+	return nil
+}
+
+func (app *App) assignIngressAgent(sc *config.ServerConf) error {
+	if sc.IngressCluster == "kubeconfig" && sc.SelfKubeconfig != "" {
+		agent, err := local.GetSelfAgentFromFileConfig(sc.SelfKubeconfig)
+
+		if err != nil {
+			return fmt.Errorf("could not get in-cluster agent: %v", err)
+		}
+
+		app.IngressAgent = agent
+
+		return nil
+	} else if sc.IngressCluster == "kubeconfig" {
+		return fmt.Errorf(`"kubeconfig" cluster option requires path to kubeconfig`)
+	}
+
+	agent, err := kubernetes.GetAgentInClusterConfig()
+
+	if err != nil {
+		return fmt.Errorf("could not get in-cluster agent: %v", err)
+	}
+
+	app.IngressAgent = agent
+
+	return nil
+}
+
 func (app *App) getTokenFromRequest(r *http.Request) *token.Token {
 	reqToken := r.Header.Get("Authorization")
 

+ 1 - 1
server/api/dns_record_handler.go

@@ -76,7 +76,7 @@ func (app *App) HandleCreateDNSRecord(w http.ResponseWriter, r *http.Request) {
 
 	_record := domain.DNSRecord(*record)
 
-	err = _record.CreateDomain(app.InClusterAgent.Clientset)
+	err = _record.CreateDomain(app.IngressAgent.Clientset)
 
 	if err != nil {
 		app.handleErrorInternal(err, w)

+ 210 - 0
server/api/integration_handler.go

@@ -1,10 +1,22 @@
 package api
 
 import (
+	"context"
+	"crypto/hmac"
+	"crypto/sha256"
+	"encoding/hex"
 	"encoding/json"
+	"fmt"
+	"github.com/google/go-github/github"
+	"github.com/porter-dev/porter/internal/oauth"
+	"golang.org/x/oauth2"
+	"gorm.io/gorm"
+	"io/ioutil"
 	"net/http"
 	"net/url"
+	"sort"
 	"strconv"
+	"strings"
 
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/internal/forms"
@@ -377,3 +389,201 @@ func (app *App) HandleListProjectOAuthIntegrations(w http.ResponseWriter, r *htt
 		return
 	}
 }
+
+// verifySignature verifies a signature based on hmac protocal
+// https://docs.github.com/en/developers/webhooks-and-events/webhooks/securing-your-webhooks
+func verifySignature(secret []byte, signature string, body []byte) bool {
+	if len(signature) != 71 || !strings.HasPrefix(signature, "sha256=") {
+		return false
+	}
+
+	actual := make([]byte, 32)
+	hex.Decode(actual, []byte(signature[7:]))
+
+	computed := hmac.New(sha256.New, secret)
+	computed.Write(body)
+
+	return hmac.Equal(computed.Sum(nil), actual)
+}
+
+func (app *App) HandleGithubAppEvent(w http.ResponseWriter, r *http.Request) {
+	payload, err := ioutil.ReadAll(r.Body)
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	// verify webhook secret
+	signature := r.Header.Get("X-Hub-Signature-256")
+
+	if !verifySignature([]byte(app.GithubAppConf.WebhookSecret), signature, payload) {
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	}
+
+	event, err := github.ParseWebHook(github.WebHookType(r), payload)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	switch e := event.(type) {
+	case *github.InstallationEvent:
+		if *e.Action == "created" {
+			_, err := app.Repo.GithubAppInstallation.ReadGithubAppInstallationByAccountID(*e.Installation.Account.ID)
+
+			if err != nil && err == gorm.ErrRecordNotFound {
+				// insert account/installation pair into database
+				_, err := app.Repo.GithubAppInstallation.CreateGithubAppInstallation(&ints.GithubAppInstallation{
+					AccountID:      *e.Installation.Account.ID,
+					InstallationID: *e.Installation.ID,
+				})
+
+				if err != nil {
+					app.handleErrorInternal(err, w)
+				}
+
+				return
+			} else if err != nil {
+				app.handleErrorInternal(err, w)
+				return
+			}
+		}
+		if *e.Action == "deleted" {
+			err := app.Repo.GithubAppInstallation.DeleteGithubAppInstallationByAccountID(*e.Installation.Account.ID)
+
+			if err != nil {
+				app.handleErrorInternal(err, w)
+				return
+			}
+		}
+	}
+
+}
+
+// HandleGithubAppAuthorize starts the oauth2 flow for a project repo request.
+func (app *App) HandleGithubAppAuthorize(w http.ResponseWriter, r *http.Request) {
+	state := oauth.CreateRandomState()
+
+	err := app.populateOAuthSession(w, r, state, false)
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	// specify access type offline to get a refresh token
+	url := app.GithubAppConf.AuthCodeURL(state, oauth2.AccessTypeOffline)
+
+	http.Redirect(w, r, url, 302)
+}
+
+// HandleGithubAppInstall redirects the user to the Porter github app installation page
+func (app *App) HandleGithubAppInstall(w http.ResponseWriter, r *http.Request) {
+	http.Redirect(w, r, fmt.Sprintf("https://github.com/apps/%s/installations/new", app.GithubAppConf.AppName), 302)
+}
+
+// HandleListGithubAppAccessResp is the response returned by HandleListGithubAppAccess
+type HandleListGithubAppAccessResp struct {
+	HasAccess bool     `json:"has_access"`
+	LoginName string   `json:"username,omitempty"`
+	Accounts  []string `json:"accounts,omitempty"`
+}
+
+// HandleListGithubAppAccess provides basic info on if the current user is authenticated through the GitHub app
+// and what accounts/organizations their authentication has access to
+func (app *App) HandleListGithubAppAccess(w http.ResponseWriter, r *http.Request) {
+	tok, err := app.getGithubUserTokenFromRequest(r)
+
+	if err != nil {
+		res := HandleListGithubAppAccessResp{
+			HasAccess: false,
+		}
+		json.NewEncoder(w).Encode(res)
+		return
+	}
+
+	client := github.NewClient(app.GithubProjectConf.Client(oauth2.NoContext, tok))
+
+	opts := &github.ListOptions{
+		PerPage: 100,
+		Page:    1,
+	}
+
+	res := HandleListGithubAppAccessResp{
+		HasAccess: true,
+	}
+
+	for {
+		orgs, pages, err := client.Organizations.List(context.Background(), "", opts)
+
+		if err != nil {
+			res := HandleListGithubAppAccessResp{
+				HasAccess: false,
+			}
+			json.NewEncoder(w).Encode(res)
+			return
+		}
+
+		for _, org := range orgs {
+			res.Accounts = append(res.Accounts, *org.Login)
+		}
+
+		if pages.NextPage == 0 {
+			break
+		}
+	}
+
+	AuthUser, _, err := client.Users.Get(context.Background(), "")
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	res.LoginName = *AuthUser.Login
+
+	// check if user has app installed in their account
+	Installation, err := app.Repo.GithubAppInstallation.ReadGithubAppInstallationByAccountID(*AuthUser.ID)
+
+	if err != nil && err != gorm.ErrRecordNotFound {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	if Installation != nil {
+		res.Accounts = append(res.Accounts, *AuthUser.Login)
+	}
+
+	sort.Strings(res.Accounts)
+
+	json.NewEncoder(w).Encode(res)
+}
+
+// getGithubUserTokenFromRequest
+func (app *App) getGithubUserTokenFromRequest(r *http.Request) (*oauth2.Token, error) {
+	userID, err := app.getUserIDFromRequest(r)
+
+	if err != nil {
+		return nil, err
+	}
+
+	user, err := app.Repo.User.ReadUser(userID)
+
+	if err != nil {
+		return nil, err
+	}
+
+	oauthInt, err := app.Repo.GithubAppOAuthIntegration.ReadGithubAppOauthIntegration(user.GithubAppIntegrationID)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &oauth2.Token{
+		AccessToken:  string(oauthInt.AccessToken),
+		RefreshToken: string(oauthInt.RefreshToken),
+		TokenType:    "Bearer",
+	}, nil
+}

+ 43 - 1
server/api/invite_handler.go

@@ -98,6 +98,42 @@ func (app *App) HandleCreateInvite(w http.ResponseWriter, r *http.Request) {
 	)
 }
 
+// HandleUpdateInviteRole updates the role for a pending invitation
+func (app *App) HandleUpdateInviteRole(w http.ResponseWriter, r *http.Request) {
+	id, err := strconv.ParseUint(chi.URLParam(r, "invite_id"), 0, 64)
+
+	if err != nil || id == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	invite, err := app.Repo.Invite.ReadInvite(uint(id))
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	form := &forms.UpdateProjectRoleForm{}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	invite.Kind = form.Kind
+
+	invite, err = app.Repo.Invite.UpdateInvite(invite)
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+}
+
 // HandleAcceptInvite accepts an invite to a new project: if successful, a new role
 // is created for that user in the project
 func (app *App) HandleAcceptInvite(w http.ResponseWriter, r *http.Request) {
@@ -167,11 +203,17 @@ func (app *App) HandleAcceptInvite(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	kind := invite.Kind
+
+	if kind == "" {
+		kind = models.RoleDeveloper
+	}
+
 	// create a new Role with the user as the admin
 	_, err = app.Repo.Project.CreateProjectRole(projModel, &models.Role{
 		UserID:    userID,
 		ProjectID: uint(projID),
-		Kind:      models.RoleAdmin,
+		Kind:      kind,
 	})
 
 	if err != nil {

+ 7 - 5
server/api/oauth_do_handler.go

@@ -76,11 +76,13 @@ func (app *App) HandleDOOAuthCallback(w http.ResponseWriter, r *http.Request) {
 	projID, _ := session.Values["project_id"].(uint)
 
 	oauthInt := &integrations.OAuthIntegration{
-		Client:       integrations.OAuthDigitalOcean,
-		UserID:       userID,
-		ProjectID:    projID,
-		AccessToken:  []byte(token.AccessToken),
-		RefreshToken: []byte(token.RefreshToken),
+		SharedOAuthModel: integrations.SharedOAuthModel{
+			AccessToken:  []byte(token.AccessToken),
+			RefreshToken: []byte(token.RefreshToken),
+		},
+		Client:    integrations.OAuthDigitalOcean,
+		UserID:    userID,
+		ProjectID: projID,
 	}
 
 	// create the oauth integration first

+ 98 - 5
server/api/oauth_github_handler.go

@@ -269,11 +269,13 @@ func (app *App) updateProjectFromToken(projectID uint, userID uint, tok *oauth2.
 	}
 
 	oauthInt := &integrations.OAuthIntegration{
-		Client:       integrations.OAuthGithub,
-		UserID:       userID,
-		ProjectID:    projectID,
-		AccessToken:  []byte(tok.AccessToken),
-		RefreshToken: []byte(tok.RefreshToken),
+		SharedOAuthModel: integrations.SharedOAuthModel{
+			AccessToken:  []byte(tok.AccessToken),
+			RefreshToken: []byte(tok.RefreshToken),
+		},
+		Client:    integrations.OAuthGithub,
+		UserID:    userID,
+		ProjectID: projectID,
 	}
 
 	// create the oauth integration first
@@ -294,3 +296,94 @@ func (app *App) updateProjectFromToken(projectID uint, userID uint, tok *oauth2.
 
 	return err
 }
+
+// HandleGithubAppOAuthCallback handles the oauth callback from the GitHub app oauth flow
+// this basically just involves generating an access token and then linking it to the current user
+func (app *App) HandleGithubAppOAuthCallback(w http.ResponseWriter, r *http.Request) {
+	session, err := app.Store.Get(r, app.ServerConf.CookieName)
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	if _, ok := session.Values["state"]; !ok {
+		app.sendExternalError(
+			err,
+			http.StatusForbidden,
+			HTTPError{
+				Code: http.StatusForbidden,
+				Errors: []string{
+					"Could not read cookie: are cookies enabled?",
+				},
+			},
+			w,
+		)
+
+		return
+	}
+
+	if r.URL.Query().Get("state") != session.Values["state"] {
+		if session.Values["query_params"] != "" {
+			http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", session.Values["query_params"]), 302)
+		} else {
+			http.Redirect(w, r, "/dashboard", 302)
+		}
+		return
+	}
+
+	token, err := app.GithubAppConf.Exchange(oauth2.NoContext, r.URL.Query().Get("code"))
+
+	if err != nil || !token.Valid() {
+		if session.Values["query_params"] != "" {
+			http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", session.Values["query_params"]), 302)
+		} else {
+			http.Redirect(w, r, "/dashboard", 302)
+		}
+		return
+	}
+
+	userID, err := app.getUserIDFromRequest(r)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	user, err := app.Repo.User.ReadUser(userID)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	oauthInt := &integrations.GithubAppOAuthIntegration{
+		SharedOAuthModel: integrations.SharedOAuthModel{
+			AccessToken:  []byte(token.AccessToken),
+			RefreshToken: []byte(token.RefreshToken),
+		},
+		UserID: user.ID,
+	}
+
+	oauthInt, err = app.Repo.GithubAppOAuthIntegration.CreateGithubAppOAuthIntegration(oauthInt)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	user.GithubAppIntegrationID = oauthInt.ID
+
+	user, err = app.Repo.User.UpdateUser(user)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	if session.Values["query_params"] != "" {
+		http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", session.Values["query_params"]), 302)
+	} else {
+		http.Redirect(w, r, "/dashboard", 302)
+	}
+}

+ 196 - 0
server/api/project_handler.go

@@ -6,6 +6,7 @@ import (
 	"strconv"
 
 	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/models"
 )
@@ -83,6 +84,78 @@ func (app *App) HandleCreateProject(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
+// HandleGetProjectRoles lists the roles available to the project. For now, these
+// roles are static.
+func (app *App) HandleGetProjectRoles(w http.ResponseWriter, r *http.Request) {
+	roles := []string{models.RoleAdmin, models.RoleDeveloper, models.RoleViewer}
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(&roles); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+type Collaborator struct {
+	ID        uint   `json:"id"`
+	Kind      string `json:"kind"`
+	UserID    uint   `json:"user_id"`
+	Email     string `json:"email"`
+	ProjectID uint   `json:"project_id"`
+}
+
+// HandleListProjectCollaborators lists the collaborators in the project
+func (app *App) HandleListProjectCollaborators(w http.ResponseWriter, r *http.Request) {
+	id, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || id == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	roles, err := app.Repo.Project.ListProjectRoles(uint(id))
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	res := make([]*Collaborator, 0)
+	roleMap := make(map[uint]*models.Role)
+	idArr := make([]uint, 0)
+
+	for _, role := range roles {
+		roleCp := role
+		roleMap[role.UserID] = &roleCp
+		idArr = append(idArr, role.UserID)
+	}
+
+	users, err := app.Repo.User.ListUsersByIDs(idArr)
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	for _, user := range users {
+		res = append(res, &Collaborator{
+			ID:        roleMap[user.ID].ID,
+			Kind:      roleMap[user.ID].Kind,
+			UserID:    roleMap[user.ID].UserID,
+			Email:     user.Email,
+			ProjectID: roleMap[user.ID].ProjectID,
+		})
+	}
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(res); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
 // HandleReadProject returns an externalized Project (models.ProjectExternal)
 // based on an ID
 func (app *App) HandleReadProject(w http.ResponseWriter, r *http.Request) {
@@ -110,6 +183,92 @@ func (app *App) HandleReadProject(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
+// HandleReadProjectPolicy returns the policy document given the current user
+func (app *App) HandleReadProjectPolicy(w http.ResponseWriter, r *http.Request) {
+	id, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || id == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	userID, err := app.getUserIDFromRequest(r)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	role, err := app.Repo.Project.ReadProjectRole(uint(id), userID)
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	// case on the role to get the policy document
+	var policy types.Policy
+	switch role.Kind {
+	case models.RoleAdmin:
+		policy = types.AdminPolicy
+	case models.RoleDeveloper:
+		policy = types.DeveloperPolicy
+	case models.RoleViewer:
+		policy = types.ViewerPolicy
+	}
+
+	if err := json.NewEncoder(w).Encode(policy); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+// HandleUpdateProjectRole updates a project role with a new "kind"
+func (app *App) HandleUpdateProjectRole(w http.ResponseWriter, r *http.Request) {
+	id, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || id == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	userID, err := strconv.ParseUint(chi.URLParam(r, "user_id"), 0, 64)
+
+	if err != nil || id == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	role, err := app.Repo.Project.ReadProjectRole(uint(id), uint(userID))
+
+	if err != nil {
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	}
+
+	form := &forms.UpdateProjectRoleForm{}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	role.Kind = form.Kind
+
+	role, err = app.Repo.Project.UpdateProjectRole(uint(id), role)
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	if err := json.NewEncoder(w).Encode(role.Externalize()); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
 // HandleDeleteProject deletes a project from the db, reading from the project_id
 // in the URL param
 func (app *App) HandleDeleteProject(w http.ResponseWriter, r *http.Request) {
@@ -143,3 +302,40 @@ func (app *App) HandleDeleteProject(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 }
+
+// HandleDeleteProjectRole deletes a project role from the db, reading from the project_id
+// in the URL param
+func (app *App) HandleDeleteProjectRole(w http.ResponseWriter, r *http.Request) {
+	id, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || id == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	userID, err := strconv.ParseUint(chi.URLParam(r, "user_id"), 0, 64)
+
+	if err != nil || id == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	role, err := app.Repo.Project.ReadProjectRole(uint(id), uint(userID))
+
+	if err != nil {
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	}
+
+	role, err = app.Repo.Project.DeleteProjectRole(uint(id), uint(userID))
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	if err := json.NewEncoder(w).Encode(role.Externalize()); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}

+ 25 - 12
server/api/provision_handler.go

@@ -51,7 +51,7 @@ func (app *App) HandleProvisionTestInfra(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	_, err = app.InClusterAgent.ProvisionTest(
+	_, err = app.ProvisionerAgent.ProvisionTest(
 		uint(projID),
 		infra,
 		*app.Repo,
@@ -59,6 +59,7 @@ func (app *App) HandleProvisionTestInfra(w http.ResponseWriter, r *http.Request)
 		&app.DBConf,
 		app.RedisConf,
 		app.ServerConf.ProvisionerImageTag,
+		app.ServerConf.ProvisionerImagePullSecret,
 	)
 
 	if err != nil {
@@ -127,6 +128,7 @@ func (app *App) HandleDestroyTestInfra(w http.ResponseWriter, r *http.Request) {
 		&app.DBConf,
 		app.RedisConf,
 		app.ServerConf.ProvisionerImageTag,
+		app.ServerConf.ProvisionerImagePullSecret,
 	)
 
 	if err != nil {
@@ -191,7 +193,7 @@ func (app *App) HandleProvisionAWSECRInfra(w http.ResponseWriter, r *http.Reques
 	}
 
 	// launch provisioning pod
-	_, err = app.InClusterAgent.ProvisionECR(
+	_, err = app.ProvisionerAgent.ProvisionECR(
 		uint(projID),
 		awsInt,
 		form.ECRName,
@@ -201,6 +203,7 @@ func (app *App) HandleProvisionAWSECRInfra(w http.ResponseWriter, r *http.Reques
 		&app.DBConf,
 		app.RedisConf,
 		app.ServerConf.ProvisionerImageTag,
+		app.ServerConf.ProvisionerImagePullSecret,
 	)
 
 	if err != nil {
@@ -273,7 +276,7 @@ func (app *App) HandleDestroyAWSECRInfra(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	_, err = app.InClusterAgent.ProvisionECR(
+	_, err = app.ProvisionerAgent.ProvisionECR(
 		infra.ProjectID,
 		awsInt,
 		form.ECRName,
@@ -283,6 +286,7 @@ func (app *App) HandleDestroyAWSECRInfra(w http.ResponseWriter, r *http.Request)
 		&app.DBConf,
 		app.RedisConf,
 		app.ServerConf.ProvisionerImageTag,
+		app.ServerConf.ProvisionerImagePullSecret,
 	)
 
 	if err != nil {
@@ -347,7 +351,7 @@ func (app *App) HandleProvisionAWSEKSInfra(w http.ResponseWriter, r *http.Reques
 	}
 
 	// launch provisioning pod
-	_, err = app.InClusterAgent.ProvisionEKS(
+	_, err = app.ProvisionerAgent.ProvisionEKS(
 		uint(projID),
 		awsInt,
 		form.EKSName,
@@ -358,6 +362,7 @@ func (app *App) HandleProvisionAWSEKSInfra(w http.ResponseWriter, r *http.Reques
 		&app.DBConf,
 		app.RedisConf,
 		app.ServerConf.ProvisionerImageTag,
+		app.ServerConf.ProvisionerImagePullSecret,
 	)
 
 	if err != nil {
@@ -430,7 +435,7 @@ func (app *App) HandleDestroyAWSEKSInfra(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	_, err = app.InClusterAgent.ProvisionEKS(
+	_, err = app.ProvisionerAgent.ProvisionEKS(
 		infra.ProjectID,
 		awsInt,
 		form.EKSName,
@@ -441,6 +446,7 @@ func (app *App) HandleDestroyAWSEKSInfra(w http.ResponseWriter, r *http.Request)
 		&app.DBConf,
 		app.RedisConf,
 		app.ServerConf.ProvisionerImageTag,
+		app.ServerConf.ProvisionerImagePullSecret,
 	)
 
 	if err != nil {
@@ -505,7 +511,7 @@ func (app *App) HandleProvisionGCPGCRInfra(w http.ResponseWriter, r *http.Reques
 	}
 
 	// launch provisioning pod
-	_, err = app.InClusterAgent.ProvisionGCR(
+	_, err = app.ProvisionerAgent.ProvisionGCR(
 		uint(projID),
 		gcpInt,
 		*app.Repo,
@@ -514,6 +520,7 @@ func (app *App) HandleProvisionGCPGCRInfra(w http.ResponseWriter, r *http.Reques
 		&app.DBConf,
 		app.RedisConf,
 		app.ServerConf.ProvisionerImageTag,
+		app.ServerConf.ProvisionerImagePullSecret,
 	)
 
 	if err != nil {
@@ -588,7 +595,7 @@ func (app *App) HandleProvisionGCPGKEInfra(w http.ResponseWriter, r *http.Reques
 	}
 
 	// launch provisioning pod
-	_, err = app.InClusterAgent.ProvisionGKE(
+	_, err = app.ProvisionerAgent.ProvisionGKE(
 		uint(projID),
 		gcpInt,
 		form.GKEName,
@@ -598,6 +605,7 @@ func (app *App) HandleProvisionGCPGKEInfra(w http.ResponseWriter, r *http.Reques
 		&app.DBConf,
 		app.RedisConf,
 		app.ServerConf.ProvisionerImageTag,
+		app.ServerConf.ProvisionerImagePullSecret,
 	)
 
 	if err != nil {
@@ -670,7 +678,7 @@ func (app *App) HandleDestroyGCPGKEInfra(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	_, err = app.InClusterAgent.ProvisionGKE(
+	_, err = app.ProvisionerAgent.ProvisionGKE(
 		infra.ProjectID,
 		gcpInt,
 		form.GKEName,
@@ -680,6 +688,7 @@ func (app *App) HandleDestroyGCPGKEInfra(w http.ResponseWriter, r *http.Request)
 		&app.DBConf,
 		app.RedisConf,
 		app.ServerConf.ProvisionerImageTag,
+		app.ServerConf.ProvisionerImagePullSecret,
 	)
 
 	if err != nil {
@@ -788,7 +797,7 @@ func (app *App) HandleProvisionDODOCRInfra(w http.ResponseWriter, r *http.Reques
 	}
 
 	// launch provisioning pod
-	_, err = app.InClusterAgent.ProvisionDOCR(
+	_, err = app.ProvisionerAgent.ProvisionDOCR(
 		uint(projID),
 		oauthInt,
 		app.DOConf,
@@ -800,6 +809,7 @@ func (app *App) HandleProvisionDODOCRInfra(w http.ResponseWriter, r *http.Reques
 		&app.DBConf,
 		app.RedisConf,
 		app.ServerConf.ProvisionerImageTag,
+		app.ServerConf.ProvisionerImagePullSecret,
 	)
 
 	if err != nil {
@@ -872,7 +882,7 @@ func (app *App) HandleDestroyDODOCRInfra(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	_, err = app.InClusterAgent.ProvisionDOCR(
+	_, err = app.ProvisionerAgent.ProvisionDOCR(
 		infra.ProjectID,
 		oauthInt,
 		app.DOConf,
@@ -884,6 +894,7 @@ func (app *App) HandleDestroyDODOCRInfra(w http.ResponseWriter, r *http.Request)
 		&app.DBConf,
 		app.RedisConf,
 		app.ServerConf.ProvisionerImageTag,
+		app.ServerConf.ProvisionerImagePullSecret,
 	)
 
 	if err != nil {
@@ -948,7 +959,7 @@ func (app *App) HandleProvisionDODOKSInfra(w http.ResponseWriter, r *http.Reques
 	}
 
 	// launch provisioning pod
-	_, err = app.InClusterAgent.ProvisionDOKS(
+	_, err = app.ProvisionerAgent.ProvisionDOKS(
 		uint(projID),
 		oauthInt,
 		app.DOConf,
@@ -960,6 +971,7 @@ func (app *App) HandleProvisionDODOKSInfra(w http.ResponseWriter, r *http.Reques
 		&app.DBConf,
 		app.RedisConf,
 		app.ServerConf.ProvisionerImageTag,
+		app.ServerConf.ProvisionerImagePullSecret,
 	)
 
 	if err != nil {
@@ -1032,7 +1044,7 @@ func (app *App) HandleDestroyDODOKSInfra(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	_, err = app.InClusterAgent.ProvisionDOKS(
+	_, err = app.ProvisionerAgent.ProvisionDOKS(
 		infra.ProjectID,
 		oauthInt,
 		app.DOConf,
@@ -1044,6 +1056,7 @@ func (app *App) HandleDestroyDODOKSInfra(w http.ResponseWriter, r *http.Request)
 		&app.DBConf,
 		app.RedisConf,
 		app.ServerConf.ProvisionerImageTag,
+		app.ServerConf.ProvisionerImagePullSecret,
 	)
 
 	if err != nil {

+ 123 - 0
server/api/release_handler.go

@@ -582,6 +582,129 @@ func (app *App) HandleGetReleaseAllPods(w http.ResponseWriter, r *http.Request)
 	}
 }
 
+type GetJobStatusResult struct {
+	Status string `json:"status"`
+}
+
+// HandleGetJobStatus gets the status for a specific job
+func (app *App) HandleGetJobStatus(w http.ResponseWriter, r *http.Request) {
+	name := chi.URLParam(r, "name")
+	namespace := chi.URLParam(r, "namespace")
+
+	form := &forms.GetReleaseForm{
+		ReleaseForm: &forms.ReleaseForm{
+			Form: &helm.Form{
+				Repo:              app.Repo,
+				DigitalOceanOAuth: app.DOConf,
+				Storage:           "secret",
+				Namespace:         namespace,
+			},
+		},
+		Name:     name,
+		Revision: 0,
+	}
+
+	agent, err := app.getAgentFromQueryParams(
+		w,
+		r,
+		form.ReleaseForm,
+		form.ReleaseForm.PopulateHelmOptionsFromQueryParams,
+	)
+
+	// errors are handled in app.getAgentFromQueryParams
+	if err != nil {
+		return
+	}
+
+	release, err := agent.GetRelease(form.Name, form.Revision)
+
+	if err != nil {
+		app.sendExternalError(err, http.StatusNotFound, HTTPError{
+			Code:   ErrReleaseReadData,
+			Errors: []string{"release not found"},
+		}, w)
+
+		return
+	}
+
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	k8sForm := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	k8sForm.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+	k8sForm.DefaultNamespace = form.ReleaseForm.Namespace
+
+	// validate the form
+	if err := app.validator.Struct(k8sForm); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new kubernetes agent
+	var k8sAgent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		k8sAgent = app.TestAgents.K8sAgent
+	} else {
+		k8sAgent, err = kubernetes.GetAgentOutOfClusterConfig(k8sForm.OutOfClusterConfig)
+	}
+
+	jobs, err := k8sAgent.ListJobsByLabel(namespace, kubernetes.Label{
+		Key: "helm.sh/chart",
+		Val: fmt.Sprintf("%s-%s", release.Chart.Name(), release.Chart.Metadata.Version),
+	}, kubernetes.Label{
+		Key: "meta.helm.sh/release-name",
+		Val: name,
+	})
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	res := &GetJobStatusResult{
+		Status: "succeeded",
+	}
+
+	// get the most recent job
+	if len(jobs) > 0 {
+		mostRecentJob := jobs[0]
+
+		for _, job := range jobs {
+			createdAt := job.ObjectMeta.CreationTimestamp
+
+			if mostRecentJob.CreationTimestamp.Before(&createdAt) {
+				mostRecentJob = job
+			}
+		}
+
+		// get the status of the most recent job
+		if mostRecentJob.Status.Succeeded >= 1 {
+			res.Status = "succeeded"
+		} else if mostRecentJob.Status.Active >= 1 {
+			res.Status = "running"
+		} else if mostRecentJob.Status.Failed >= 1 {
+			res.Status = "failed"
+		}
+	}
+
+	if err := json.NewEncoder(w).Encode(res); err != nil {
+		app.handleErrorFormDecoding(err, ErrK8sDecode, w)
+		return
+	}
+}
+
 // HandleListReleaseHistory retrieves a history of releases based on a release name
 func (app *App) HandleListReleaseHistory(w http.ResponseWriter, r *http.Request) {
 	name := chi.URLParam(r, "name")

+ 10 - 5
server/middleware/auth.go

@@ -163,6 +163,7 @@ type AccessType string
 
 // The various access types
 const (
+	AdminAccess AccessType = "admin"
 	ReadAccess  AccessType = "read"
 	WriteAccess AccessType = "write"
 )
@@ -221,16 +222,20 @@ func (auth *Auth) DoesUserHaveProjectAccess(
 		// look for the user role in the project
 		for _, role := range proj.Roles {
 			if role.UserID == userID {
-				if accessType == ReadAccess {
-					next.ServeHTTP(w, r)
-					return
-				} else if accessType == WriteAccess {
+				if accessType == AdminAccess {
 					if role.Kind == models.RoleAdmin {
 						next.ServeHTTP(w, r)
 						return
 					}
+				} else if accessType == WriteAccess {
+					if role.Kind == models.RoleAdmin || role.Kind == models.RoleDeveloper {
+						next.ServeHTTP(w, r)
+						return
+					}
+				} else if accessType == ReadAccess {
+					next.ServeHTTP(w, r)
+					return
 				}
-
 			}
 		}
 

+ 144 - 48
server/router/router.go

@@ -176,6 +176,32 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
+			r.Method(
+				"POST",
+				"/integrations/github-app/webhook",
+				requestlog.NewHandler(a.HandleGithubAppEvent, l),
+			)
+
+			r.Method(
+				"GET",
+				"/integrations/github-app/authorize",
+				requestlog.NewHandler(a.HandleGithubAppAuthorize, l),
+			)
+
+			r.Method(
+				"GET",
+				"/integrations/github-app/install",
+				requestlog.NewHandler(a.HandleGithubAppInstall, l),
+			)
+
+			r.Method(
+				"GET",
+				"/integrations/github-app/access",
+				auth.BasicAuthenticate(
+					requestlog.NewHandler(a.HandleListGithubAppAccess, l),
+				),
+			)
+
 			// /api/templates routes
 			r.Method(
 				"GET",
@@ -215,6 +241,12 @@ func New(a *api.App) *chi.Mux {
 				requestlog.NewHandler(a.HandleGithubOAuthCallback, l),
 			)
 
+			r.Method(
+				"GET",
+				"/oauth/github-app/callback",
+				requestlog.NewHandler(a.HandleGithubAppOAuthCallback, l),
+			)
+
 			r.Method(
 				"GET",
 				"/oauth/login/google",
@@ -254,6 +286,46 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
+			r.Method(
+				"GET",
+				"/projects/{project_id}/policy",
+				auth.DoesUserHaveProjectAccess(
+					requestlog.NewHandler(a.HandleReadProjectPolicy, l),
+					mw.URLParam,
+					mw.ReadAccess,
+				),
+			)
+
+			r.Method(
+				"GET",
+				"/projects/{project_id}/roles",
+				auth.DoesUserHaveProjectAccess(
+					requestlog.NewHandler(a.HandleGetProjectRoles, l),
+					mw.URLParam,
+					mw.AdminAccess,
+				),
+			)
+
+			r.Method(
+				"GET",
+				"/projects/{project_id}/collaborators",
+				auth.DoesUserHaveProjectAccess(
+					requestlog.NewHandler(a.HandleListProjectCollaborators, l),
+					mw.URLParam,
+					mw.AdminAccess,
+				),
+			)
+
+			r.Method(
+				"POST",
+				"/projects/{project_id}/roles/{user_id}",
+				auth.DoesUserHaveProjectAccess(
+					requestlog.NewHandler(a.HandleUpdateProjectRole, l),
+					mw.URLParam,
+					mw.AdminAccess,
+				),
+			)
+
 			r.Method(
 				"POST",
 				"/projects",
@@ -268,7 +340,17 @@ func New(a *api.App) *chi.Mux {
 				auth.DoesUserHaveProjectAccess(
 					requestlog.NewHandler(a.HandleDeleteProject, l),
 					mw.URLParam,
-					mw.WriteAccess,
+					mw.AdminAccess,
+				),
+			)
+
+			r.Method(
+				"DELETE",
+				"/projects/{project_id}/roles/{user_id}",
+				auth.DoesUserHaveProjectAccess(
+					requestlog.NewHandler(a.HandleDeleteProjectRole, l),
+					mw.URLParam,
+					mw.AdminAccess,
 				),
 			)
 
@@ -283,7 +365,7 @@ func New(a *api.App) *chi.Mux {
 						mw.QueryParam,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -294,7 +376,7 @@ func New(a *api.App) *chi.Mux {
 				auth.DoesUserHaveProjectAccess(
 					requestlog.NewHandler(a.HandleCreateInvite, l),
 					mw.URLParam,
-					mw.WriteAccess,
+					mw.AdminAccess,
 				),
 			)
 
@@ -304,7 +386,7 @@ func New(a *api.App) *chi.Mux {
 				auth.DoesUserHaveProjectAccess(
 					requestlog.NewHandler(a.HandleListProjectInvites, l),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.AdminAccess,
 				),
 			)
 
@@ -316,6 +398,20 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
+			r.Method(
+				"POST",
+				"/projects/{project_id}/invites/{invite_id}",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveInviteAccess(
+						requestlog.NewHandler(a.HandleUpdateInviteRole, l),
+						mw.URLParam,
+						mw.URLParam,
+					),
+					mw.URLParam,
+					mw.AdminAccess,
+				),
+			)
+
 			r.Method(
 				"DELETE",
 				"/projects/{project_id}/invites/{invite_id}",
@@ -326,7 +422,7 @@ func New(a *api.App) *chi.Mux {
 						mw.URLParam,
 					),
 					mw.URLParam,
-					mw.WriteAccess,
+					mw.AdminAccess,
 				),
 			)
 
@@ -348,7 +444,7 @@ func New(a *api.App) *chi.Mux {
 				auth.DoesUserHaveProjectAccess(
 					requestlog.NewHandler(a.HandleProvisionTestInfra, l),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -363,7 +459,7 @@ func New(a *api.App) *chi.Mux {
 						false,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -378,7 +474,7 @@ func New(a *api.App) *chi.Mux {
 						false,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -393,7 +489,7 @@ func New(a *api.App) *chi.Mux {
 						false,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -408,7 +504,7 @@ func New(a *api.App) *chi.Mux {
 						false,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -423,7 +519,7 @@ func New(a *api.App) *chi.Mux {
 						false,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -438,7 +534,7 @@ func New(a *api.App) *chi.Mux {
 						false,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -456,20 +552,6 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
-			r.Method(
-				"POST",
-				"/projects/{project_id}/provision/{kind}/{infra_id}/logs",
-				auth.DoesUserHaveProjectAccess(
-					auth.DoesUserHaveInfraAccess(
-						requestlog.NewHandler(a.HandleGetProvisioningLogs, l),
-						mw.URLParam,
-						mw.URLParam,
-					),
-					mw.URLParam,
-					mw.ReadAccess,
-				),
-			)
-
 			r.Method(
 				"POST",
 				"/projects/{project_id}/infra/{infra_id}/ecr/destroy",
@@ -480,7 +562,7 @@ func New(a *api.App) *chi.Mux {
 						mw.URLParam,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -494,7 +576,7 @@ func New(a *api.App) *chi.Mux {
 						mw.URLParam,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -508,7 +590,7 @@ func New(a *api.App) *chi.Mux {
 						mw.URLParam,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -522,7 +604,7 @@ func New(a *api.App) *chi.Mux {
 						mw.URLParam,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -536,7 +618,7 @@ func New(a *api.App) *chi.Mux {
 						mw.URLParam,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -550,7 +632,7 @@ func New(a *api.App) *chi.Mux {
 						mw.URLParam,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -773,7 +855,7 @@ func New(a *api.App) *chi.Mux {
 				auth.DoesUserHaveProjectAccess(
 					requestlog.NewHandler(a.HandleListProjectHelmRepos, l),
 					mw.URLParam,
-					mw.WriteAccess,
+					mw.ReadAccess,
 				),
 			)
 
@@ -783,7 +865,7 @@ func New(a *api.App) *chi.Mux {
 				auth.DoesUserHaveProjectAccess(
 					requestlog.NewHandler(a.HandleListHelmRepoCharts, l),
 					mw.URLParam,
-					mw.WriteAccess,
+					mw.ReadAccess,
 				),
 			)
 
@@ -819,7 +901,7 @@ func New(a *api.App) *chi.Mux {
 				auth.DoesUserHaveProjectAccess(
 					requestlog.NewHandler(a.HandleListProjectRegistries, l),
 					mw.URLParam,
-					mw.WriteAccess,
+					mw.ReadAccess,
 				),
 			)
 
@@ -833,7 +915,7 @@ func New(a *api.App) *chi.Mux {
 						mw.URLParam,
 					),
 					mw.URLParam,
-					mw.WriteAccess,
+					mw.ReadAccess,
 				),
 			)
 
@@ -933,7 +1015,7 @@ func New(a *api.App) *chi.Mux {
 						mw.URLParam,
 					),
 					mw.URLParam,
-					mw.WriteAccess,
+					mw.ReadAccess,
 				),
 			)
 
@@ -1272,6 +1354,20 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
+			r.Method(
+				"GET",
+				"/projects/{project_id}/k8s/{namespace}/{name}/jobs/status",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						requestlog.NewHandler(a.HandleGetJobStatus, l),
+						mw.URLParam,
+						mw.QueryParam,
+					),
+					mw.URLParam,
+					mw.ReadAccess,
+				),
+			)
+
 			r.Method(
 				"GET",
 				"/projects/{project_id}/k8s/jobs/{namespace}/{name}/pods",
@@ -1352,7 +1448,7 @@ func New(a *api.App) *chi.Mux {
 						mw.QueryParam,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -1380,7 +1476,7 @@ func New(a *api.App) *chi.Mux {
 						mw.QueryParam,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -1394,7 +1490,7 @@ func New(a *api.App) *chi.Mux {
 						mw.QueryParam,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -1436,7 +1532,7 @@ func New(a *api.App) *chi.Mux {
 						mw.QueryParam,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -1464,7 +1560,7 @@ func New(a *api.App) *chi.Mux {
 						mw.QueryParam,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -1479,7 +1575,7 @@ func New(a *api.App) *chi.Mux {
 						mw.QueryParam,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -1501,7 +1597,7 @@ func New(a *api.App) *chi.Mux {
 						mw.QueryParam,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -1515,7 +1611,7 @@ func New(a *api.App) *chi.Mux {
 						mw.QueryParam,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 		})
@@ -1534,7 +1630,7 @@ func New(a *api.App) *chi.Mux {
 						mw.QueryParam,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -1554,7 +1650,7 @@ func New(a *api.App) *chi.Mux {
 						mw.QueryParam,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -1568,7 +1664,7 @@ func New(a *api.App) *chi.Mux {
 						mw.QueryParam,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)