Explorar el Código

Merge branch '0.6.0-implement-rbac' of https://github.com/porter-dev/porter into 0.6.0-implement-rbac

jusrhee hace 4 años
padre
commit
442680b65a
Se han modificado 58 ficheros con 1998 adiciones y 360 borrados
  1. 101 0
      api/types/policy.go
  2. 4 1
      dashboard/src/components/RadioSelector.tsx
  3. 59 0
      dashboard/src/components/UnauthorizedPage.tsx
  4. 4 1
      dashboard/src/main/MainWrapper.tsx
  5. 64 38
      dashboard/src/main/home/Home.tsx
  6. 55 18
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  7. 17 2
      dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx
  8. 19 13
      dashboard/src/main/home/cluster-dashboard/dashboard/NamespaceList.tsx
  9. 26 13
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx
  10. 57 22
      dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx
  11. 19 5
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  12. 23 13
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  13. 9 3
      dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx
  14. 10 6
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ValuesYaml.tsx
  15. 12 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobList.tsx
  16. 16 9
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx
  17. 2 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  18. 18 14
      dashboard/src/main/home/dashboard/Dashboard.tsx
  19. 12 3
      dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx
  20. 1 1
      dashboard/src/main/home/modals/AccountSettingsModal.tsx
  21. 176 0
      dashboard/src/main/home/modals/EditInviteOrCollaboratorModal.tsx
  22. 33 3
      dashboard/src/main/home/navbar/Navbar.tsx
  23. 173 26
      dashboard/src/main/home/project-settings/InviteList.tsx
  24. 28 21
      dashboard/src/main/home/sidebar/Sidebar.tsx
  25. 3 1
      dashboard/src/shared/Context.tsx
  26. 38 0
      dashboard/src/shared/api.tsx
  27. 58 15
      dashboard/src/shared/auth/AuthContext.tsx
  28. 37 2
      dashboard/src/shared/auth/AuthorizationHoc.tsx
  29. 11 9
      dashboard/src/shared/auth/RouteGuard.tsx
  30. 58 18
      dashboard/src/shared/auth/authorization-helpers.ts
  31. 5 2
      dashboard/src/shared/auth/types.ts
  32. 21 0
      dashboard/src/shared/auth/useAuth.ts
  33. 1 0
      docker-compose.dev.yaml
  34. 1 0
      docker/Dockerfile
  35. 17 0
      docs/guides/authorization-and-team-management.md
  36. 17 9
      docs/guides/linking-github-account.md
  37. 4 3
      internal/config/config.go
  38. 2 0
      internal/forms/invite.go
  39. 3 16
      internal/forms/project.go
  40. 5 0
      internal/models/invite.go
  41. 0 8
      internal/models/project.go
  42. 3 2
      internal/models/role.go
  43. 6 4
      internal/oauth/config.go
  44. 30 0
      internal/repository/gorm/helpers_test.go
  45. 54 0
      internal/repository/gorm/project.go
  46. 117 1
      internal/repository/gorm/project_test.go
  47. 11 0
      internal/repository/gorm/user.go
  48. 34 0
      internal/repository/gorm/user_test.go
  49. 123 0
      internal/repository/memory/project.go
  50. 19 0
      internal/repository/memory/user.go
  51. 4 0
      internal/repository/project.go
  52. 1 0
      internal/repository/user.go
  53. 2 2
      server/api/api.go
  54. 28 0
      server/api/integration_handler.go
  55. 43 1
      server/api/invite_handler.go
  56. 196 0
      server/api/project_handler.go
  57. 10 5
      server/middleware/auth.go
  58. 98 48
      server/router/router.go

+ 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{},
+			},
+		},
+	},
+}

+ 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) => {
           (option: { label: string; value: string }, i: number) => {
             let selected = option.value === this.props.selected;
             let selected = option.value === this.props.selected;
             return (
             return (
-              <RadioRow onClick={() => this.props.setSelected(option.value)}>
+              <RadioRow
+                key={option.value}
+                onClick={() => this.props.setSelected(option.value)}
+              >
                 <Indicator selected={selected}>
                 <Indicator selected={selected}>
                   {selected && <Circle />}
                   {selected && <Circle />}
                 </Indicator>
                 </Indicator>

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

+ 64 - 38
dashboard/src/main/home/Home.tsx

@@ -26,14 +26,28 @@ import ProjectSettings from "./project-settings/ProjectSettings";
 import Sidebar from "./sidebar/Sidebar";
 import Sidebar from "./sidebar/Sidebar";
 import PageNotFound from "components/PageNotFound";
 import PageNotFound from "components/PageNotFound";
 import DeleteNamespaceModal from "./modals/DeleteNamespaceModal";
 import DeleteNamespaceModal from "./modals/DeleteNamespaceModal";
+import { fakeGuardedRoute } from "shared/auth/RouteGuard";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+import EditInviteOrCollaboratorModal from "./modals/EditInviteOrCollaboratorModal";
 import AccountSettingsModal from "./modals/AccountSettingsModal";
 import AccountSettingsModal from "./modals/AccountSettingsModal";
-
-type PropsType = RouteComponentProps & {
-  logOut: () => void;
-  currentProject: ProjectType;
-  currentCluster: ClusterType;
-  currentRoute: PorterUrl;
-};
+// Guarded components
+const GuardedProjectSettings = fakeGuardedRoute("settings", "", [
+  "get",
+  "list",
+])(ProjectSettings);
+
+const GuardedIntegrations = fakeGuardedRoute("integrations", "", [
+  "get",
+  "list",
+])(Integrations);
+
+type PropsType = RouteComponentProps &
+  WithAuthProps & {
+    logOut: () => void;
+    currentProject: ProjectType;
+    currentCluster: ClusterType;
+    currentRoute: PorterUrl;
+  };
 
 
 type StateType = {
 type StateType = {
   forceSidebar: boolean;
   forceSidebar: boolean;
@@ -91,9 +105,6 @@ class Home extends Component<PropsType, StateType> {
   };
   };
 
 
   getCapabilities = () => {
   getCapabilities = () => {
-    let { currentProject } = this.props;
-    if (!currentProject) return;
-
     api
     api
       .getCapabilities("<token>", {}, {})
       .getCapabilities("<token>", {}, {})
       .then((res) => {
       .then((res) => {
@@ -337,9 +348,9 @@ class Home extends Component<PropsType, StateType> {
           </DashboardWrapper>
           </DashboardWrapper>
         );
         );
       } else if (currentView === "integrations") {
       } else if (currentView === "integrations") {
-        return <Integrations />;
+        return <GuardedIntegrations />;
       } else if (currentView === "project-settings") {
       } else if (currentView === "project-settings") {
-        return <ProjectSettings />;
+        return <GuardedProjectSettings />;
       }
       }
       return <Templates />;
       return <Templates />;
     } else if (currentView === "new-project") {
     } else if (currentView === "new-project") {
@@ -472,19 +483,22 @@ class Home extends Component<PropsType, StateType> {
             <ClusterInstructionsModal />
             <ClusterInstructionsModal />
           </Modal>
           </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" && (
         {currentModal === "IntegrationsModal" && (
           <Modal
           <Modal
             onRequestClose={() => setCurrentModal(null, null)}
             onRequestClose={() => setCurrentModal(null, null)}
@@ -503,22 +517,34 @@ class Home extends Component<PropsType, StateType> {
             <IntegrationsInstructionsModal />
             <IntegrationsInstructionsModal />
           </Modal>
           </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
           <Modal
             onRequestClose={() => setCurrentModal(null, null)}
             onRequestClose={() => setCurrentModal(null, null)}
             width="600px"
             width="600px"
-            height="220px"
-          >
-            <NamespaceModal />
-          </Modal>
-        )}
-        {currentModal === "DeleteNamespaceModal" && (
-          <Modal
-            onRequestClose={() => setCurrentModal(null, null)}
-            width="700px"
-            height="280px"
+            height="250px"
           >
           >
-            <DeleteNamespaceModal />
+            <EditInviteOrCollaboratorModal />
           </Modal>
           </Modal>
         )}
         )}
         {currentModal === "AccountSettingsModal" && (
         {currentModal === "AccountSettingsModal" && (
@@ -558,7 +584,7 @@ class Home extends Component<PropsType, StateType> {
 
 
 Home.contextType = Context;
 Home.contextType = Context;
 
 
-export default withRouter(Home);
+export default withRouter(withAuth(Home));
 
 
 const ViewWrapper = styled.div`
 const ViewWrapper = styled.div`
   height: 100%;
   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 EnvGroupDashboard from "./env-groups/EnvGroupDashboard";
 import NamespaceSelector from "./NamespaceSelector";
 import NamespaceSelector from "./NamespaceSelector";
 import SortSelector from "./SortSelector";
 import SortSelector from "./SortSelector";
-import ExpandedChart from "./expanded-chart/ExpandedChart";
 import ExpandedChartWrapper from "./expanded-chart/ExpandedChartWrapper";
 import ExpandedChartWrapper from "./expanded-chart/ExpandedChartWrapper";
 import { RouteComponentProps, withRouter } from "react-router";
 import { RouteComponentProps, withRouter } from "react-router";
 
 
 import api from "shared/api";
 import api from "shared/api";
 import DashboardRoutes from "./dashboard/Routes";
 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 = {
 type StateType = {
   namespace: string;
   namespace: string;
@@ -128,14 +130,23 @@ class ClusterDashboard extends Component<PropsType, StateType> {
 
 
   renderBody = () => {
   renderBody = () => {
     let { currentCluster, currentView } = this.props;
     let { currentCluster, currentView } = this.props;
+    const isAuthorizedToAdd = this.props.isAuthorized(
+      "namespace",
+      [],
+      ["get", "create"]
+    );
     return (
     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>
           <SortFilterWrapper>
             <SortSelector
             <SortSelector
               setSortType={(sortType) => this.setState({ sortType })}
               setSortType={(sortType) => this.setState({ sortType })}
@@ -203,9 +214,30 @@ class ClusterDashboard extends Component<PropsType, StateType> {
             isMetricsInstalled={this.state.isMetricsInstalled}
             isMetricsInstalled={this.state.isMetricsInstalled}
           />
           />
         </Route>
         </Route>
-        <Route path={["/jobs", "/applications", "/env-groups"]}>
+        <GuardedRoute
+          path={"/jobs"}
+          scope="job"
+          resource=""
+          verb={["get", "list"]}
+        >
           {this.renderContents()}
           {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"]}>
         <Route path={["/cluster-dashboard"]}>
           <DashboardRoutes />
           <DashboardRoutes />
         </Route>
         </Route>
@@ -216,11 +248,16 @@ class ClusterDashboard extends Component<PropsType, StateType> {
 
 
 ClusterDashboard.contextType = Context;
 ClusterDashboard.contextType = Context;
 
 
-export default withRouter(ClusterDashboard);
+export default withRouter(withAuth(ClusterDashboard));
 
 
 const ControlRow = styled.div`
 const ControlRow = styled.div`
   display: flex;
   display: flex;
-  justify-content: space-between;
+  justify-content: ${(props: { hasMultipleChilds: boolean }) => {
+    if (props.hasMultipleChilds) {
+      return "space-between";
+    }
+    return "flex-end";
+  }};
   align-items: center;
   align-items: center;
   margin-bottom: 35px;
   margin-bottom: 35px;
   padding-left: 0px;
   padding-left: 0px;
@@ -389,7 +426,7 @@ const TitleSection = styled.div`
     margin-left: 10px;
     margin-left: 10px;
     cursor: pointer;
     cursor: pointer;
     font-size: 18px;
     font-size: 18px;
-    color: #858FAAaa;
+    color: #858faaaa;
     padding: 5px;
     padding: 5px;
     border-radius: 100px;
     border-radius: 100px;
     :hover {
     :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 styled from "styled-components";
 
 
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
@@ -8,6 +8,7 @@ import NodeList from "./NodeList";
 
 
 import { NamespaceList } from "./NamespaceList";
 import { NamespaceList } from "./NamespaceList";
 import ClusterSettings from "./ClusterSettings";
 import ClusterSettings from "./ClusterSettings";
+import useAuth from "shared/auth/useAuth";
 
 
 type TabEnum = "nodes" | "settings" | "namespaces";
 type TabEnum = "nodes" | "settings" | "namespaces";
 
 
@@ -22,6 +23,9 @@ const tabOptions: {
 
 
 export const Dashboard: React.FunctionComponent = () => {
 export const Dashboard: React.FunctionComponent = () => {
   const [currentTab, setCurrentTab] = useState<TabEnum>("nodes");
   const [currentTab, setCurrentTab] = useState<TabEnum>("nodes");
+  const [currentTabOptions, setCurrentTabOptions] = useState(tabOptions);
+  const [isAuthorized] = useAuth();
+
   const context = useContext(Context);
   const context = useContext(Context);
   const renderTab = () => {
   const renderTab = () => {
     switch (currentTab) {
     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 (
   return (
     <>
     <>
       <TitleSection>
       <TitleSection>
@@ -56,7 +71,7 @@ export const Dashboard: React.FunctionComponent = () => {
       </InfoSection>
       </InfoSection>
 
 
       <TabSelector
       <TabSelector
-        options={tabOptions}
+        options={currentTabOptions}
         currentTab={currentTab}
         currentTab={currentTab}
         setCurrentTab={(value: TabEnum) => setCurrentTab(value)}
         setCurrentTab={(value: TabEnum) => setCurrentTab(value)}
       />
       />

+ 19 - 13
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 { ClusterType, ProjectType } from "shared/types";
 import { pushFiltered } from "shared/routing";
 import { pushFiltered } from "shared/routing";
 import { useHistory, useLocation } from "react-router";
 import { useHistory, useLocation } from "react-router";
+import useAuth from "shared/auth/useAuth";
 
 
 const OptionsDropdown: React.FC = ({ children }) => {
 const OptionsDropdown: React.FC = ({ children }) => {
   const [isOpen, setIsOpen] = useState(false);
   const [isOpen, setIsOpen] = useState(false);
@@ -68,6 +69,8 @@ export const NamespaceList: React.FunctionComponent = () => {
     setCurrentModal("DeleteNamespaceModal", namespace);
     setCurrentModal("DeleteNamespaceModal", namespace);
   };
   };
 
 
+  const [isAuthorized] = useAuth();
+
   const isAvailableForDeletion = (namespaceName: string) => {
   const isAvailableForDeletion = (namespaceName: string) => {
     // Only the namespaces that doesn't start with kube- or has by name default will be
     // 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)
     // available for deletion (as those are the k8s namespaces)
@@ -133,18 +136,20 @@ export const NamespaceList: React.FunctionComponent = () => {
   return (
   return (
     <NamespaceListWrapper>
     <NamespaceListWrapper>
       <ControlRow>
       <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>
       </ControlRow>
       <NamespacesGrid>
       <NamespacesGrid>
         {sortedNamespaces.map((namespace) => {
         {sortedNamespaces.map((namespace) => {
@@ -165,7 +170,8 @@ export const NamespaceList: React.FunctionComponent = () => {
                   {namespace?.status?.phase}
                   {namespace?.status?.phase}
                 </Status>
                 </Status>
               </ContentContainer>
               </ContentContainer>
-              {isAvailableForDeletion(namespace?.metadata?.name) &&
+              {isAuthorized("namespace", "", ["get", "delete"]) &&
+                isAvailableForDeletion(namespace?.metadata?.name) &&
                 namespace?.status?.phase === "Active" && (
                 namespace?.status?.phase === "Active" && (
                   <OptionsDropdown>
                   <OptionsDropdown>
                     <DropdownOption onClick={() => onDelete(namespace)}>
                     <DropdownOption onClick={() => onDelete(namespace)}>

+ 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 ExpandedEnvGroup from "./ExpandedEnvGroup";
 import { RouteComponentProps, withRouter } from "react-router";
 import { RouteComponentProps, withRouter } from "react-router";
 import { pushQueryParams } from "shared/routing";
 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 = {
 type StateType = {
   expand: boolean;
   expand: boolean;
@@ -59,16 +61,22 @@ class EnvGroupDashboard extends Component<PropsType, StateType> {
         />
         />
       );
       );
     } else {
     } else {
+      const isAuthorizedToAdd = this.props.isAuthorized("env_group", "", [
+        "get",
+        "create",
+      ]);
       return (
       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>
             <SortFilterWrapper>
               <SortSelector
               <SortSelector
                 setSortType={(sortType) => this.setState({ sortType })}
                 setSortType={(sortType) => this.setState({ sortType })}
@@ -131,7 +139,7 @@ class EnvGroupDashboard extends Component<PropsType, StateType> {
 
 
 EnvGroupDashboard.contextType = Context;
 EnvGroupDashboard.contextType = Context;
 
 
-export default withRouter(EnvGroupDashboard);
+export default withRouter(withAuth(EnvGroupDashboard));
 
 
 const SortFilterWrapper = styled.div`
 const SortFilterWrapper = styled.div`
   width: 468px;
   width: 468px;
@@ -141,7 +149,12 @@ const SortFilterWrapper = styled.div`
 
 
 const ControlRow = styled.div`
 const ControlRow = styled.div`
   display: flex;
   display: flex;
-  justify-content: space-between;
+  justify-content: ${(props: { hasMultipleChilds: boolean }) => {
+    if (props.hasMultipleChilds) {
+      return "space-between";
+    }
+    return "flex-end";
+  }};
   align-items: center;
   align-items: center;
   margin-bottom: 35px;
   margin-bottom: 35px;
   padding-left: 0px;
   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 EnvGroupArray, { KeyValueType } from "./EnvGroupArray";
 import Heading from "components/values-form/Heading";
 import Heading from "components/values-form/Heading";
 import Helper from "components/values-form/Helper";
 import Helper from "components/values-form/Helper";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 
 
-type PropsType = {
+type PropsType = WithAuthProps & {
   namespace: string;
   namespace: string;
   envGroup: any;
   envGroup: any;
   currentCluster: ClusterType;
   currentCluster: ClusterType;
@@ -30,6 +31,7 @@ type StateType = {
   deleting: boolean;
   deleting: boolean;
   saveValuesStatus: string | null;
   saveValuesStatus: string | null;
   envVariables: KeyValueType[];
   envVariables: KeyValueType[];
+  tabOptions: { value: string; label: string }[];
 };
 };
 
 
 const tabOptions = [
 const tabOptions = [
@@ -37,7 +39,7 @@ const tabOptions = [
   { value: "settings", label: "Settings" },
   { value: "settings", label: "Settings" },
 ];
 ];
 
 
-export default class ExpandedEnvGroup extends Component<PropsType, StateType> {
+class ExpandedEnvGroup extends Component<PropsType, StateType> {
   state = {
   state = {
     loading: true,
     loading: true,
     currentTab: "environment",
     currentTab: "environment",
@@ -45,6 +47,10 @@ export default class ExpandedEnvGroup extends Component<PropsType, StateType> {
     deleting: false,
     deleting: false,
     saveValuesStatus: null as string | null,
     saveValuesStatus: null as string | null,
     envVariables: [] as KeyValueType[],
     envVariables: [] as KeyValueType[],
+    tabOptions: [
+      { value: "environment", label: "Environment Variables" },
+      { value: "settings", label: "Settings" },
+    ],
   };
   };
 
 
   componentDidMount() {
   componentDidMount() {
@@ -63,6 +69,21 @@ export default class ExpandedEnvGroup extends Component<PropsType, StateType> {
     }
     }
 
 
     this.setState({ envVariables });
     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 = () => {
   handleUpdateValues = () => {
@@ -170,32 +191,44 @@ export default class ExpandedEnvGroup extends Component<PropsType, StateType> {
                 setValues={(x: any) => this.setState({ envVariables: x })}
                 setValues={(x: any) => this.setState({ envVariables: x })}
                 fileUpload={true}
                 fileUpload={true}
                 secretOption={true}
                 secretOption={true}
+                disabled={
+                  !this.props.isAuthorized("env_group", "", [
+                    "get",
+                    "create",
+                    "delete",
+                    "update",
+                  ])
+                }
               />
               />
             </InnerWrapper>
             </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>
           </TabWrapper>
         );
         );
       default:
       default:
         return (
         return (
           <TabWrapper>
           <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>
           </TabWrapper>
         );
         );
     }
     }
@@ -292,7 +325,7 @@ export default class ExpandedEnvGroup extends Component<PropsType, StateType> {
           <TabRegion
           <TabRegion
             currentTab={this.state.currentTab}
             currentTab={this.state.currentTab}
             setCurrentTab={(x: string) => this.setState({ currentTab: x })}
             setCurrentTab={(x: string) => this.setState({ currentTab: x })}
-            options={tabOptions}
+            options={this.state.tabOptions}
             color={null}
             color={null}
           >
           >
             {this.renderTabContents()}
             {this.renderTabContents()}
@@ -305,6 +338,8 @@ export default class ExpandedEnvGroup extends Component<PropsType, StateType> {
 
 
 ExpandedEnvGroup.contextType = Context;
 ExpandedEnvGroup.contextType = Context;
 
 
+export default withAuth(ExpandedEnvGroup);
+
 const Button = styled.button`
 const Button = styled.button`
   height: 35px;
   height: 35px;
   font-size: 13px;
   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 StatusSection from "./status/StatusSection";
 import SettingsSection from "./SettingsSection";
 import SettingsSection from "./SettingsSection";
 import ChartList from "../chart/ChartList";
 import ChartList from "../chart/ChartList";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 
 
-type PropsType = {
+type PropsType = WithAuthProps & {
   namespace: string;
   namespace: string;
   currentChart: ChartType;
   currentChart: ChartType;
   currentCluster: ClusterType;
   currentCluster: ClusterType;
@@ -58,7 +59,7 @@ type StateType = {
   newestImage: string;
   newestImage: string;
 };
 };
 
 
-export default class ExpandedChart extends Component<PropsType, StateType> {
+class ExpandedChart extends Component<PropsType, StateType> {
   state = {
   state = {
     currentChart: this.props.currentChart,
     currentChart: this.props.currentChart,
     loading: true,
     loading: true,
@@ -444,7 +445,13 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
         );
         );
       case "values":
       case "values":
         return (
         return (
-          <ValuesYaml currentChart={chart} refreshChart={this.refreshChart} />
+          <ValuesYaml
+            currentChart={chart}
+            refreshChart={this.refreshChart}
+            disabled={
+              !this.props.isAuthorized("application", "", ["get", "update"])
+            }
+          />
         );
         );
       default:
       default:
     }
     }
@@ -474,7 +481,9 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
     }
     }
 
 
     // Settings tab is always last
     // 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
     // Filter tabs if previewing an old revision or updating the chart version
     if (this.state.isPreview || this.state.isUpdatingChart) {
     if (this.state.isPreview || this.state.isUpdatingChart) {
@@ -787,7 +796,10 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
           </HeaderWrapper>
           </HeaderWrapper>
           <BodyWrapper>
           <BodyWrapper>
             <FormWrapper
             <FormWrapper
-              isReadOnly={this.state.imageIsPlaceholder}
+              isReadOnly={
+                this.state.imageIsPlaceholder ||
+                !this.props.isAuthorized("application", "", ["get", "update"])
+              }
               formData={this.state.formData}
               formData={this.state.formData}
               tabOptions={this.state.tabOptions}
               tabOptions={this.state.tabOptions}
               isInModal={true}
               isInModal={true}
@@ -817,6 +829,8 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
 
 
 ExpandedChart.contextType = Context;
 ExpandedChart.contextType = Context;
 
 
+export default withAuth(ExpandedChart);
+
 const TextWrap = styled.div``;
 const TextWrap = styled.div``;
 
 
 const Header = 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 SettingsSection from "./SettingsSection";
 import FormWrapper from "components/values-form/FormWrapper";
 import FormWrapper from "components/values-form/FormWrapper";
 import { PlaceHolder } from "brace";
 import { PlaceHolder } from "brace";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 
 
-type PropsType = {
+type PropsType = WithAuthProps & {
   namespace: string;
   namespace: string;
   currentChart: ChartType;
   currentChart: ChartType;
   currentCluster: ClusterType;
   currentCluster: ClusterType;
@@ -43,7 +44,7 @@ type StateType = {
   valuesToOverride: any;
   valuesToOverride: any;
 };
 };
 
 
-export default class ExpandedJobChart extends Component<PropsType, StateType> {
+class ExpandedJobChart extends Component<PropsType, StateType> {
   state = {
   state = {
     currentChart: this.props.currentChart,
     currentChart: this.props.currentChart,
     imageIsPlaceholder: false,
     imageIsPlaceholder: false,
@@ -457,15 +458,17 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
         );
         );
       case "settings":
       case "settings":
         return (
         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:
       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
     // Filter tabs if previewing an old revision
     this.setState({ tabOptions });
     this.setState({ tabOptions });
@@ -612,7 +617,10 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
 
 
           <BodyWrapper>
           <BodyWrapper>
             <FormWrapper
             <FormWrapper
-              isReadOnly={this.state.imageIsPlaceholder}
+              isReadOnly={
+                this.state.imageIsPlaceholder ||
+                !this.props.isAuthorized("job", "", ["get", "update"])
+              }
               valuesToOverride={this.state.valuesToOverride}
               valuesToOverride={this.state.valuesToOverride}
               clearValuesToOverride={() =>
               clearValuesToOverride={() =>
                 this.setState({ valuesToOverride: {} })
                 this.setState({ valuesToOverride: {} })
@@ -637,6 +645,8 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
 
 
 ExpandedJobChart.contextType = Context;
 ExpandedJobChart.contextType = Context;
 
 
+export default withAuth(ExpandedJobChart);
+
 const TextWrap = styled.div``;
 const TextWrap = styled.div``;
 
 
 const Header = 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 { ChartType, StorageType } from "shared/types";
 
 
 import ConfirmOverlay from "components/ConfirmOverlay";
 import ConfirmOverlay from "components/ConfirmOverlay";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 
 
-type PropsType = {
+type PropsType = WithAuthProps & {
   showRevisions: boolean;
   showRevisions: boolean;
   toggleShowRevisions: () => void;
   toggleShowRevisions: () => void;
   chart: ChartType;
   chart: ChartType;
@@ -31,7 +32,7 @@ type StateType = {
 };
 };
 
 
 // TODO: handle refresh when new revision is generated from an old revision
 // 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 = {
   state = {
     revisions: [] as ChartType[],
     revisions: [] as ChartType[],
     rollbackRevision: null as number | null,
     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>v{revision.chart.metadata.version}</Td>
           <Td>
           <Td>
             <RollbackButton
             <RollbackButton
-              disabled={isCurrent}
+              disabled={
+                isCurrent ||
+                !this.props.isAuthorized("application", "", ["get", "update"])
+              }
               onClick={() =>
               onClick={() =>
                 this.setState({ rollbackRevision: revision.version })
                 this.setState({ rollbackRevision: revision.version })
               }
               }
@@ -341,6 +345,8 @@ export default class RevisionSection extends Component<PropsType, StateType> {
 
 
 RevisionSection.contextType = Context;
 RevisionSection.contextType = Context;
 
 
+export default withAuth(RevisionSection);
+
 const TableWrapper = styled.div`
 const TableWrapper = styled.div`
   padding-bottom: 20px;
   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 = {
 type PropsType = {
   currentChart: ChartType;
   currentChart: ChartType;
   refreshChart: () => void;
   refreshChart: () => void;
+  disabled?: boolean;
 };
 };
 
 
 type StateType = {
 type StateType = {
@@ -89,14 +90,17 @@ export default class ValuesYaml extends Component<PropsType, StateType> {
           <YamlEditor
           <YamlEditor
             value={this.state.values}
             value={this.state.values}
             onChange={(e: any) => this.setState({ values: e })}
             onChange={(e: any) => this.setState({ values: e })}
+            readOnly={this.props.disabled}
           />
           />
         </Wrapper>
         </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>
       </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 { Context } from "shared/Context";
 import JobResource from "./JobResource";
 import JobResource from "./JobResource";
 import ConfirmOverlay from "components/ConfirmOverlay";
 import ConfirmOverlay from "components/ConfirmOverlay";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 
 
-type PropsType = {
+type PropsType = WithAuthProps & {
   jobs: any[];
   jobs: any[];
   setJobs: (job: any) => void;
   setJobs: (job: any) => void;
 };
 };
@@ -17,7 +18,7 @@ type StateType = {
   deletionJob: any;
   deletionJob: any;
 };
 };
 
 
-export default class JobList extends Component<PropsType, StateType> {
+class JobList extends Component<PropsType, StateType> {
   state = {
   state = {
     deletionCandidate: null as any,
     deletionCandidate: null as any,
     deletionJob: null as any,
     deletionJob: null as any,
@@ -43,6 +44,13 @@ export default class JobList extends Component<PropsType, StateType> {
                 deleting={
                 deleting={
                   this.state.deletionJob?.metadata?.name == job.metadata?.name
                   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;
 JobList.contextType = Context;
 
 
+export default withAuth(JobList);
+
 const Placeholder = styled.div`
 const Placeholder = styled.div`
   width: 100%;
   width: 100%;
   height: 100%;
   height: 100%;

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

@@ -14,6 +14,7 @@ type PropsType = {
   job: any;
   job: any;
   handleDelete: () => void;
   handleDelete: () => void;
   deleting: boolean;
   deleting: boolean;
+  readOnly?: boolean;
 };
 };
 
 
 type StateType = {
 type StateType = {
@@ -243,6 +244,10 @@ export default class JobResource extends Component<PropsType, StateType> {
   };
   };
 
 
   renderStopButton = () => {
   renderStopButton = () => {
+    if (this.props.readOnly) {
+      return null;
+    }
+
     if (!this.props.job.status?.succeeded && !this.props.job.status?.failed) {
     if (!this.props.job.status?.succeeded && !this.props.job.status?.failed) {
       // look for a sidecar container
       // look for a sidecar container
       if (this.props.job?.spec?.template?.spec?.containers.length == 2) {
       if (this.props.job?.spec?.template?.spec?.containers.length == 2) {
@@ -281,15 +286,17 @@ export default class JobResource extends Component<PropsType, StateType> {
               {this.renderStatus()}
               {this.renderStatus()}
               <MaterialIconTray disabled={false}>
               <MaterialIconTray disabled={false}>
                 {this.renderStopButton()}
                 {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}>
                 <i className="material-icons" onClick={this.expandJob}>
                   {this.state.expanded ? "expand_less" : "expand_more"}
                   {this.state.expanded ? "expand_less" : "expand_more"}
                 </i>
                 </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) {
     if (prevState.currentTab !== this.state.currentTab) {
       let { selectedPod } = this.props;
       let { selectedPod } = this.props;
 
 
+      this.ws?.close();
+
       this.setState({ logs: [] });
       this.setState({ logs: [] });
 
 
       if (this.state.currentTab == "Application") {
       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 FormDebugger from "components/values-form/FormDebugger";
 
 
 import { pushQueryParams, pushFiltered } from "shared/routing";
 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
 // TODO: rethink this list, should be coupled with tabOptions
 const tabOptionStrings = ["overview", "create-cluster", "provisioner"];
 const tabOptionStrings = ["overview", "create-cluster", "provisioner"];
@@ -126,11 +128,13 @@ class Dashboard extends Component<PropsType, StateType> {
     let { currentProject, capabilities } = this.context;
     let { currentProject, capabilities } = this.context;
     let { onShowProjectSettings } = this;
     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) {
     if (!capabilities?.provisioner) {
       tabOptions = [{ label: "Project Overview", value: "overview" }];
       tabOptions = [{ label: "Project Overview", value: "overview" }];
@@ -154,9 +158,9 @@ class Dashboard extends Component<PropsType, StateType> {
                     </Overlay>
                     </Overlay>
                   </DashboardIcon>
                   </DashboardIcon>
                   <Title>{currentProject && currentProject.name}</Title>
                   <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;
                     return obj.user_id === this.context.user.userId;
-                  })[0].kind === "admin" && (
+                  })[0].kind === "admin" || (
                     <i
                     <i
                       className="material-icons"
                       className="material-icons"
                       onClick={onShowProjectSettings}
                       onClick={onShowProjectSettings}
@@ -195,7 +199,7 @@ class Dashboard extends Component<PropsType, StateType> {
 
 
 Dashboard.contextType = Context;
 Dashboard.contextType = Context;
 
 
-export default withRouter(Dashboard);
+export default withRouter(withAuth(Dashboard));
 
 
 const DashboardWrapper = styled.div`
 const DashboardWrapper = styled.div`
   padding-bottom: 100px;
   padding-bottom: 100px;
@@ -318,8 +322,8 @@ const TitleSection = styled.div`
   > i {
   > i {
     margin-left: 10px;
     margin-left: 10px;
     cursor: pointer;
     cursor: pointer;
-    font-size 18px;
-    color: #858FAAaa;
+    font-size: 18px;
+    color: #858faaaa;
     padding: 5px;
     padding: 5px;
     border-radius: 100px;
     border-radius: 100px;
     :hover {
     :hover {

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

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

@@ -79,7 +79,7 @@ const AccountSettingsModal = () => {
           {accessData.has_access ? (
           {accessData.has_access ? (
             <Placeholder>
             <Placeholder>
               <User>
               <User>
-                You are currently authorized as <B>{accessData.username || "jusrhee"}</B> and have access to:
+                You are currently authorized as <B>{accessData.username}</B> and have access to:
               </User>
               </User>
               {!accessData.accounts || accessData.accounts?.length == 0 ? (
               {!accessData.accounts || accessData.accounts?.length == 0 ? (
                 <ListWrapper>
                 <ListWrapper>

+ 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" : "collaborator"} {user?.email}
+      </ModalTitle>
+      <Subtitle>Select the new role for the 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;
+`;

+ 33 - 3
dashboard/src/main/home/navbar/Navbar.tsx

@@ -5,19 +5,24 @@ import api from "shared/api";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
 
 
 import Feedback from "./Feedback";
 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;
   logOut: () => void;
   currentView: string;
   currentView: string;
 };
 };
 
 
 type StateType = {
 type StateType = {
   showDropdown: boolean;
   showDropdown: boolean;
+  currentPolicy: string;
 };
 };
 
 
-export default class Navbar extends Component<PropsType, StateType> {
+class Navbar extends Component<PropsType, StateType> {
   state = {
   state = {
     showDropdown: false,
     showDropdown: false,
+    currentPolicy: "admin",
   };
   };
 
 
   renderSettingsDropdown = () => {
   renderSettingsDropdown = () => {
@@ -38,7 +43,7 @@ export default class Navbar extends Component<PropsType, StateType> {
             >
             >
               <SettingsIcon>
               <SettingsIcon>
                 <i className="material-icons">settings</i>
                 <i className="material-icons">settings</i>
-              </SettingsIcon> 
+              </SettingsIcon>
               Account Settings
               Account Settings
             </UserDropdownButton>
             </UserDropdownButton>
             <UserDropdownButton onClick={this.props.logOut}>
             <UserDropdownButton onClick={this.props.logOut}>
@@ -59,6 +64,22 @@ export default class Navbar extends Component<PropsType, StateType> {
   render() {
   render() {
     return (
     return (
       <StyledNavbar>
       <StyledNavbar>
+        <AuthContext.Consumer>
+          {(value) => (
+            <PolicySelector
+              value={this.state.currentPolicy}
+              onChange={(e) => {
+                value.setPolicy(e.target.value as any);
+                this.setState({ currentPolicy: e.target.value as string });
+              }}
+            >
+              <MenuItem value={"admin"}>Admin</MenuItem>
+              <MenuItem value={"dev"}>Dev</MenuItem>
+              <MenuItem value={"viewer"}>Viewer</MenuItem>
+            </PolicySelector>
+          )}
+        </AuthContext.Consumer>
+
         {this.renderFeedbackButton()}
         {this.renderFeedbackButton()}
         <NavButton
         <NavButton
           selected={this.state.showDropdown}
           selected={this.state.showDropdown}
@@ -76,6 +97,8 @@ export default class Navbar extends Component<PropsType, StateType> {
 
 
 Navbar.contextType = Context;
 Navbar.contextType = Context;
 
 
+export default withAuth(Navbar);
+
 const SettingsIcon = styled.div`
 const SettingsIcon = styled.div`
   > i {
   > i {
     background: none;
     background: none;
@@ -96,6 +119,13 @@ const I = styled.i`
   margin-right: 7px;
   margin-right: 7px;
 `;
 `;
 
 
+const PolicySelector = styled(Select)`
+  height: 30px;
+  width: 100px;
+  margin-right: 15px;
+  color: white !important;
+`;
+
 const CloseOverlay = styled.div`
 const CloseOverlay = styled.div`
   position: fixed;
   position: fixed;
   width: 100vw;
   width: 100vw;

+ 173 - 26
dashboard/src/main/home/project-settings/InviteList.tsx

@@ -18,44 +18,107 @@ import Heading from "components/values-form/Heading";
 import CopyToClipboard from "components/CopyToClipboard";
 import CopyToClipboard from "components/CopyToClipboard";
 import { Column } from "react-table";
 import { Column } from "react-table";
 import Table from "components/Table";
 import Table from "components/Table";
+import RadioSelector from "components/RadioSelector";
 
 
 type Props = {};
 type Props = {};
 
 
+export type Collaborator = {
+  id: string;
+  user_id: string;
+  project_id: string;
+  email: string;
+  kind: string;
+};
+
 const InvitePage: React.FunctionComponent<Props> = ({}) => {
 const InvitePage: React.FunctionComponent<Props> = ({}) => {
-  const { currentProject } = useContext(Context);
+  const { currentProject, setCurrentModal, user } = useContext(Context);
   const [isLoading, setIsLoading] = useState(true);
   const [isLoading, setIsLoading] = useState(true);
   const [invites, setInvites] = useState<Array<InviteType>>([]);
   const [invites, setInvites] = useState<Array<InviteType>>([]);
   const [email, setEmail] = useState("");
   const [email, setEmail] = useState("");
+  const [role, setRole] = useState("developer");
+  const [roleList, setRoleList] = useState([]);
   const [isInvalidEmail, setIsInvalidEmail] = useState(false);
   const [isInvalidEmail, setIsInvalidEmail] = useState(false);
   const [isHTTPS] = useState(() => window.location.protocol === "https:");
   const [isHTTPS] = useState(() => window.location.protocol === "https:");
 
 
   useEffect(() => {
   useEffect(() => {
-    getInviteData();
-  }, []);
+    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);
+  };
 
 
-  const getInviteData = () => {
+  const getData = async () => {
     setIsLoading(true);
     setIsLoading(true);
-
-    api
-      .getInvites(
+    let invites = [];
+    try {
+      const response = await api.getInvites(
         "<token>",
         "<token>",
         {},
         {},
         {
         {
           id: currentProject.id,
           id: currentProject.id,
         }
         }
-      )
-      .then((res) => {
-        setInvites(res.data);
-        setIsLoading(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);
+  };
+
+  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: "",
+        }))
+    );
   };
   };
 
 
   const createInvite = () => {
   const createInvite = () => {
     api
     api
-      .createInvite("<token>", { email }, { id: currentProject.id })
+      .createInvite("<token>", { email, kind: role }, { id: currentProject.id })
       .then(() => {
       .then(() => {
-        getInviteData();
+        getData();
         setEmail("");
         setEmail("");
       })
       })
       .catch((err) => console.log(err));
       .catch((err) => console.log(err));
@@ -71,15 +134,19 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
           invId: inviteId,
           invId: inviteId,
         }
         }
       )
       )
-      .then(getInviteData)
+      .then(getData)
       .catch((err) => console.log(err));
       .catch((err) => console.log(err));
   };
   };
 
 
-  const replaceInvite = (inviteEmail: string, inviteId: number) => {
+  const replaceInvite = (
+    inviteEmail: string,
+    inviteId: number,
+    kind: string
+  ) => {
     api
     api
       .createInvite(
       .createInvite(
         "<token>",
         "<token>",
-        { email: inviteEmail },
+        { email: inviteEmail, kind },
         { id: currentProject.id }
         { id: currentProject.id }
       )
       )
       .then(() =>
       .then(() =>
@@ -92,7 +159,7 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
           }
           }
         )
         )
       )
       )
-      .then(getInviteData)
+      .then(getData)
       .catch((err) => console.log(err));
       .catch((err) => console.log(err));
   };
   };
 
 
@@ -107,12 +174,37 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
     createInvite();
     createInvite();
   };
   };
 
 
+  const openEditModal = (user: any) => {
+    if (setCurrentModal) {
+      console.log(user);
+      setCurrentModal("EditInviteOrCollaboratorModal", {
+        user,
+        isInvite: user.status !== "accepted",
+        refetchCallerData: getData,
+      });
+    }
+  };
+
+  const removeCollaborator = (user_id: number) => {
+    try {
+      api.removeCollaborator(
+        "<token>",
+        {},
+        { project_id: currentProject.id, user_id }
+      );
+      getData();
+    } catch (error) {
+      console.log(error);
+    }
+  };
+
   const columns = useMemo<
   const columns = useMemo<
     Column<{
     Column<{
       email: string;
       email: string;
       id: number;
       id: number;
       status: string;
       status: string;
       invite_link: string;
       invite_link: string;
+      kind: string;
     }>[]
     }>[]
   >(
   >(
     () => [
     () => [
@@ -120,6 +212,15 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
         Header: "Mail address",
         Header: "Mail address",
         accessor: "email",
         accessor: "email",
       },
       },
+      {
+        Header: "Role",
+        accessor: "kind",
+        Cell: ({ row }) => {
+          return (
+            <Status status={"accepted"}>{row.values.kind || "Admin"}</Status>
+          );
+        },
+      },
       {
       {
         Header: "Status",
         Header: "Status",
         accessor: "status",
         accessor: "status",
@@ -136,7 +237,13 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
           if (row.values.status === "expired") {
           if (row.values.status === "expired") {
             return (
             return (
               <NewLinkButton
               <NewLinkButton
-                onClick={() => replaceInvite(row.values.email, row.values.id)}
+                onClick={() =>
+                  replaceInvite(
+                    row.values.email,
+                    row.values.id,
+                    row.values.kind
+                  )
+                }
               >
               >
                 <u>Generate a new link</u>
                 <u>Generate a new link</u>
               </NewLinkButton>
               </NewLinkButton>
@@ -157,14 +264,37 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
         },
         },
       },
       },
       {
       {
-        accessor: "id",
-        Cell: ({ row }) => {
+        id: "edit_action",
+        Cell: ({ row }: any) => {
+          return (
+            <CopyButton
+              invis={row.original.currentUser}
+              onClick={() => openEditModal(row.original)}
+            >
+              Edit
+            </CopyButton>
+          );
+        },
+      },
+      {
+        id: "remove_invite_action",
+        Cell: ({ row }: any) => {
           if (row.values.status === "accepted") {
           if (row.values.status === "accepted") {
-            return <CopyButton invis={true}>Remove</CopyButton>;
+            return (
+              <CopyButton
+                invis={row.original.currentUser}
+                onClick={() => removeCollaborator(row.original.id)}
+              >
+                Remove
+              </CopyButton>
+            );
           }
           }
           return (
           return (
             <>
             <>
-              <CopyButton onClick={() => deleteInvite(row.values.id)}>
+              <CopyButton
+                invis={row.original.currentUser}
+                onClick={() => deleteInvite(row.original.id)}
+              >
                 Delete Invite
                 Delete Invite
               </CopyButton>
               </CopyButton>
             </>
             </>
@@ -187,10 +317,13 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
 
 
     const mappedInviteList = inviteList.map(
     const mappedInviteList = inviteList.map(
       ({ accepted, expired, token, ...rest }) => {
       ({ accepted, expired, token, ...rest }) => {
+        const currentUser: boolean = user.email === rest.email;
+        console.log(currentUser, user, rest);
         if (accepted) {
         if (accepted) {
           return {
           return {
             status: "accepted",
             status: "accepted",
             invite_link: buildInviteLink(token),
             invite_link: buildInviteLink(token),
+            currentUser,
             ...rest,
             ...rest,
           };
           };
         }
         }
@@ -199,6 +332,7 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
           return {
           return {
             status: "expired",
             status: "expired",
             invite_link: buildInviteLink(token),
             invite_link: buildInviteLink(token),
+            currentUser,
             ...rest,
             ...rest,
           };
           };
         }
         }
@@ -206,18 +340,19 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
         return {
         return {
           status: "pending",
           status: "pending",
           invite_link: buildInviteLink(token),
           invite_link: buildInviteLink(token),
+          currentUser,
           ...rest,
           ...rest,
         };
         };
       }
       }
     );
     );
 
 
     return mappedInviteList || [];
     return mappedInviteList || [];
-  }, [invites, currentProject?.id, window?.location?.host, isHTTPS]);
+  }, [invites, currentProject?.id, window?.location?.host, isHTTPS, user?.id]);
 
 
   return (
   return (
     <>
     <>
       <Heading isAtTop={true}>Share Project</Heading>
       <Heading isAtTop={true}>Share Project</Heading>
-      <Helper>Generate a project invite for another admin user.</Helper>
+      <Helper>Generate a project invite for another user.</Helper>
       <InputRowWrapper>
       <InputRowWrapper>
         <InputRow
         <InputRow
           value={email}
           value={email}
@@ -227,6 +362,14 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
           placeholder="ex: mrp@getporter.dev"
           placeholder="ex: mrp@getporter.dev"
         />
         />
       </InputRowWrapper>
       </InputRowWrapper>
+      <Helper>Select the role the user will have.</Helper>
+      <RoleSelectorWrapper>
+        <RadioSelector
+          selected={role}
+          setSelected={setRole}
+          options={roleList}
+        />
+      </RoleSelectorWrapper>
       <ButtonWrapper>
       <ButtonWrapper>
         <InviteButton disabled={false} onClick={() => validateEmail()}>
         <InviteButton disabled={false} onClick={() => validateEmail()}>
           Create Invite
           Create Invite
@@ -259,6 +402,10 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
 
 
 export default InvitePage;
 export default InvitePage;
 
 
+const RoleSelectorWrapper = styled.div`
+  font-size: 14px;
+`;
+
 const Placeholder = styled.div`
 const Placeholder = styled.div`
   width: 100%;
   width: 100%;
   height: 200px;
   height: 200px;

+ 28 - 21
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -16,14 +16,16 @@ import ProjectSectionContainer from "./ProjectSectionContainer";
 import loading from "assets/loading.gif";
 import loading from "assets/loading.gif";
 import { RouteComponentProps, withRouter } from "react-router";
 import { RouteComponentProps, withRouter } from "react-router";
 import { pushFiltered, pushQueryParams } from "shared/routing";
 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 = {
 type StateType = {
   showSidebar: boolean;
   showSidebar: boolean;
@@ -231,18 +233,23 @@ class Sidebar extends Component<PropsType, StateType> {
             <Img src={rocket} />
             <Img src={rocket} />
             Launch
             Launch
           </NavButton>
           </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
             <NavButton
               onClick={() =>
               onClick={() =>
                 pushFiltered(this.props, "/project-settings", ["project_id"])
                 pushFiltered(this.props, "/project-settings", ["project_id"])
@@ -313,7 +320,7 @@ class Sidebar extends Component<PropsType, StateType> {
 
 
 Sidebar.contextType = Context;
 Sidebar.contextType = Context;
 
 
-export default withRouter(Sidebar);
+export default withRouter(withAuth(Sidebar));
 
 
 const BranchPad = styled.div`
 const BranchPad = styled.div`
   width: 20px;
   width: 20px;

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

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

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

@@ -150,6 +150,7 @@ const createGKE = baseApi<
 const createInvite = baseApi<
 const createInvite = baseApi<
   {
   {
     email: string;
     email: string;
+    kind: string;
   },
   },
   {
   {
     id: number;
     id: number;
@@ -921,6 +922,38 @@ const stopJob = baseApi<
   return `/api/projects/${id}/k8s/jobs/${namespace}/${name}/stop?cluster_id=${cluster_id}`;
   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}`
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
 export default {
   checkAuth,
   checkAuth,
@@ -1014,4 +1047,9 @@ export default {
   upgradeChartValues,
   upgradeChartValues,
   deleteJob,
   deleteJob,
   stopJob,
   stopJob,
+  updateInvite,
+  getAvailableRoles,
+  getCollaborators,
+  updateCollaborator,
+  removeCollaborator,
 };
 };

+ 58 - 15
dashboard/src/shared/auth/AuthContext.tsx

@@ -1,14 +1,17 @@
 import React, { useContext, useEffect, useState } from "react";
 import React, { useContext, useEffect, useState } from "react";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
 import {
 import {
-  ADMIN_POLICY_MOCK,
+  VIEWER_POLICY_MOCK,
   POLICY_HIERARCHY_TREE,
   POLICY_HIERARCHY_TREE,
   populatePolicy,
   populatePolicy,
+  DEV_POLICY_MOCK,
+  ADMIN_POLICY_MOCK,
 } from "./authorization-helpers";
 } from "./authorization-helpers";
 import { PolicyDocType } from "./types";
 import { PolicyDocType } from "./types";
 
 
 type AuthContext = {
 type AuthContext = {
   currentPolicy: PolicyDocType;
   currentPolicy: PolicyDocType;
+  setPolicy: (pol: "admin" | "dev" | "viewer") => void;
 };
 };
 
 
 export const AuthContext = React.createContext<AuthContext>({} as AuthContext);
 export const AuthContext = React.createContext<AuthContext>({} as AuthContext);
@@ -17,24 +20,64 @@ const AuthProvider: React.FC = ({ children }) => {
   const { user } = useContext(Context);
   const { user } = useContext(Context);
   const [currentPolicy, setCurrentPolicy] = useState(null);
   const [currentPolicy, setCurrentPolicy] = useState(null);
 
 
+  // useEffect(() => {
+  //   if (!user) {
+  //     setCurrentPolicy(null);
+  //   } else {
+  //     // TODO: GET POLICY FROM ENDPOINT
+  //     setCurrentPolicy(
+  //       populatePolicy(
+  //         VIEWER_POLICY_MOCK,
+  //         POLICY_HIERARCHY_TREE,
+  //         VIEWER_POLICY_MOCK.scope,
+  //         VIEWER_POLICY_MOCK.verbs
+  //       )
+  //     );
+  //   }
+  // }, [user]);
+
   useEffect(() => {
   useEffect(() => {
-    if (!user) {
-      setCurrentPolicy(null);
-    } else {
-      // TODO: GET POLICY FROM ENDPOINT
-      setCurrentPolicy(
-        populatePolicy(
-          ADMIN_POLICY_MOCK,
-          POLICY_HIERARCHY_TREE,
-          ADMIN_POLICY_MOCK.scope,
-          ADMIN_POLICY_MOCK.verbs
-        )
-      );
+    setPolicy("admin");
+  }, []);
+
+  // This is just for test case, should be deleted before merged with master
+  const setPolicy = (pol: "admin" | "dev" | "viewer") => {
+    switch (pol) {
+      case "viewer":
+        setCurrentPolicy(
+          populatePolicy(
+            VIEWER_POLICY_MOCK,
+            POLICY_HIERARCHY_TREE,
+            VIEWER_POLICY_MOCK.scope,
+            VIEWER_POLICY_MOCK.verbs
+          )
+        );
+        break;
+      case "dev":
+        setCurrentPolicy(
+          populatePolicy(
+            DEV_POLICY_MOCK,
+            POLICY_HIERARCHY_TREE,
+            DEV_POLICY_MOCK.scope,
+            DEV_POLICY_MOCK.verbs
+          )
+        );
+        break;
+      default:
+        setCurrentPolicy(
+          populatePolicy(
+            ADMIN_POLICY_MOCK,
+            POLICY_HIERARCHY_TREE,
+            ADMIN_POLICY_MOCK.scope,
+            ADMIN_POLICY_MOCK.verbs
+          )
+        );
+        break;
     }
     }
-  }, [user]);
+  };
 
 
   return (
   return (
-    <AuthContext.Provider value={{ currentPolicy }}>
+    <AuthContext.Provider value={{ currentPolicy, setPolicy }}>
       {children}
       {children}
     </AuthContext.Provider>
     </AuthContext.Provider>
   );
   );

+ 37 - 2
dashboard/src/shared/auth/AuthorizationHoc.tsx

@@ -1,10 +1,10 @@
-import React from "react";
+import React, { useCallback } from "react";
 import { useContext } from "react";
 import { useContext } from "react";
 import { AuthContext } from "./AuthContext";
 import { AuthContext } from "./AuthContext";
 import { isAuthorized } from "./authorization-helpers";
 import { isAuthorized } from "./authorization-helpers";
 import { ScopeType, Verbs } from "./types";
 import { ScopeType, Verbs } from "./types";
 
 
-export const withAuth = <ComponentProps extends object>(
+export const GuardedComponent = <ComponentProps extends object>(
   scope: ScopeType,
   scope: ScopeType,
   resource: string,
   resource: string,
   verb: Verbs | Array<Verbs>
   verb: Verbs | Array<Verbs>
@@ -17,3 +17,38 @@ export const withAuth = <ComponentProps extends object>(
 
 
   return null;
   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;
+}

+ 11 - 9
dashboard/src/shared/auth/RouteGuard.tsx

@@ -1,3 +1,4 @@
+import UnauthorizedPage from "components/UnauthorizedPage";
 import React, { useMemo, useContext } from "react";
 import React, { useMemo, useContext } from "react";
 import { Redirect, Route, RouteProps } from "react-router";
 import { Redirect, Route, RouteProps } from "react-router";
 import { AuthContext } from "./AuthContext";
 import { AuthContext } from "./AuthContext";
@@ -15,6 +16,7 @@ const GuardedRoute: React.FC<RouteProps & GuardedRouteProps> = ({
   scope,
   scope,
   resource,
   resource,
   verb,
   verb,
+  children,
   ...rest
   ...rest
 }) => {
 }) => {
   const { currentPolicy } = useContext(AuthContext);
   const { currentPolicy } = useContext(AuthContext);
@@ -22,14 +24,14 @@ const GuardedRoute: React.FC<RouteProps & GuardedRouteProps> = ({
     return isAuthorized(currentPolicy, scope, resource, verb);
     return isAuthorized(currentPolicy, scope, resource, verb);
   }, [currentPolicy, scope, resource, verb]);
   }, [currentPolicy, scope, resource, verb]);
 
 
-  return (
-    <Route
-      {...rest}
-      render={(props) =>
-        auth === true ? <Component {...props} /> : <Redirect to="/" />
-      }
-    />
-  );
+  const render = (props: any) => {
+    if (auth) {
+      return children || <Component {...props} />;
+    }
+    return <UnauthorizedPage />;
+  };
+
+  return <Route {...rest} render={render} />;
 };
 };
 
 
 export const fakeGuardedRoute = <ComponentProps extends object>(
 export const fakeGuardedRoute = <ComponentProps extends object>(
@@ -43,7 +45,7 @@ export const fakeGuardedRoute = <ComponentProps extends object>(
     return <Component {...props} />;
     return <Component {...props} />;
   }
   }
 
 
-  return <Redirect to="/" />;
+  return <UnauthorizedPage />;
 };
 };
 
 
 export default GuardedRoute;
 export default GuardedRoute;

+ 58 - 18
dashboard/src/shared/auth/authorization-helpers.ts

@@ -3,19 +3,57 @@ import { HIERARCHY_TREE, PolicyDocType, ScopeType, Verbs } from "./types";
 export const ADMIN_POLICY_MOCK: PolicyDocType = {
 export const ADMIN_POLICY_MOCK: PolicyDocType = {
   scope: "project",
   scope: "project",
   verbs: ["get", "list", "create", "update", "delete"],
   verbs: ["get", "list", "create", "update", "delete"],
+};
+
+export const DEV_POLICY_MOCK: PolicyDocType = {
+  scope: "project",
+  verbs: ["get", "list", "create", "update", "delete"],
   resources: [],
   resources: [],
   children: {
   children: {
     settings: {
     settings: {
       scope: "settings",
       scope: "settings",
+      verbs: ["get", "list"],
+      resources: [],
+    },
+  },
+};
+
+export const VIEWER_POLICY_MOCK: PolicyDocType = {
+  scope: "project",
+  verbs: ["get", "list"],
+  resources: [],
+  children: {
+    integrations: {
+      scope: "integrations",
       verbs: [],
       verbs: [],
+      resources: [],
+    },
+    settings: {
+      scope: "settings",
+      verbs: [],
+      resources: [],
+    },
+  },
+};
+
+export const POLICY_HIERARCHY_TREE: HIERARCHY_TREE = {
+  project: {
+    cluster: {
+      namespace: {
+        application: {},
+        job: {},
+        env_group: {},
+      },
     },
     },
-  } as Record<ScopeType, PolicyDocType>,
+    settings: {},
+    integrations: {},
+  },
 };
 };
 
 
 export const isAuthorized = (
 export const isAuthorized = (
   policy: PolicyDocType,
   policy: PolicyDocType,
   scope: string,
   scope: string,
-  resource: string,
+  resource: string | Array<string>,
   verb: Verbs | Array<Verbs>
   verb: Verbs | Array<Verbs>
 ): boolean => {
 ): boolean => {
   if (!policy) {
   if (!policy) {
@@ -23,11 +61,21 @@ export const isAuthorized = (
   }
   }
 
 
   if (policy?.scope === scope) {
   if (policy?.scope === scope) {
-    return (policy.resources.length === 0 ||
-      policy.resources.includes(resource)) &&
-      typeof verb === "string"
-      ? policy.verbs.includes(verb)
-      : (verb as Array<Verbs>).every((v) => policy.verbs.includes(v));
+    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 {
   } else {
     const isValid =
     const isValid =
       policy?.children &&
       policy?.children &&
@@ -43,17 +91,6 @@ export const isAuthorized = (
   }
   }
 };
 };
 
 
-export const POLICY_HIERARCHY_TREE: HIERARCHY_TREE = {
-  project: {
-    cluster: {
-      namespace: {
-        application: {},
-      },
-    },
-    settings: {},
-  },
-};
-
 export const populatePolicy = (
 export const populatePolicy = (
   currPolicy: PolicyDocType,
   currPolicy: PolicyDocType,
   tree: HIERARCHY_TREE,
   tree: HIERARCHY_TREE,
@@ -64,6 +101,9 @@ export const populatePolicy = (
 
 
   const treeKeys = Object.keys(currTree) as Array<ScopeType>;
   const treeKeys = Object.keys(currTree) as Array<ScopeType>;
 
 
+  currPolicy.children = currPolicy?.children || {};
+  currPolicy.resources = currPolicy?.resources || [];
+
   for (const child of treeKeys) {
   for (const child of treeKeys) {
     let childPolicy = currPolicy?.children && currPolicy?.children[child];
     let childPolicy = currPolicy?.children && currPolicy?.children[child];
     if (!childPolicy) {
     if (!childPolicy) {

+ 5 - 2
dashboard/src/shared/auth/types.ts

@@ -3,14 +3,17 @@ export type ScopeType =
   | "cluster"
   | "cluster"
   | "settings"
   | "settings"
   | "namespace"
   | "namespace"
-  | "application";
+  | "application"
+  | "env_group"
+  | "job"
+  | "integrations";
 
 
 export type Verbs = "get" | "list" | "create" | "update" | "delete";
 export type Verbs = "get" | "list" | "create" | "update" | "delete";
 
 
 export interface PolicyDocType {
 export interface PolicyDocType {
   scope: ScopeType;
   scope: ScopeType;
   verbs: Array<Verbs>;
   verbs: Array<Verbs>;
-  resources: string[];
+  resources?: string[];
   children?: Partial<Record<ScopeType, PolicyDocType>>;
   children?: Partial<Record<ScopeType, PolicyDocType>>;
 }
 }
 
 

+ 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;

+ 1 - 0
docker-compose.dev.yaml

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

+ 1 - 0
docker/Dockerfile

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

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

@@ -0,0 +1,17 @@
+
+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. 
+
+TODO: ADD SCREENSHOT
+
+TODO: ADD NOTE ABOUT LOGGING IN AND ACCEPTING INVITE
+
+# Changing Collaborator Permissions
+
+# Removing Collaborators

+ 17 - 9
docs/guides/linking-github-account.md

@@ -2,14 +2,22 @@
 
 
 > 🚧
 > 🚧
 >
 >
-> **Note:** Porter currently uses an 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 the GitHub Application.
+> **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.
 
 
-Porter uses a GitHub application to authorize and gain access to your GitHub repositories. 
-In order to see your repositories on the web application, you first need to authorize the application through oauth. 
-You can do this by clicking "Account Settings" on the user dropdown on the top right and then authorizing the GitHub application through the link in the modal that appears.
-After you authorize the application, you can open the modal again to install your application in either your account or any organization you are part of. 
-Note that in organizations Porter will have 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 organization repositories is sufficient.
+## 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.

+ 4 - 3
internal/config/config.go

@@ -41,9 +41,10 @@ type ServerConf struct {
 	GithubClientSecret string `env:"GITHUB_CLIENT_SECRET"`
 	GithubClientSecret string `env:"GITHUB_CLIENT_SECRET"`
 	GithubLoginEnabled bool   `env:"GITHUB_LOGIN_ENABLED,default=true"`
 	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"`
+	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"`
 	GoogleClientID         string `env:"GOOGLE_CLIENT_ID"`
 	GoogleClientSecret     string `env:"GOOGLE_CLIENT_SECRET"`
 	GoogleClientSecret     string `env:"GOOGLE_CLIENT_SECRET"`

+ 2 - 0
internal/forms/invite.go

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

+ 3 - 16
internal/forms/project.go

@@ -3,7 +3,6 @@ package forms
 import (
 import (
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/repository"
-	"gorm.io/gorm"
 )
 )
 
 
 // WriteProjectForm is a generic form for write operations to the Project model
 // WriteProjectForm is a generic form for write operations to the Project model
@@ -24,20 +23,8 @@ func (cpf *CreateProjectForm) ToProject(_ repository.ProjectRepository) (*models
 	}, nil
 	}, nil
 }
 }
 
 
-// CreateProjectRoleForm represents the accepted values for creating a project
+// UpdateProjectRoleForm represents the accepted values for updating a project
 // role
 // 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"`
 }
 }

+ 5 - 0
internal/models/invite.go

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

+ 0 - 8
internal/models/project.go

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

+ 3 - 2
internal/models/role.go

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

+ 6 - 4
internal/oauth/config.go

@@ -18,9 +18,10 @@ type Config struct {
 	BaseURL      string
 	BaseURL      string
 }
 }
 
 
-// GithubAppConf is standard oauth2 config but it need to keeps track of the app name
+// GithubAppConf is standard oauth2 config but it need to keeps track of the app name and webhook secret
 type GithubAppConf struct {
 type GithubAppConf struct {
-	AppName string
+	AppName       string
+	WebhookSecret string
 	oauth2.Config
 	oauth2.Config
 }
 }
 
 
@@ -37,9 +38,10 @@ func NewGithubClient(cfg *Config) *oauth2.Config {
 	}
 	}
 }
 }
 
 
-func NewGithubAppClient(cfg *Config, name string) *GithubAppConf {
+func NewGithubAppClient(cfg *Config, name string, secret string) *GithubAppConf {
 	return &GithubAppConf{
 	return &GithubAppConf{
-		AppName: name,
+		AppName:       name,
+		WebhookSecret: secret,
 		Config: oauth2.Config{
 		Config: oauth2.Config{
 			ClientID:     cfg.ClientID,
 			ClientID:     cfg.ClientID,
 			ClientSecret: cfg.ClientSecret,
 			ClientSecret: cfg.ClientSecret,

+ 30 - 0
internal/repository/gorm/helpers_test.go

@@ -114,6 +114,36 @@ func initUser(tester *tester, t *testing.T) {
 	tester.initUsers = append(tester.initUsers, user)
 	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) {
 func initProject(tester *tester, t *testing.T) {
 	t.Helper()
 	t.Helper()
 
 

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

@@ -41,6 +41,22 @@ func (repo *ProjectRepository) CreateProjectRole(project *models.Project, role *
 	return role, nil
 	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
 // ReadProject gets a projects specified by a unique id
 func (repo *ProjectRepository) ReadProject(id uint) (*models.Project, error) {
 func (repo *ProjectRepository) ReadProject(id uint) (*models.Project, error) {
 	project := &models.Project{}
 	project := &models.Project{}
@@ -52,6 +68,18 @@ func (repo *ProjectRepository) ReadProject(id uint) (*models.Project, error) {
 	return project, nil
 	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
 // ListProjectsByUserID lists projects where a user has an associated role
 func (repo *ProjectRepository) ListProjectsByUserID(userID uint) ([]*models.Project, error) {
 func (repo *ProjectRepository) ListProjectsByUserID(userID uint) ([]*models.Project, error) {
 	projects := make([]*models.Project, 0)
 	projects := make([]*models.Project, 0)
@@ -65,6 +93,17 @@ func (repo *ProjectRepository) ListProjectsByUserID(userID uint) ([]*models.Proj
 	return projects, nil
 	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)
 // DeleteProject deletes a project (marking deleted in the db)
 func (repo *ProjectRepository) DeleteProject(project *models.Project) (*models.Project, error) {
 func (repo *ProjectRepository) DeleteProject(project *models.Project) (*models.Project, error) {
 	if err := repo.db.Delete(&project).Error; err != nil {
 	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
 	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) {
 func TestListProjectsByUserID(t *testing.T) {
 	tester := &tester{
 	tester := &tester{
 		dbFileName: "./list_projects_user_id.db",
 		dbFileName: "./list_projects_user_id.db",
@@ -172,7 +238,7 @@ func TestListProjectsByUserID(t *testing.T) {
 
 
 func TestDeleteProject(t *testing.T) {
 func TestDeleteProject(t *testing.T) {
 	tester := &tester{
 	tester := &tester{
-		dbFileName: "./porter_create_proj_role.db",
+		dbFileName: "./porter_delete_proj.db",
 	}
 	}
 
 
 	setupTestEnv(tester, t)
 	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)
 		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)
+	}
+}

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

@@ -35,6 +35,17 @@ func (repo *UserRepository) ReadUser(id uint) (*models.User, error) {
 	return user, nil
 	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
 // ReadUserByEmail finds a single user based on their unique email
 func (repo *UserRepository) ReadUserByEmail(email string) (*models.User, error) {
 func (repo *UserRepository) ReadUserByEmail(email string) (*models.User, error) {
 	user := &models.User{}
 	user := &models.User{}

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

@@ -7,6 +7,40 @@ import (
 	"github.com/porter-dev/porter/internal/models"
 	"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) {
 func TestReadUserByGithubUserID(t *testing.T) {
 	tester := &tester{
 	tester := &tester{
 		dbFileName: "./porter_read_user_github.db",
 		dbFileName: "./porter_read_user_github.db",

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

@@ -51,6 +51,41 @@ func (repo *ProjectRepository) CreateProjectRole(project *models.Project, role *
 	return role, nil
 	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
 // ReadProject gets a projects specified by a unique id
 func (repo *ProjectRepository) ReadProject(id uint) (*models.Project, error) {
 func (repo *ProjectRepository) ReadProject(id uint) (*models.Project, error) {
 	if !repo.canQuery {
 	if !repo.canQuery {
@@ -65,6 +100,42 @@ func (repo *ProjectRepository) ReadProject(id uint) (*models.Project, error) {
 	return repo.projects[index], nil
 	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
 // ListProjectsByUserID lists projects where a user has an associated role
 func (repo *ProjectRepository) ListProjectsByUserID(userID uint) ([]*models.Project, error) {
 func (repo *ProjectRepository) ListProjectsByUserID(userID uint) ([]*models.Project, error) {
 	if !repo.canQuery {
 	if !repo.canQuery {
@@ -85,6 +156,22 @@ func (repo *ProjectRepository) ListProjectsByUserID(userID uint) ([]*models.Proj
 	return resp, nil
 	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
 // DeleteProject removes a project
 func (repo *ProjectRepository) DeleteProject(project *models.Project) (*models.Project, error) {
 func (repo *ProjectRepository) DeleteProject(project *models.Project) (*models.Project, error) {
 	if !repo.canQuery {
 	if !repo.canQuery {
@@ -100,3 +187,39 @@ func (repo *ProjectRepository) DeleteProject(project *models.Project) (*models.P
 
 
 	return project, nil
 	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 - 0
internal/repository/memory/user.go

@@ -56,6 +56,25 @@ func (repo *UserRepository) ReadUser(id uint) (*models.User, error) {
 	return repo.users[index], nil
 	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
 // ReadUserByEmail finds a single user based on their unique email
 func (repo *UserRepository) ReadUserByEmail(email string) (*models.User, error) {
 func (repo *UserRepository) ReadUserByEmail(email string) (*models.User, error) {
 	if !repo.canQuery {
 	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 {
 type ProjectRepository interface {
 	CreateProject(project *models.Project) (*models.Project, error)
 	CreateProject(project *models.Project) (*models.Project, error)
 	CreateProjectRole(project *models.Project, role *models.Role) (*models.Role, 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)
 	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)
 	ListProjectsByUserID(userID uint) ([]*models.Project, error)
 	DeleteProject(project *models.Project) (*models.Project, error)
 	DeleteProject(project *models.Project) (*models.Project, error)
+	DeleteProjectRole(projID, userID uint) (*models.Role, error)
 }
 }

+ 1 - 0
internal/repository/user.go

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

+ 2 - 2
server/api/api.go

@@ -170,13 +170,13 @@ func New(conf *AppConfig) (*App, error) {
 		app.Capabilities.GithubLogin = sc.GithubLoginEnabled
 		app.Capabilities.GithubLogin = sc.GithubLoginEnabled
 	}
 	}
 
 
-	if sc.GithubAppClientID != "" && sc.GithubAppClientSecret != "" && sc.GithubAppName != "" {
+	if sc.GithubAppClientID != "" && sc.GithubAppClientSecret != "" && sc.GithubAppName != "" && sc.GithubAppWebhookSecret != "" {
 		app.GithubAppConf = oauth.NewGithubAppClient(&oauth.Config{
 		app.GithubAppConf = oauth.NewGithubAppClient(&oauth.Config{
 			ClientID:     sc.GithubAppClientID,
 			ClientID:     sc.GithubAppClientID,
 			ClientSecret: sc.GithubAppClientSecret,
 			ClientSecret: sc.GithubAppClientSecret,
 			Scopes:       []string{"read:user"},
 			Scopes:       []string{"read:user"},
 			BaseURL:      sc.ServerURL,
 			BaseURL:      sc.ServerURL,
-		}, sc.GithubAppName)
+		}, sc.GithubAppName, sc.GithubAppWebhookSecret)
 	}
 	}
 
 
 	if sc.GoogleClientID != "" && sc.GoogleClientSecret != "" {
 	if sc.GoogleClientID != "" && sc.GoogleClientSecret != "" {

+ 28 - 0
server/api/integration_handler.go

@@ -2,6 +2,9 @@ package api
 
 
 import (
 import (
 	"context"
 	"context"
+	"crypto/hmac"
+	"crypto/sha256"
+	"encoding/hex"
 	"encoding/json"
 	"encoding/json"
 	"fmt"
 	"fmt"
 	"github.com/google/go-github/github"
 	"github.com/google/go-github/github"
@@ -13,6 +16,7 @@ import (
 	"net/url"
 	"net/url"
 	"sort"
 	"sort"
 	"strconv"
 	"strconv"
+	"strings"
 
 
 	"github.com/go-chi/chi"
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/forms"
@@ -386,6 +390,22 @@ func (app *App) HandleListProjectOAuthIntegrations(w http.ResponseWriter, r *htt
 	}
 	}
 }
 }
 
 
+// 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) {
 func (app *App) HandleGithubAppEvent(w http.ResponseWriter, r *http.Request) {
 	payload, err := ioutil.ReadAll(r.Body)
 	payload, err := ioutil.ReadAll(r.Body)
 	if err != nil {
 	if err != nil {
@@ -393,6 +413,14 @@ func (app *App) HandleGithubAppEvent(w http.ResponseWriter, r *http.Request) {
 		return
 		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)
 	event, err := github.ParseWebHook(github.WebHookType(r), payload)
 
 
 	if err != nil {
 	if err != 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
 // HandleAcceptInvite accepts an invite to a new project: if successful, a new role
 // is created for that user in the project
 // is created for that user in the project
 func (app *App) HandleAcceptInvite(w http.ResponseWriter, r *http.Request) {
 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
 		return
 	}
 	}
 
 
+	kind := invite.Kind
+
+	if kind == "" {
+		kind = models.RoleDeveloper
+	}
+
 	// create a new Role with the user as the admin
 	// create a new Role with the user as the admin
 	_, err = app.Repo.Project.CreateProjectRole(projModel, &models.Role{
 	_, err = app.Repo.Project.CreateProjectRole(projModel, &models.Role{
 		UserID:    userID,
 		UserID:    userID,
 		ProjectID: uint(projID),
 		ProjectID: uint(projID),
-		Kind:      models.RoleAdmin,
+		Kind:      kind,
 	})
 	})
 
 
 	if err != nil {
 	if err != nil {

+ 196 - 0
server/api/project_handler.go

@@ -6,6 +6,7 @@ import (
 	"strconv"
 	"strconv"
 
 
 	"github.com/go-chi/chi"
 	"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/forms"
 	"github.com/porter-dev/porter/internal/models"
 	"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)
 // HandleReadProject returns an externalized Project (models.ProjectExternal)
 // based on an ID
 // based on an ID
 func (app *App) HandleReadProject(w http.ResponseWriter, r *http.Request) {
 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
 // HandleDeleteProject deletes a project from the db, reading from the project_id
 // in the URL param
 // in the URL param
 func (app *App) HandleDeleteProject(w http.ResponseWriter, r *http.Request) {
 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
 		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
+	}
+}

+ 10 - 5
server/middleware/auth.go

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

+ 98 - 48
server/router/router.go

@@ -286,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(
 			r.Method(
 				"POST",
 				"POST",
 				"/projects",
 				"/projects",
@@ -300,7 +340,17 @@ func New(a *api.App) *chi.Mux {
 				auth.DoesUserHaveProjectAccess(
 				auth.DoesUserHaveProjectAccess(
 					requestlog.NewHandler(a.HandleDeleteProject, l),
 					requestlog.NewHandler(a.HandleDeleteProject, l),
 					mw.URLParam,
 					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,
 				),
 				),
 			)
 			)
 
 
@@ -315,7 +365,7 @@ func New(a *api.App) *chi.Mux {
 						mw.QueryParam,
 						mw.QueryParam,
 					),
 					),
 					mw.URLParam,
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 				),
 			)
 			)
 
 
@@ -326,7 +376,7 @@ func New(a *api.App) *chi.Mux {
 				auth.DoesUserHaveProjectAccess(
 				auth.DoesUserHaveProjectAccess(
 					requestlog.NewHandler(a.HandleCreateInvite, l),
 					requestlog.NewHandler(a.HandleCreateInvite, l),
 					mw.URLParam,
 					mw.URLParam,
-					mw.WriteAccess,
+					mw.AdminAccess,
 				),
 				),
 			)
 			)
 
 
@@ -336,7 +386,7 @@ func New(a *api.App) *chi.Mux {
 				auth.DoesUserHaveProjectAccess(
 				auth.DoesUserHaveProjectAccess(
 					requestlog.NewHandler(a.HandleListProjectInvites, l),
 					requestlog.NewHandler(a.HandleListProjectInvites, l),
 					mw.URLParam,
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.AdminAccess,
 				),
 				),
 			)
 			)
 
 
@@ -348,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(
 			r.Method(
 				"DELETE",
 				"DELETE",
 				"/projects/{project_id}/invites/{invite_id}",
 				"/projects/{project_id}/invites/{invite_id}",
@@ -358,7 +422,7 @@ func New(a *api.App) *chi.Mux {
 						mw.URLParam,
 						mw.URLParam,
 					),
 					),
 					mw.URLParam,
 					mw.URLParam,
-					mw.WriteAccess,
+					mw.AdminAccess,
 				),
 				),
 			)
 			)
 
 
@@ -380,7 +444,7 @@ func New(a *api.App) *chi.Mux {
 				auth.DoesUserHaveProjectAccess(
 				auth.DoesUserHaveProjectAccess(
 					requestlog.NewHandler(a.HandleProvisionTestInfra, l),
 					requestlog.NewHandler(a.HandleProvisionTestInfra, l),
 					mw.URLParam,
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 				),
 			)
 			)
 
 
@@ -395,7 +459,7 @@ func New(a *api.App) *chi.Mux {
 						false,
 						false,
 					),
 					),
 					mw.URLParam,
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 				),
 			)
 			)
 
 
@@ -410,7 +474,7 @@ func New(a *api.App) *chi.Mux {
 						false,
 						false,
 					),
 					),
 					mw.URLParam,
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 				),
 			)
 			)
 
 
@@ -425,7 +489,7 @@ func New(a *api.App) *chi.Mux {
 						false,
 						false,
 					),
 					),
 					mw.URLParam,
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 				),
 			)
 			)
 
 
@@ -440,7 +504,7 @@ func New(a *api.App) *chi.Mux {
 						false,
 						false,
 					),
 					),
 					mw.URLParam,
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 				),
 			)
 			)
 
 
@@ -455,7 +519,7 @@ func New(a *api.App) *chi.Mux {
 						false,
 						false,
 					),
 					),
 					mw.URLParam,
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 				),
 			)
 			)
 
 
@@ -470,7 +534,7 @@ func New(a *api.App) *chi.Mux {
 						false,
 						false,
 					),
 					),
 					mw.URLParam,
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 				),
 			)
 			)
 
 
@@ -488,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(
 			r.Method(
 				"POST",
 				"POST",
 				"/projects/{project_id}/infra/{infra_id}/ecr/destroy",
 				"/projects/{project_id}/infra/{infra_id}/ecr/destroy",
@@ -512,7 +562,7 @@ func New(a *api.App) *chi.Mux {
 						mw.URLParam,
 						mw.URLParam,
 					),
 					),
 					mw.URLParam,
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 				),
 			)
 			)
 
 
@@ -526,7 +576,7 @@ func New(a *api.App) *chi.Mux {
 						mw.URLParam,
 						mw.URLParam,
 					),
 					),
 					mw.URLParam,
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 				),
 			)
 			)
 
 
@@ -540,7 +590,7 @@ func New(a *api.App) *chi.Mux {
 						mw.URLParam,
 						mw.URLParam,
 					),
 					),
 					mw.URLParam,
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 				),
 			)
 			)
 
 
@@ -554,7 +604,7 @@ func New(a *api.App) *chi.Mux {
 						mw.URLParam,
 						mw.URLParam,
 					),
 					),
 					mw.URLParam,
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 				),
 			)
 			)
 
 
@@ -568,7 +618,7 @@ func New(a *api.App) *chi.Mux {
 						mw.URLParam,
 						mw.URLParam,
 					),
 					),
 					mw.URLParam,
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 				),
 			)
 			)
 
 
@@ -582,7 +632,7 @@ func New(a *api.App) *chi.Mux {
 						mw.URLParam,
 						mw.URLParam,
 					),
 					),
 					mw.URLParam,
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 				),
 			)
 			)
 
 
@@ -805,7 +855,7 @@ func New(a *api.App) *chi.Mux {
 				auth.DoesUserHaveProjectAccess(
 				auth.DoesUserHaveProjectAccess(
 					requestlog.NewHandler(a.HandleListProjectHelmRepos, l),
 					requestlog.NewHandler(a.HandleListProjectHelmRepos, l),
 					mw.URLParam,
 					mw.URLParam,
-					mw.WriteAccess,
+					mw.ReadAccess,
 				),
 				),
 			)
 			)
 
 
@@ -815,7 +865,7 @@ func New(a *api.App) *chi.Mux {
 				auth.DoesUserHaveProjectAccess(
 				auth.DoesUserHaveProjectAccess(
 					requestlog.NewHandler(a.HandleListHelmRepoCharts, l),
 					requestlog.NewHandler(a.HandleListHelmRepoCharts, l),
 					mw.URLParam,
 					mw.URLParam,
-					mw.WriteAccess,
+					mw.ReadAccess,
 				),
 				),
 			)
 			)
 
 
@@ -851,7 +901,7 @@ func New(a *api.App) *chi.Mux {
 				auth.DoesUserHaveProjectAccess(
 				auth.DoesUserHaveProjectAccess(
 					requestlog.NewHandler(a.HandleListProjectRegistries, l),
 					requestlog.NewHandler(a.HandleListProjectRegistries, l),
 					mw.URLParam,
 					mw.URLParam,
-					mw.WriteAccess,
+					mw.ReadAccess,
 				),
 				),
 			)
 			)
 
 
@@ -865,7 +915,7 @@ func New(a *api.App) *chi.Mux {
 						mw.URLParam,
 						mw.URLParam,
 					),
 					),
 					mw.URLParam,
 					mw.URLParam,
-					mw.WriteAccess,
+					mw.ReadAccess,
 				),
 				),
 			)
 			)
 
 
@@ -965,7 +1015,7 @@ func New(a *api.App) *chi.Mux {
 						mw.URLParam,
 						mw.URLParam,
 					),
 					),
 					mw.URLParam,
 					mw.URLParam,
-					mw.WriteAccess,
+					mw.ReadAccess,
 				),
 				),
 			)
 			)
 
 
@@ -1398,7 +1448,7 @@ func New(a *api.App) *chi.Mux {
 						mw.QueryParam,
 						mw.QueryParam,
 					),
 					),
 					mw.URLParam,
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 				),
 			)
 			)
 
 
@@ -1426,7 +1476,7 @@ func New(a *api.App) *chi.Mux {
 						mw.QueryParam,
 						mw.QueryParam,
 					),
 					),
 					mw.URLParam,
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 				),
 			)
 			)
 
 
@@ -1440,7 +1490,7 @@ func New(a *api.App) *chi.Mux {
 						mw.QueryParam,
 						mw.QueryParam,
 					),
 					),
 					mw.URLParam,
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 				),
 			)
 			)
 
 
@@ -1482,7 +1532,7 @@ func New(a *api.App) *chi.Mux {
 						mw.QueryParam,
 						mw.QueryParam,
 					),
 					),
 					mw.URLParam,
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 				),
 			)
 			)
 
 
@@ -1510,7 +1560,7 @@ func New(a *api.App) *chi.Mux {
 						mw.QueryParam,
 						mw.QueryParam,
 					),
 					),
 					mw.URLParam,
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 				),
 			)
 			)
 
 
@@ -1525,7 +1575,7 @@ func New(a *api.App) *chi.Mux {
 						mw.QueryParam,
 						mw.QueryParam,
 					),
 					),
 					mw.URLParam,
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 				),
 			)
 			)
 
 
@@ -1547,7 +1597,7 @@ func New(a *api.App) *chi.Mux {
 						mw.QueryParam,
 						mw.QueryParam,
 					),
 					),
 					mw.URLParam,
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 				),
 			)
 			)
 
 
@@ -1561,7 +1611,7 @@ func New(a *api.App) *chi.Mux {
 						mw.QueryParam,
 						mw.QueryParam,
 					),
 					),
 					mw.URLParam,
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 				),
 			)
 			)
 		})
 		})
@@ -1580,7 +1630,7 @@ func New(a *api.App) *chi.Mux {
 						mw.QueryParam,
 						mw.QueryParam,
 					),
 					),
 					mw.URLParam,
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 				),
 			)
 			)
 
 
@@ -1600,7 +1650,7 @@ func New(a *api.App) *chi.Mux {
 						mw.QueryParam,
 						mw.QueryParam,
 					),
 					),
 					mw.URLParam,
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 				),
 			)
 			)
 
 
@@ -1614,7 +1664,7 @@ func New(a *api.App) *chi.Mux {
 						mw.QueryParam,
 						mw.QueryParam,
 					),
 					),
 					mw.URLParam,
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 				),
 			)
 			)