Browse Source

Merge pull request #842 from porter-dev/0.6.0-implement-rbac

[0.6.0] - Implement basic RBAC
Nicolas Frati 5 years ago
parent
commit
451f72b935
52 changed files with 2171 additions and 306 deletions
  1. 101 0
      api/types/policy.go
  2. 4 1
      dashboard/src/components/RadioSelector.tsx
  3. 6 1
      dashboard/src/components/Table.tsx
  4. 59 0
      dashboard/src/components/UnauthorizedPage.tsx
  5. 4 1
      dashboard/src/main/MainWrapper.tsx
  6. 67 35
      dashboard/src/main/home/Home.tsx
  7. 55 18
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  8. 17 2
      dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx
  9. 19 13
      dashboard/src/main/home/cluster-dashboard/dashboard/NamespaceList.tsx
  10. 26 13
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx
  11. 57 22
      dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx
  12. 19 5
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  13. 23 13
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  14. 9 3
      dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx
  15. 10 6
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ValuesYaml.tsx
  16. 12 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobList.tsx
  17. 16 9
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx
  18. 18 14
      dashboard/src/main/home/dashboard/Dashboard.tsx
  19. 12 3
      dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx
  20. 176 0
      dashboard/src/main/home/modals/EditInviteOrCollaboratorModal.tsx
  21. 17 3
      dashboard/src/main/home/navbar/Navbar.tsx
  22. 222 32
      dashboard/src/main/home/project-settings/InviteList.tsx
  23. 22 8
      dashboard/src/main/home/project-settings/ProjectSettings.tsx
  24. 28 21
      dashboard/src/main/home/sidebar/Sidebar.tsx
  25. 44 0
      dashboard/src/shared/api.tsx
  26. 51 0
      dashboard/src/shared/auth/AuthContext.tsx
  27. 54 0
      dashboard/src/shared/auth/AuthorizationHoc.tsx
  28. 62 0
      dashboard/src/shared/auth/RouteGuard.tsx
  29. 127 0
      dashboard/src/shared/auth/authorization-helpers.ts
  30. 28 0
      dashboard/src/shared/auth/types.ts
  31. 21 0
      dashboard/src/shared/auth/useAuth.ts
  32. 1 0
      docker-compose.dev.yaml
  33. 1 0
      docker/Dockerfile
  34. 30 0
      docs/guides/authorization-and-team-management.md
  35. 2 0
      internal/forms/invite.go
  36. 3 16
      internal/forms/project.go
  37. 5 0
      internal/models/invite.go
  38. 0 8
      internal/models/project.go
  39. 3 2
      internal/models/role.go
  40. 30 0
      internal/repository/gorm/helpers_test.go
  41. 54 0
      internal/repository/gorm/project.go
  42. 117 1
      internal/repository/gorm/project_test.go
  43. 11 0
      internal/repository/gorm/user.go
  44. 34 0
      internal/repository/gorm/user_test.go
  45. 123 0
      internal/repository/memory/project.go
  46. 19 0
      internal/repository/memory/user.go
  47. 4 0
      internal/repository/project.go
  48. 1 0
      internal/repository/user.go
  49. 43 1
      server/api/invite_handler.go
  50. 196 0
      server/api/project_handler.go
  51. 10 5
      server/middleware/auth.go
  52. 98 48
      server/router/router.go

+ 101 - 0
api/types/policy.go

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

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

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

+ 6 - 1
dashboard/src/components/Table.tsx

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

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

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

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

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

+ 67 - 35
dashboard/src/main/home/Home.tsx

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

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

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

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

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

+ 19 - 13
dashboard/src/main/home/cluster-dashboard/dashboard/NamespaceList.tsx

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -5,19 +5,24 @@ import api from "shared/api";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
 
 
 import Feedback from "./Feedback";
 import Feedback from "./Feedback";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+import { Select, MenuItem } from "@material-ui/core";
+import { AuthContext } from "shared/auth/AuthContext";
 
 
-type PropsType = {
+type PropsType = WithAuthProps & {
   logOut: () => void;
   logOut: () => void;
   currentView: string;
   currentView: string;
 };
 };
 
 
 type StateType = {
 type StateType = {
   showDropdown: boolean;
   showDropdown: boolean;
+  currentPolicy: string;
 };
 };
 
 
-export default class Navbar extends Component<PropsType, StateType> {
+class Navbar extends Component<PropsType, StateType> {
   state = {
   state = {
     showDropdown: false,
     showDropdown: false,
+    currentPolicy: "admin",
   };
   };
 
 
   renderSettingsDropdown = () => {
   renderSettingsDropdown = () => {
@@ -38,7 +43,7 @@ export default class Navbar extends Component<PropsType, StateType> {
             >
             >
               <SettingsIcon>
               <SettingsIcon>
                 <i className="material-icons">settings</i>
                 <i className="material-icons">settings</i>
-              </SettingsIcon> 
+              </SettingsIcon>
               Account Settings
               Account Settings
             </UserDropdownButton>
             </UserDropdownButton>
             <UserDropdownButton onClick={this.props.logOut}>
             <UserDropdownButton onClick={this.props.logOut}>
@@ -76,6 +81,8 @@ export default class Navbar extends Component<PropsType, StateType> {
 
 
 Navbar.contextType = Context;
 Navbar.contextType = Context;
 
 
+export default withAuth(Navbar);
+
 const SettingsIcon = styled.div`
 const SettingsIcon = styled.div`
   > i {
   > i {
     background: none;
     background: none;
@@ -96,6 +103,13 @@ const I = styled.i`
   margin-right: 7px;
   margin-right: 7px;
 `;
 `;
 
 
+const PolicySelector = styled(Select)`
+  height: 30px;
+  width: 100px;
+  margin-right: 15px;
+  color: white !important;
+`;
+
 const CloseOverlay = styled.div`
 const CloseOverlay = styled.div`
   position: fixed;
   position: fixed;
   width: 100vw;
   width: 100vw;

+ 222 - 32
dashboard/src/main/home/project-settings/InviteList.tsx

@@ -18,44 +18,107 @@ import Heading from "components/values-form/Heading";
 import CopyToClipboard from "components/CopyToClipboard";
 import CopyToClipboard from "components/CopyToClipboard";
 import { Column } from "react-table";
 import { Column } from "react-table";
 import Table from "components/Table";
 import Table from "components/Table";
+import RadioSelector from "components/RadioSelector";
 
 
 type Props = {};
 type Props = {};
 
 
+export type Collaborator = {
+  id: string;
+  user_id: string;
+  project_id: string;
+  email: string;
+  kind: string;
+};
+
 const InvitePage: React.FunctionComponent<Props> = ({}) => {
 const InvitePage: React.FunctionComponent<Props> = ({}) => {
-  const { currentProject } = useContext(Context);
+  const { currentProject, setCurrentModal, user } = useContext(Context);
   const [isLoading, setIsLoading] = useState(true);
   const [isLoading, setIsLoading] = useState(true);
   const [invites, setInvites] = useState<Array<InviteType>>([]);
   const [invites, setInvites] = useState<Array<InviteType>>([]);
   const [email, setEmail] = useState("");
   const [email, setEmail] = useState("");
+  const [role, setRole] = useState("developer");
+  const [roleList, setRoleList] = useState([]);
   const [isInvalidEmail, setIsInvalidEmail] = useState(false);
   const [isInvalidEmail, setIsInvalidEmail] = useState(false);
   const [isHTTPS] = useState(() => window.location.protocol === "https:");
   const [isHTTPS] = useState(() => window.location.protocol === "https:");
 
 
   useEffect(() => {
   useEffect(() => {
-    getInviteData();
-  }, []);
+    api
+      .getAvailableRoles("<token>", {}, { project_id: currentProject.id })
+      .then(({ data }: { data: string[] }) => {
+        const availableRoleList = data?.map((role) => ({
+          value: role,
+          label: capitalizeFirstLetter(role),
+        }));
+        setRoleList(availableRoleList);
+        setRole("developer");
+      });
+
+    getData();
+  }, [currentProject]);
+
+  const capitalizeFirstLetter = (string: string) => {
+    return string.charAt(0).toUpperCase() + string.slice(1);
+  };
 
 
-  const getInviteData = () => {
+  const getData = async () => {
     setIsLoading(true);
     setIsLoading(true);
-
-    api
-      .getInvites(
+    let invites = [];
+    try {
+      const response = await api.getInvites(
         "<token>",
         "<token>",
         {},
         {},
         {
         {
           id: currentProject.id,
           id: currentProject.id,
         }
         }
-      )
-      .then((res) => {
-        setInvites(res.data);
-        setIsLoading(false);
-      })
-      .catch((err) => console.log(err));
+      );
+      invites = response.data.filter((i: InviteType) => !i.accepted);
+    } catch (err) {
+      console.log(err);
+    }
+    let collaborators: any = [];
+    try {
+      const response = await api.getCollaborators(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+        }
+      );
+      collaborators = parseCollaboratorsResponse(response.data);
+    } catch (err) {
+      console.log(err);
+    }
+    setInvites([...invites, ...collaborators]);
+    setIsLoading(false);
+  };
+
+  const parseCollaboratorsResponse = (
+    collaborators: Array<Collaborator>
+  ): Array<InviteType> => {
+    return (
+      collaborators
+        // Parse role id to number
+        .map((c) => ({ ...c, id: Number(c.id) }))
+        // Sort them so the owner will be first allways
+        .sort((curr, prev) => curr.id - prev.id)
+        // Remove the owner from list
+        .slice(1)
+        // Parse the remainings to InviteType
+        .map((c) => ({
+          email: c.email,
+          expired: false,
+          id: Number(c.user_id),
+          kind: c.kind,
+          accepted: true,
+          token: "",
+        }))
+    );
   };
   };
 
 
   const createInvite = () => {
   const createInvite = () => {
     api
     api
-      .createInvite("<token>", { email }, { id: currentProject.id })
+      .createInvite("<token>", { email, kind: role }, { id: currentProject.id })
       .then(() => {
       .then(() => {
-        getInviteData();
+        getData();
         setEmail("");
         setEmail("");
       })
       })
       .catch((err) => console.log(err));
       .catch((err) => console.log(err));
@@ -71,15 +134,19 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
           invId: inviteId,
           invId: inviteId,
         }
         }
       )
       )
-      .then(getInviteData)
+      .then(getData)
       .catch((err) => console.log(err));
       .catch((err) => console.log(err));
   };
   };
 
 
-  const replaceInvite = (inviteEmail: string, inviteId: number) => {
+  const replaceInvite = (
+    inviteEmail: string,
+    inviteId: number,
+    kind: string
+  ) => {
     api
     api
       .createInvite(
       .createInvite(
         "<token>",
         "<token>",
-        { email: inviteEmail },
+        { email: inviteEmail, kind },
         { id: currentProject.id }
         { id: currentProject.id }
       )
       )
       .then(() =>
       .then(() =>
@@ -92,7 +159,7 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
           }
           }
         )
         )
       )
       )
-      .then(getInviteData)
+      .then(getData)
       .catch((err) => console.log(err));
       .catch((err) => console.log(err));
   };
   };
 
 
@@ -107,19 +174,50 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
     createInvite();
     createInvite();
   };
   };
 
 
+  const openEditModal = (user: any) => {
+    if (setCurrentModal) {
+      setCurrentModal("EditInviteOrCollaboratorModal", {
+        user,
+        isInvite: user.status !== "accepted",
+        refetchCallerData: getData,
+      });
+    }
+  };
+
+  const removeCollaborator = (user_id: number) => {
+    try {
+      api.removeCollaborator(
+        "<token>",
+        {},
+        { project_id: currentProject.id, user_id }
+      );
+      getData();
+    } catch (error) {
+      console.log(error);
+    }
+  };
+
   const columns = useMemo<
   const columns = useMemo<
     Column<{
     Column<{
       email: string;
       email: string;
       id: number;
       id: number;
       status: string;
       status: string;
       invite_link: string;
       invite_link: string;
+      kind: string;
     }>[]
     }>[]
   >(
   >(
     () => [
     () => [
       {
       {
-        Header: "Mail address",
+        Header: "User",
         accessor: "email",
         accessor: "email",
       },
       },
+      {
+        Header: "Role",
+        accessor: "kind",
+        Cell: ({ row }) => {
+          return <Role>{row.values.kind || "Admin"}</Role>;
+        },
+      },
       {
       {
         Header: "Status",
         Header: "Status",
         accessor: "status",
         accessor: "status",
@@ -130,13 +228,19 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
         },
         },
       },
       },
       {
       {
-        Header: "Invite link",
+        Header: "",
         accessor: "invite_link",
         accessor: "invite_link",
         Cell: ({ row }) => {
         Cell: ({ row }) => {
           if (row.values.status === "expired") {
           if (row.values.status === "expired") {
             return (
             return (
               <NewLinkButton
               <NewLinkButton
-                onClick={() => replaceInvite(row.values.email, row.values.id)}
+                onClick={() =>
+                  replaceInvite(
+                    row.values.email,
+                    row.values.id,
+                    row.values.kind
+                  )
+                }
               >
               >
                 <u>Generate a new link</u>
                 <u>Generate a new link</u>
               </NewLinkButton>
               </NewLinkButton>
@@ -157,17 +261,47 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
         },
         },
       },
       },
       {
       {
-        accessor: "id",
-        Cell: ({ row }) => {
+        id: "edit_action",
+        Cell: ({ row }: any) => {
+          return <></>;
+        },
+      },
+      {
+        id: "remove_invite_action",
+        Cell: ({ row }: any) => {
           if (row.values.status === "accepted") {
           if (row.values.status === "accepted") {
-            return <CopyButton invis={true}>Remove</CopyButton>;
+            return (
+              <Flex>
+                <SettingsButton
+                  invis={row.original.currentUser}
+                  onClick={() => openEditModal(row.original)}
+                >
+                  <i className="material-icons">more_vert</i>
+                </SettingsButton>
+                <DeleteButton
+                  invis={row.original.currentUser}
+                  onClick={() => removeCollaborator(row.original.id)}
+                >
+                  <i className="material-icons">delete</i>
+                </DeleteButton>
+              </Flex>
+            );
           }
           }
           return (
           return (
-            <>
-              <CopyButton onClick={() => deleteInvite(row.values.id)}>
-                Delete Invite
-              </CopyButton>
-            </>
+            <Flex>
+              <SettingsButton
+                invis={row.original.currentUser}
+                onClick={() => openEditModal(row.original)}
+              >
+                <i className="material-icons">more_vert</i>
+              </SettingsButton>
+              <DeleteButton
+                invis={row.original.currentUser}
+                onClick={() => deleteInvite(row.original.id)}
+              >
+                <i className="material-icons">delete</i>
+              </DeleteButton>
+            </Flex>
           );
           );
         },
         },
       },
       },
@@ -187,10 +321,12 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
 
 
     const mappedInviteList = inviteList.map(
     const mappedInviteList = inviteList.map(
       ({ accepted, expired, token, ...rest }) => {
       ({ accepted, expired, token, ...rest }) => {
+        const currentUser: boolean = user.email === rest.email;
         if (accepted) {
         if (accepted) {
           return {
           return {
             status: "accepted",
             status: "accepted",
             invite_link: buildInviteLink(token),
             invite_link: buildInviteLink(token),
+            currentUser,
             ...rest,
             ...rest,
           };
           };
         }
         }
@@ -199,6 +335,7 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
           return {
           return {
             status: "expired",
             status: "expired",
             invite_link: buildInviteLink(token),
             invite_link: buildInviteLink(token),
+            currentUser,
             ...rest,
             ...rest,
           };
           };
         }
         }
@@ -206,18 +343,19 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
         return {
         return {
           status: "pending",
           status: "pending",
           invite_link: buildInviteLink(token),
           invite_link: buildInviteLink(token),
+          currentUser,
           ...rest,
           ...rest,
         };
         };
       }
       }
     );
     );
 
 
     return mappedInviteList || [];
     return mappedInviteList || [];
-  }, [invites, currentProject?.id, window?.location?.host, isHTTPS]);
+  }, [invites, currentProject?.id, window?.location?.host, isHTTPS, user?.id]);
 
 
   return (
   return (
     <>
     <>
       <Heading isAtTop={true}>Share Project</Heading>
       <Heading isAtTop={true}>Share Project</Heading>
-      <Helper>Generate a project invite for another admin user.</Helper>
+      <Helper>Generate a project invite for another user.</Helper>
       <InputRowWrapper>
       <InputRowWrapper>
         <InputRow
         <InputRow
           value={email}
           value={email}
@@ -227,6 +365,14 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
           placeholder="ex: mrp@getporter.dev"
           placeholder="ex: mrp@getporter.dev"
         />
         />
       </InputRowWrapper>
       </InputRowWrapper>
+      <Helper>Specify a role for this user.</Helper>
+      <RoleSelectorWrapper>
+        <RadioSelector
+          selected={role}
+          setSelected={setRole}
+          options={roleList}
+        />
+      </RoleSelectorWrapper>
       <ButtonWrapper>
       <ButtonWrapper>
         <InviteButton disabled={false} onClick={() => validateEmail()}>
         <InviteButton disabled={false} onClick={() => validateEmail()}>
           Create Invite
           Create Invite
@@ -243,6 +389,7 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
         <Table
         <Table
           columns={columns}
           columns={columns}
           data={data}
           data={data}
+          disableHover={true}
           isLoading={false}
           isLoading={false}
           disableGlobalFilter={true}
           disableGlobalFilter={true}
         />
         />
@@ -259,6 +406,49 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
 
 
 export default InvitePage;
 export default InvitePage;
 
 
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+  width: 70px;
+  float: right;
+  justify-content: space-between;
+`;
+
+const DeleteButton = styled.div`
+  display: flex;
+  visibility: ${(props: { invis?: boolean }) =>
+    props.invis ? "hidden" : "visible"};
+  align-items: center;
+  justify-content: center;
+  width: 30px;
+  float: right;
+  height: 30px;
+  :hover {
+    background: #ffffff11;
+    border-radius: 20px;
+    cursor: pointer;
+  }
+
+  > i {
+    font-size: 20px;
+    color: #ffffff44;
+    border-radius: 20px;
+  }
+`;
+
+const SettingsButton = styled(DeleteButton)`
+  margin-right: -60px;
+`;
+
+const Role = styled.div`
+  text-transform: capitalize;
+  margin-right: 50px;
+`;
+
+const RoleSelectorWrapper = styled.div`
+  font-size: 14px;
+`;
+
 const Placeholder = styled.div`
 const Placeholder = styled.div`
   width: 100%;
   width: 100%;
   height: 200px;
   height: 200px;

+ 22 - 8
dashboard/src/main/home/project-settings/ProjectSettings.tsx

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

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

@@ -16,14 +16,16 @@ import ProjectSectionContainer from "./ProjectSectionContainer";
 import loading from "assets/loading.gif";
 import loading from "assets/loading.gif";
 import { RouteComponentProps, withRouter } from "react-router";
 import { RouteComponentProps, withRouter } from "react-router";
 import { pushFiltered, pushQueryParams } from "shared/routing";
 import { pushFiltered, pushQueryParams } from "shared/routing";
-
-type PropsType = RouteComponentProps & {
-  forceSidebar: boolean;
-  setWelcome: (x: boolean) => void;
-  currentView: string;
-  forceRefreshClusters: boolean;
-  setRefreshClusters: (x: boolean) => void;
-};
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+
+type PropsType = RouteComponentProps &
+  WithAuthProps & {
+    forceSidebar: boolean;
+    setWelcome: (x: boolean) => void;
+    currentView: string;
+    forceRefreshClusters: boolean;
+    setRefreshClusters: (x: boolean) => void;
+  };
 
 
 type StateType = {
 type StateType = {
   showSidebar: boolean;
   showSidebar: boolean;
@@ -231,18 +233,23 @@ class Sidebar extends Component<PropsType, StateType> {
             <Img src={rocket} />
             <Img src={rocket} />
             Launch
             Launch
           </NavButton>
           </NavButton>
-          <NavButton
-            selected={currentView === "integrations"}
-            onClick={() =>
-              pushFiltered(this.props, "/integrations", ["project_id"])
-            }
-          >
-            <Img src={integrations} />
-            Integrations
-          </NavButton>
-          {this.context.currentProject.roles.filter((obj: any) => {
-            return obj.user_id === this.context.user.userId;
-          })[0].kind === "admin" && (
+
+          {this.props.isAuthorized("integrations", "", ["get"]) && (
+            <NavButton
+              selected={currentView === "integrations"}
+              onClick={() =>
+                pushFiltered(this.props, "/integrations", ["project_id"])
+              }
+            >
+              <Img src={integrations} />
+              Integrations
+            </NavButton>
+          )}
+          {this.props.isAuthorized("settings", "", [
+            "get",
+            "update",
+            "delete",
+          ]) && (
             <NavButton
             <NavButton
               onClick={() =>
               onClick={() =>
                 pushFiltered(this.props, "/project-settings", ["project_id"])
                 pushFiltered(this.props, "/project-settings", ["project_id"])
@@ -313,7 +320,7 @@ class Sidebar extends Component<PropsType, StateType> {
 
 
 Sidebar.contextType = Context;
 Sidebar.contextType = Context;
 
 
-export default withRouter(Sidebar);
+export default withRouter(withAuth(Sidebar));
 
 
 const BranchPad = styled.div`
 const BranchPad = styled.div`
   width: 20px;
   width: 20px;

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

@@ -150,6 +150,7 @@ const createGKE = baseApi<
 const createInvite = baseApi<
 const createInvite = baseApi<
   {
   {
     email: string;
     email: string;
+    kind: string;
   },
   },
   {
   {
     id: number;
     id: number;
@@ -921,6 +922,43 @@ const stopJob = baseApi<
   return `/api/projects/${id}/k8s/jobs/${namespace}/${name}/stop?cluster_id=${cluster_id}`;
   return `/api/projects/${id}/k8s/jobs/${namespace}/${name}/stop?cluster_id=${cluster_id}`;
 });
 });
 
 
+const getAvailableRoles = baseApi<{}, { project_id: number }>(
+  "GET",
+  ({ project_id }) => `/api/projects/${project_id}/roles`
+);
+
+const updateInvite = baseApi<
+  { kind: string },
+  { project_id: number; invite_id: number }
+>(
+  "POST",
+  ({ project_id, invite_id }) =>
+    `/api/projects/${project_id}/invites/${invite_id}`
+);
+
+const getCollaborators = baseApi<{}, { project_id: number }>(
+  "GET",
+  ({ project_id }) => `/api/projects/${project_id}/collaborators`
+);
+
+const updateCollaborator = baseApi<
+  { kind: string },
+  { project_id: number; user_id: number }
+>(
+  "POST",
+  ({ project_id, user_id }) => `/api/projects/${project_id}/roles/${user_id}`
+);
+
+const removeCollaborator = baseApi<{}, { project_id: number; user_id: number }>(
+  "DELETE",
+  ({ project_id, user_id }) => `/api/projects/${project_id}/roles/${user_id}`
+);
+
+const getPolicyDocument = baseApi<{}, { project_id: number }>(
+  "GET",
+  ({ project_id }) => `/api/projects/${project_id}/policy`
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
 export default {
   checkAuth,
   checkAuth,
@@ -1014,4 +1052,10 @@ export default {
   upgradeChartValues,
   upgradeChartValues,
   deleteJob,
   deleteJob,
   stopJob,
   stopJob,
+  updateInvite,
+  getAvailableRoles,
+  getCollaborators,
+  updateCollaborator,
+  removeCollaborator,
+  getPolicyDocument,
 };
 };

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

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

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

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

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

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

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

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

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

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

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

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

+ 1 - 0
docker-compose.dev.yaml

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

+ 1 - 0
docker/Dockerfile

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

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

@@ -0,0 +1,30 @@
+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
+
+**DISCLAIMER**
+
+The user has to register or access porter with the same email that the invitation was sent to.
+If not, it will not be able to look your project.
+
+# Changing Collaborator Permissions
+
+To change an invite or collaborator role, you must be logged in with an **Admin** role. As an admin, you will se a **Settings** tab in the sidebar. Navigate to **Settings** and lookup on the table the invite/collaborator that you want to change it's role, then click the button **Edit** on the row. This will open a pop up that will allow you to select the new role for that invite/collaborator.
+
+You will note that the user that created the project will not be displayed on the table, and you cannot change your own permissions.
+
+TODO: ADD SCREENSHOT OF MODAL
+
+# Removing Collaborators
+
+To remove an invite or a collaborator, you must be logged in with an **Admin** role. As an admin, you will se a **Settings** tab in the sidebar. Navigate to **Settings** and lookup on the table the invite/collaborator that you want to change it's role, then click the button **Remove** when you're removing a collaborator, or **Delete invite** when you're removing an invite.
+
+TODO: ADD SCREENSHOT OF TABLE WITH THE TWO DIFFERENT OPTIONS

+ 2 - 0
internal/forms/invite.go

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

+ 3 - 16
internal/forms/project.go

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

+ 5 - 0
internal/models/invite.go

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

+ 0 - 8
internal/models/project.go

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

+ 3 - 2
internal/models/role.go

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

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

@@ -114,6 +114,36 @@ func initUser(tester *tester, t *testing.T) {
 	tester.initUsers = append(tester.initUsers, user)
 	tester.initUsers = append(tester.initUsers, user)
 }
 }
 
 
+func initMultiUser(tester *tester, t *testing.T) {
+	t.Helper()
+
+	user := &models.User{
+		Email:    "example@example.com",
+		Password: "hello1234",
+	}
+
+	user, err := tester.repo.User.CreateUser(user)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initUsers = append(tester.initUsers, user)
+
+	user = &models.User{
+		Email:    "example2@example.com",
+		Password: "hello1234",
+	}
+
+	user, err = tester.repo.User.CreateUser(user)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initUsers = append(tester.initUsers, user)
+}
+
 func initProject(tester *tester, t *testing.T) {
 func initProject(tester *tester, t *testing.T) {
 	t.Helper()
 	t.Helper()
 
 

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

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

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

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

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

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

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

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

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

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

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

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

+ 4 - 0
internal/repository/project.go

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

+ 1 - 0
internal/repository/user.go

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

+ 43 - 1
server/api/invite_handler.go

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

+ 196 - 0
server/api/project_handler.go

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

+ 10 - 5
server/middleware/auth.go

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

+ 98 - 48
server/router/router.go

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