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

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

jusrhee 4 лет назад
Родитель
Сommit
442680b65a
58 измененных файлов с 1998 добавлено и 360 удалено
  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) => {
             let selected = option.value === this.props.selected;
             return (
-              <RadioRow onClick={() => this.props.setSelected(option.value)}>
+              <RadioRow
+                key={option.value}
+                onClick={() => this.props.setSelected(option.value)}
+              >
                 <Indicator selected={selected}>
                   {selected && <Circle />}
                 </Indicator>

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

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

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

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

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

@@ -26,14 +26,28 @@ import ProjectSettings from "./project-settings/ProjectSettings";
 import Sidebar from "./sidebar/Sidebar";
 import PageNotFound from "components/PageNotFound";
 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";
-
-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 = {
   forceSidebar: boolean;
@@ -91,9 +105,6 @@ class Home extends Component<PropsType, StateType> {
   };
 
   getCapabilities = () => {
-    let { currentProject } = this.props;
-    if (!currentProject) return;
-
     api
       .getCapabilities("<token>", {}, {})
       .then((res) => {
@@ -337,9 +348,9 @@ class Home extends Component<PropsType, StateType> {
           </DashboardWrapper>
         );
       } else if (currentView === "integrations") {
-        return <Integrations />;
+        return <GuardedIntegrations />;
       } else if (currentView === "project-settings") {
-        return <ProjectSettings />;
+        return <GuardedProjectSettings />;
       }
       return <Templates />;
     } else if (currentView === "new-project") {
@@ -472,19 +483,22 @@ class Home extends Component<PropsType, StateType> {
             <ClusterInstructionsModal />
           </Modal>
         )}
-        {currentModal === "UpdateClusterModal" && (
-          <Modal
-            onRequestClose={() => setCurrentModal(null, null)}
-            width="565px"
-            height="275px"
-          >
-            <UpdateClusterModal
-              setRefreshClusters={(x: boolean) =>
-                this.setState({ forceRefreshClusters: x })
-              }
-            />
-          </Modal>
-        )}
+
+        {/* We should be careful, as this component is named Update but is for deletion */}
+        {this.props.isAuthorized("cluster", "", ["get", "delete"]) &&
+          currentModal === "UpdateClusterModal" && (
+            <Modal
+              onRequestClose={() => setCurrentModal(null, null)}
+              width="565px"
+              height="275px"
+            >
+              <UpdateClusterModal
+                setRefreshClusters={(x: boolean) =>
+                  this.setState({ forceRefreshClusters: x })
+                }
+              />
+            </Modal>
+          )}
         {currentModal === "IntegrationsModal" && (
           <Modal
             onRequestClose={() => setCurrentModal(null, null)}
@@ -503,22 +517,34 @@ class Home extends Component<PropsType, StateType> {
             <IntegrationsInstructionsModal />
           </Modal>
         )}
-        {currentModal === "NamespaceModal" && (
+        {this.props.isAuthorized("namespace", "", ["get", "create"]) &&
+          currentModal === "NamespaceModal" && (
+            <Modal
+              onRequestClose={() => setCurrentModal(null, null)}
+              width="600px"
+              height="220px"
+            >
+              <NamespaceModal />
+            </Modal>
+          )}
+        {this.props.isAuthorized("namespace", "", ["get", "delete"]) &&
+          currentModal === "DeleteNamespaceModal" && (
+            <Modal
+              onRequestClose={() => setCurrentModal(null, null)}
+              width="700px"
+              height="280px"
+            >
+              <DeleteNamespaceModal />
+            </Modal>
+          )}
+
+        {currentModal === "EditInviteOrCollaboratorModal" && (
           <Modal
             onRequestClose={() => setCurrentModal(null, null)}
             width="600px"
-            height="220px"
-          >
-            <NamespaceModal />
-          </Modal>
-        )}
-        {currentModal === "DeleteNamespaceModal" && (
-          <Modal
-            onRequestClose={() => setCurrentModal(null, null)}
-            width="700px"
-            height="280px"
+            height="250px"
           >
-            <DeleteNamespaceModal />
+            <EditInviteOrCollaboratorModal />
           </Modal>
         )}
         {currentModal === "AccountSettingsModal" && (
@@ -558,7 +584,7 @@ class Home extends Component<PropsType, StateType> {
 
 Home.contextType = Context;
 
-export default withRouter(Home);
+export default withRouter(withAuth(Home));
 
 const ViewWrapper = styled.div`
   height: 100%;

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

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

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

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

+ 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 { pushFiltered } from "shared/routing";
 import { useHistory, useLocation } from "react-router";
+import useAuth from "shared/auth/useAuth";
 
 const OptionsDropdown: React.FC = ({ children }) => {
   const [isOpen, setIsOpen] = useState(false);
@@ -68,6 +69,8 @@ export const NamespaceList: React.FunctionComponent = () => {
     setCurrentModal("DeleteNamespaceModal", namespace);
   };
 
+  const [isAuthorized] = useAuth();
+
   const isAvailableForDeletion = (namespaceName: string) => {
     // Only the namespaces that doesn't start with kube- or has by name default will be
     // available for deletion (as those are the k8s namespaces)
@@ -133,18 +136,20 @@ export const NamespaceList: React.FunctionComponent = () => {
   return (
     <NamespaceListWrapper>
       <ControlRow>
-        <Button
-          onClick={() =>
-            setCurrentModal(
-              "NamespaceModal",
-              namespaces.map((namespace) => ({
-                value: namespace.metadata.name,
-              }))
-            )
-          }
-        >
-          <i className="material-icons">add</i> Add namespace
-        </Button>
+        {isAuthorized("namespace", "", ["get", "create"]) && (
+          <Button
+            onClick={() =>
+              setCurrentModal(
+                "NamespaceModal",
+                namespaces.map((namespace) => ({
+                  value: namespace.metadata.name,
+                }))
+              )
+            }
+          >
+            <i className="material-icons">add</i> Add namespace
+          </Button>
+        )}
       </ControlRow>
       <NamespacesGrid>
         {sortedNamespaces.map((namespace) => {
@@ -165,7 +170,8 @@ export const NamespaceList: React.FunctionComponent = () => {
                   {namespace?.status?.phase}
                 </Status>
               </ContentContainer>
-              {isAvailableForDeletion(namespace?.metadata?.name) &&
+              {isAuthorized("namespace", "", ["get", "delete"]) &&
+                isAvailableForDeletion(namespace?.metadata?.name) &&
                 namespace?.status?.phase === "Active" && (
                   <OptionsDropdown>
                     <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 { RouteComponentProps, withRouter } from "react-router";
 import { pushQueryParams } from "shared/routing";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 
-type PropsType = RouteComponentProps & {
-  currentCluster: ClusterType;
-};
+type PropsType = RouteComponentProps &
+  WithAuthProps & {
+    currentCluster: ClusterType;
+  };
 
 type StateType = {
   expand: boolean;
@@ -59,16 +61,22 @@ class EnvGroupDashboard extends Component<PropsType, StateType> {
         />
       );
     } else {
+      const isAuthorizedToAdd = this.props.isAuthorized("env_group", "", [
+        "get",
+        "create",
+      ]);
       return (
         <>
-          <ControlRow>
-            <Button
-              onClick={() =>
-                this.setState({ createEnvMode: !this.state.createEnvMode })
-              }
-            >
-              <i className="material-icons">add</i> Create Env Group
-            </Button>
+          <ControlRow hasMultipleChilds={isAuthorizedToAdd}>
+            {isAuthorizedToAdd && (
+              <Button
+                onClick={() =>
+                  this.setState({ createEnvMode: !this.state.createEnvMode })
+                }
+              >
+                <i className="material-icons">add</i> Create Env Group
+              </Button>
+            )}
             <SortFilterWrapper>
               <SortSelector
                 setSortType={(sortType) => this.setState({ sortType })}
@@ -131,7 +139,7 @@ class EnvGroupDashboard extends Component<PropsType, StateType> {
 
 EnvGroupDashboard.contextType = Context;
 
-export default withRouter(EnvGroupDashboard);
+export default withRouter(withAuth(EnvGroupDashboard));
 
 const SortFilterWrapper = styled.div`
   width: 468px;
@@ -141,7 +149,12 @@ const SortFilterWrapper = styled.div`
 
 const ControlRow = styled.div`
   display: flex;
-  justify-content: space-between;
+  justify-content: ${(props: { hasMultipleChilds: boolean }) => {
+    if (props.hasMultipleChilds) {
+      return "space-between";
+    }
+    return "flex-end";
+  }};
   align-items: center;
   margin-bottom: 35px;
   padding-left: 0px;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -79,7 +79,7 @@ const AccountSettingsModal = () => {
           {accessData.has_access ? (
             <Placeholder>
               <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>
               {!accessData.accounts || accessData.accounts?.length == 0 ? (
                 <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 Feedback from "./Feedback";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+import { Select, MenuItem } from "@material-ui/core";
+import { AuthContext } from "shared/auth/AuthContext";
 
-type PropsType = {
+type PropsType = WithAuthProps & {
   logOut: () => void;
   currentView: string;
 };
 
 type StateType = {
   showDropdown: boolean;
+  currentPolicy: string;
 };
 
-export default class Navbar extends Component<PropsType, StateType> {
+class Navbar extends Component<PropsType, StateType> {
   state = {
     showDropdown: false,
+    currentPolicy: "admin",
   };
 
   renderSettingsDropdown = () => {
@@ -38,7 +43,7 @@ export default class Navbar extends Component<PropsType, StateType> {
             >
               <SettingsIcon>
                 <i className="material-icons">settings</i>
-              </SettingsIcon> 
+              </SettingsIcon>
               Account Settings
             </UserDropdownButton>
             <UserDropdownButton onClick={this.props.logOut}>
@@ -59,6 +64,22 @@ export default class Navbar extends Component<PropsType, StateType> {
   render() {
     return (
       <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()}
         <NavButton
           selected={this.state.showDropdown}
@@ -76,6 +97,8 @@ export default class Navbar extends Component<PropsType, StateType> {
 
 Navbar.contextType = Context;
 
+export default withAuth(Navbar);
+
 const SettingsIcon = styled.div`
   > i {
     background: none;
@@ -96,6 +119,13 @@ const I = styled.i`
   margin-right: 7px;
 `;
 
+const PolicySelector = styled(Select)`
+  height: 30px;
+  width: 100px;
+  margin-right: 15px;
+  color: white !important;
+`;
+
 const CloseOverlay = styled.div`
   position: fixed;
   width: 100vw;

+ 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 { Column } from "react-table";
 import Table from "components/Table";
+import RadioSelector from "components/RadioSelector";
 
 type Props = {};
 
+export type Collaborator = {
+  id: string;
+  user_id: string;
+  project_id: string;
+  email: string;
+  kind: string;
+};
+
 const InvitePage: React.FunctionComponent<Props> = ({}) => {
-  const { currentProject } = useContext(Context);
+  const { currentProject, setCurrentModal, user } = useContext(Context);
   const [isLoading, setIsLoading] = useState(true);
   const [invites, setInvites] = useState<Array<InviteType>>([]);
   const [email, setEmail] = useState("");
+  const [role, setRole] = useState("developer");
+  const [roleList, setRoleList] = useState([]);
   const [isInvalidEmail, setIsInvalidEmail] = useState(false);
   const [isHTTPS] = useState(() => window.location.protocol === "https:");
 
   useEffect(() => {
-    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);
-
-    api
-      .getInvites(
+    let invites = [];
+    try {
+      const response = await api.getInvites(
         "<token>",
         {},
         {
           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 = () => {
     api
-      .createInvite("<token>", { email }, { id: currentProject.id })
+      .createInvite("<token>", { email, kind: role }, { id: currentProject.id })
       .then(() => {
-        getInviteData();
+        getData();
         setEmail("");
       })
       .catch((err) => console.log(err));
@@ -71,15 +134,19 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
           invId: inviteId,
         }
       )
-      .then(getInviteData)
+      .then(getData)
       .catch((err) => console.log(err));
   };
 
-  const replaceInvite = (inviteEmail: string, inviteId: number) => {
+  const replaceInvite = (
+    inviteEmail: string,
+    inviteId: number,
+    kind: string
+  ) => {
     api
       .createInvite(
         "<token>",
-        { email: inviteEmail },
+        { email: inviteEmail, kind },
         { id: currentProject.id }
       )
       .then(() =>
@@ -92,7 +159,7 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
           }
         )
       )
-      .then(getInviteData)
+      .then(getData)
       .catch((err) => console.log(err));
   };
 
@@ -107,12 +174,37 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
     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<
     Column<{
       email: string;
       id: number;
       status: string;
       invite_link: string;
+      kind: string;
     }>[]
   >(
     () => [
@@ -120,6 +212,15 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
         Header: "Mail address",
         accessor: "email",
       },
+      {
+        Header: "Role",
+        accessor: "kind",
+        Cell: ({ row }) => {
+          return (
+            <Status status={"accepted"}>{row.values.kind || "Admin"}</Status>
+          );
+        },
+      },
       {
         Header: "Status",
         accessor: "status",
@@ -136,7 +237,13 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
           if (row.values.status === "expired") {
             return (
               <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>
               </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") {
-            return <CopyButton invis={true}>Remove</CopyButton>;
+            return (
+              <CopyButton
+                invis={row.original.currentUser}
+                onClick={() => removeCollaborator(row.original.id)}
+              >
+                Remove
+              </CopyButton>
+            );
           }
           return (
             <>
-              <CopyButton onClick={() => deleteInvite(row.values.id)}>
+              <CopyButton
+                invis={row.original.currentUser}
+                onClick={() => deleteInvite(row.original.id)}
+              >
                 Delete Invite
               </CopyButton>
             </>
@@ -187,10 +317,13 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
 
     const mappedInviteList = inviteList.map(
       ({ accepted, expired, token, ...rest }) => {
+        const currentUser: boolean = user.email === rest.email;
+        console.log(currentUser, user, rest);
         if (accepted) {
           return {
             status: "accepted",
             invite_link: buildInviteLink(token),
+            currentUser,
             ...rest,
           };
         }
@@ -199,6 +332,7 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
           return {
             status: "expired",
             invite_link: buildInviteLink(token),
+            currentUser,
             ...rest,
           };
         }
@@ -206,18 +340,19 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
         return {
           status: "pending",
           invite_link: buildInviteLink(token),
+          currentUser,
           ...rest,
         };
       }
     );
 
     return mappedInviteList || [];
-  }, [invites, currentProject?.id, window?.location?.host, isHTTPS]);
+  }, [invites, currentProject?.id, window?.location?.host, isHTTPS, user?.id]);
 
   return (
     <>
       <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>
         <InputRow
           value={email}
@@ -227,6 +362,14 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
           placeholder="ex: mrp@getporter.dev"
         />
       </InputRowWrapper>
+      <Helper>Select the role the user will have.</Helper>
+      <RoleSelectorWrapper>
+        <RadioSelector
+          selected={role}
+          setSelected={setRole}
+          options={roleList}
+        />
+      </RoleSelectorWrapper>
       <ButtonWrapper>
         <InviteButton disabled={false} onClick={() => validateEmail()}>
           Create Invite
@@ -259,6 +402,10 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
 
 export default InvitePage;
 
+const RoleSelectorWrapper = styled.div`
+  font-size: 14px;
+`;
+
 const Placeholder = styled.div`
   width: 100%;
   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 { RouteComponentProps, withRouter } from "react-router";
 import { pushFiltered, pushQueryParams } from "shared/routing";
-
-type PropsType = RouteComponentProps & {
-  forceSidebar: boolean;
-  setWelcome: (x: boolean) => void;
-  currentView: string;
-  forceRefreshClusters: boolean;
-  setRefreshClusters: (x: boolean) => void;
-};
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+
+type PropsType = RouteComponentProps &
+  WithAuthProps & {
+    forceSidebar: boolean;
+    setWelcome: (x: boolean) => void;
+    currentView: string;
+    forceRefreshClusters: boolean;
+    setRefreshClusters: (x: boolean) => void;
+  };
 
 type StateType = {
   showSidebar: boolean;
@@ -231,18 +233,23 @@ class Sidebar extends Component<PropsType, StateType> {
             <Img src={rocket} />
             Launch
           </NavButton>
-          <NavButton
-            selected={currentView === "integrations"}
-            onClick={() =>
-              pushFiltered(this.props, "/integrations", ["project_id"])
-            }
-          >
-            <Img src={integrations} />
-            Integrations
-          </NavButton>
-          {this.context.currentProject.roles.filter((obj: any) => {
-            return obj.user_id === this.context.user.userId;
-          })[0].kind === "admin" && (
+
+          {this.props.isAuthorized("integrations", "", ["get"]) && (
+            <NavButton
+              selected={currentView === "integrations"}
+              onClick={() =>
+                pushFiltered(this.props, "/integrations", ["project_id"])
+              }
+            >
+              <Img src={integrations} />
+              Integrations
+            </NavButton>
+          )}
+          {this.props.isAuthorized("settings", "", [
+            "get",
+            "update",
+            "delete",
+          ]) && (
             <NavButton
               onClick={() =>
                 pushFiltered(this.props, "/project-settings", ["project_id"])
@@ -313,7 +320,7 @@ class Sidebar extends Component<PropsType, StateType> {
 
 Sidebar.contextType = Context;
 
-export default withRouter(Sidebar);
+export default withRouter(withAuth(Sidebar));
 
 const BranchPad = styled.div`
   width: 20px;

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

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

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

@@ -150,6 +150,7 @@ const createGKE = baseApi<
 const createInvite = baseApi<
   {
     email: string;
+    kind: string;
   },
   {
     id: number;
@@ -921,6 +922,38 @@ const stopJob = baseApi<
   return `/api/projects/${id}/k8s/jobs/${namespace}/${name}/stop?cluster_id=${cluster_id}`;
 });
 
+const getAvailableRoles = baseApi<{}, { project_id: number }>(
+  "GET",
+  ({ project_id }) => `/api/projects/${project_id}/roles`
+);
+
+const updateInvite = baseApi<
+  { kind: string },
+  { project_id: number; invite_id: number }
+>(
+  "POST",
+  ({ project_id, invite_id }) =>
+    `/api/projects/${project_id}/invites/${invite_id}`
+);
+
+const getCollaborators = baseApi<{}, { project_id: number }>(
+  "GET",
+  ({ project_id }) => `/api/projects/${project_id}/collaborators`
+);
+
+const updateCollaborator = baseApi<
+  { kind: string },
+  { project_id: number; user_id: number }
+>(
+  "POST",
+  ({ project_id, user_id }) => `/api/projects/${project_id}/roles/${user_id}`
+);
+
+const removeCollaborator = baseApi<{}, { project_id: number; user_id: number }>(
+  "DELETE",
+  ({ project_id, user_id }) => `/api/projects/${project_id}/roles/${user_id}`
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -1014,4 +1047,9 @@ export default {
   upgradeChartValues,
   deleteJob,
   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 { Context } from "shared/Context";
 import {
-  ADMIN_POLICY_MOCK,
+  VIEWER_POLICY_MOCK,
   POLICY_HIERARCHY_TREE,
   populatePolicy,
+  DEV_POLICY_MOCK,
+  ADMIN_POLICY_MOCK,
 } from "./authorization-helpers";
 import { PolicyDocType } from "./types";
 
 type AuthContext = {
   currentPolicy: PolicyDocType;
+  setPolicy: (pol: "admin" | "dev" | "viewer") => void;
 };
 
 export const AuthContext = React.createContext<AuthContext>({} as AuthContext);
@@ -17,24 +20,64 @@ const AuthProvider: React.FC = ({ children }) => {
   const { user } = useContext(Context);
   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(() => {
-    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 (
-    <AuthContext.Provider value={{ currentPolicy }}>
+    <AuthContext.Provider value={{ currentPolicy, setPolicy }}>
       {children}
     </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 { AuthContext } from "./AuthContext";
 import { isAuthorized } from "./authorization-helpers";
 import { ScopeType, Verbs } from "./types";
 
-export const withAuth = <ComponentProps extends object>(
+export const GuardedComponent = <ComponentProps extends object>(
   scope: ScopeType,
   resource: string,
   verb: Verbs | Array<Verbs>
@@ -17,3 +17,38 @@ export const withAuth = <ComponentProps extends object>(
 
   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 { Redirect, Route, RouteProps } from "react-router";
 import { AuthContext } from "./AuthContext";
@@ -15,6 +16,7 @@ const GuardedRoute: React.FC<RouteProps & GuardedRouteProps> = ({
   scope,
   resource,
   verb,
+  children,
   ...rest
 }) => {
   const { currentPolicy } = useContext(AuthContext);
@@ -22,14 +24,14 @@ const GuardedRoute: React.FC<RouteProps & GuardedRouteProps> = ({
     return isAuthorized(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>(
@@ -43,7 +45,7 @@ export const fakeGuardedRoute = <ComponentProps extends object>(
     return <Component {...props} />;
   }
 
-  return <Redirect to="/" />;
+  return <UnauthorizedPage />;
 };
 
 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 = {
   scope: "project",
   verbs: ["get", "list", "create", "update", "delete"],
+};
+
+export const DEV_POLICY_MOCK: PolicyDocType = {
+  scope: "project",
+  verbs: ["get", "list", "create", "update", "delete"],
   resources: [],
   children: {
     settings: {
       scope: "settings",
+      verbs: ["get", "list"],
+      resources: [],
+    },
+  },
+};
+
+export const VIEWER_POLICY_MOCK: PolicyDocType = {
+  scope: "project",
+  verbs: ["get", "list"],
+  resources: [],
+  children: {
+    integrations: {
+      scope: "integrations",
       verbs: [],
+      resources: [],
+    },
+    settings: {
+      scope: "settings",
+      verbs: [],
+      resources: [],
+    },
+  },
+};
+
+export const POLICY_HIERARCHY_TREE: HIERARCHY_TREE = {
+  project: {
+    cluster: {
+      namespace: {
+        application: {},
+        job: {},
+        env_group: {},
+      },
     },
-  } as Record<ScopeType, PolicyDocType>,
+    settings: {},
+    integrations: {},
+  },
 };
 
 export const isAuthorized = (
   policy: PolicyDocType,
   scope: string,
-  resource: string,
+  resource: string | Array<string>,
   verb: Verbs | Array<Verbs>
 ): boolean => {
   if (!policy) {
@@ -23,11 +61,21 @@ export const isAuthorized = (
   }
 
   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 {
     const isValid =
       policy?.children &&
@@ -43,17 +91,6 @@ export const isAuthorized = (
   }
 };
 
-export const POLICY_HIERARCHY_TREE: HIERARCHY_TREE = {
-  project: {
-    cluster: {
-      namespace: {
-        application: {},
-      },
-    },
-    settings: {},
-  },
-};
-
 export const populatePolicy = (
   currPolicy: PolicyDocType,
   tree: HIERARCHY_TREE,
@@ -64,6 +101,9 @@ export const populatePolicy = (
 
   const treeKeys = Object.keys(currTree) as Array<ScopeType>;
 
+  currPolicy.children = currPolicy?.children || {};
+  currPolicy.resources = currPolicy?.resources || [];
+
   for (const child of treeKeys) {
     let childPolicy = currPolicy?.children && currPolicy?.children[child];
     if (!childPolicy) {

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

@@ -3,14 +3,17 @@ export type ScopeType =
   | "cluster"
   | "settings"
   | "namespace"
-  | "application";
+  | "application"
+  | "env_group"
+  | "job"
+  | "integrations";
 
 export type Verbs = "get" | "list" | "create" | "update" | "delete";
 
 export interface PolicyDocType {
   scope: ScopeType;
   verbs: Array<Verbs>;
-  resources: string[];
+  resources?: string[];
   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
       - ./internal:/porter/internal
       - ./server:/porter/server
+      - ./api:/porter/api
       - ./docker/kubeconfig.yaml:/porter/kubeconfig.yaml
   postgres:
     image: postgres:latest

+ 1 - 0
docker/Dockerfile

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

+ 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"`
 	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"`
 	GoogleClientSecret     string `env:"GOOGLE_CLIENT_SECRET"`

+ 2 - 0
internal/forms/invite.go

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

+ 3 - 16
internal/forms/project.go

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

+ 5 - 0
internal/models/invite.go

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

+ 0 - 8
internal/models/project.go

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

+ 3 - 2
internal/models/role.go

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

+ 6 - 4
internal/oauth/config.go

@@ -18,9 +18,10 @@ type Config struct {
 	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 {
-	AppName string
+	AppName       string
+	WebhookSecret string
 	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{
-		AppName: name,
+		AppName:       name,
+		WebhookSecret: secret,
 		Config: oauth2.Config{
 			ClientID:     cfg.ClientID,
 			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)
 }
 
+func initMultiUser(tester *tester, t *testing.T) {
+	t.Helper()
+
+	user := &models.User{
+		Email:    "example@example.com",
+		Password: "hello1234",
+	}
+
+	user, err := tester.repo.User.CreateUser(user)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initUsers = append(tester.initUsers, user)
+
+	user = &models.User{
+		Email:    "example2@example.com",
+		Password: "hello1234",
+	}
+
+	user, err = tester.repo.User.CreateUser(user)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initUsers = append(tester.initUsers, user)
+}
+
 func initProject(tester *tester, t *testing.T) {
 	t.Helper()
 

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

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

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

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

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

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

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

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

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

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

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

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

+ 4 - 0
internal/repository/project.go

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

+ 1 - 0
internal/repository/user.go

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

+ 2 - 2
server/api/api.go

@@ -170,13 +170,13 @@ func New(conf *AppConfig) (*App, error) {
 		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{
 			ClientID:     sc.GithubAppClientID,
 			ClientSecret: sc.GithubAppClientSecret,
 			Scopes:       []string{"read:user"},
 			BaseURL:      sc.ServerURL,
-		}, sc.GithubAppName)
+		}, sc.GithubAppName, sc.GithubAppWebhookSecret)
 	}
 
 	if sc.GoogleClientID != "" && sc.GoogleClientSecret != "" {

+ 28 - 0
server/api/integration_handler.go

@@ -2,6 +2,9 @@ package api
 
 import (
 	"context"
+	"crypto/hmac"
+	"crypto/sha256"
+	"encoding/hex"
 	"encoding/json"
 	"fmt"
 	"github.com/google/go-github/github"
@@ -13,6 +16,7 @@ import (
 	"net/url"
 	"sort"
 	"strconv"
+	"strings"
 
 	"github.com/go-chi/chi"
 	"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) {
 	payload, err := ioutil.ReadAll(r.Body)
 	if err != nil {
@@ -393,6 +413,14 @@ func (app *App) HandleGithubAppEvent(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	// verify webhook secret
+	signature := r.Header.Get("X-Hub-Signature-256")
+
+	if !verifySignature([]byte(app.GithubAppConf.WebhookSecret), signature, payload) {
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	}
+
 	event, err := github.ParseWebHook(github.WebHookType(r), payload)
 
 	if err != nil {

+ 43 - 1
server/api/invite_handler.go

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

+ 196 - 0
server/api/project_handler.go

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

+ 10 - 5
server/middleware/auth.go

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

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