Przeglądaj źródła

Merge pull request #865 from porter-dev/0.6.0-implement-authorization-module

[0.6.0] - Implement authorization module
Nicolas Frati 4 lat temu
rodzic
commit
45198353dd
24 zmienionych plików z 677 dodań i 243 usunięć
  1. 59 0
      dashboard/src/components/UnauthorizedPage.tsx
  2. 4 1
      dashboard/src/main/MainWrapper.tsx
  3. 60 41
      dashboard/src/main/home/Home.tsx
  4. 55 18
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  5. 17 2
      dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx
  6. 26 20
      dashboard/src/main/home/cluster-dashboard/dashboard/NamespaceList.tsx
  7. 26 13
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx
  8. 57 22
      dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx
  9. 19 5
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  10. 23 13
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  11. 9 3
      dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx
  12. 10 6
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ValuesYaml.tsx
  13. 12 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobList.tsx
  14. 16 9
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx
  15. 16 12
      dashboard/src/main/home/dashboard/Dashboard.tsx
  16. 12 3
      dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx
  17. 32 2
      dashboard/src/main/home/navbar/Navbar.tsx
  18. 34 25
      dashboard/src/main/home/sidebar/Sidebar.tsx
  19. 58 15
      dashboard/src/shared/auth/AuthContext.tsx
  20. 37 2
      dashboard/src/shared/auth/AuthorizationHoc.tsx
  21. 11 9
      dashboard/src/shared/auth/RouteGuard.tsx
  22. 58 18
      dashboard/src/shared/auth/authorization-helpers.ts
  23. 5 2
      dashboard/src/shared/auth/types.ts
  24. 21 0
      dashboard/src/shared/auth/useAuth.ts

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

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

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

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

+ 60 - 41
dashboard/src/main/home/Home.tsx

@@ -26,13 +26,27 @@ import ProjectSettings from "./project-settings/ProjectSettings";
 import Sidebar from "./sidebar/Sidebar";
 import PageNotFound from "components/PageNotFound";
 import DeleteNamespaceModal from "./modals/DeleteNamespaceModal";
-
-type PropsType = RouteComponentProps & {
-  logOut: () => void;
-  currentProject: ProjectType;
-  currentCluster: ClusterType;
-  currentRoute: PorterUrl;
-};
+import { fakeGuardedRoute } from "shared/auth/RouteGuard";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+
+// Guarded components
+const GuardedProjectSettings = fakeGuardedRoute("settings", "", [
+  "get",
+  "list",
+])(ProjectSettings);
+
+const GuardedIntegrations = fakeGuardedRoute("integrations", "", [
+  "get",
+  "list",
+])(Integrations);
+
+type PropsType = RouteComponentProps &
+  WithAuthProps & {
+    logOut: () => void;
+    currentProject: ProjectType;
+    currentCluster: ClusterType;
+    currentRoute: PorterUrl;
+  };
 
 type StateType = {
   forceSidebar: boolean;
@@ -336,9 +350,9 @@ class Home extends Component<PropsType, StateType> {
           </DashboardWrapper>
         );
       } else if (currentView === "integrations") {
-        return <Integrations />;
+        return <GuardedIntegrations />;
       } else if (currentView === "project-settings") {
-        return <ProjectSettings />;
+        return <GuardedProjectSettings />;
       }
       return <Templates />;
     } else if (currentView === "new-project") {
@@ -471,19 +485,22 @@ class Home extends Component<PropsType, StateType> {
             <ClusterInstructionsModal />
           </Modal>
         )}
-        {currentModal === "UpdateClusterModal" && (
-          <Modal
-            onRequestClose={() => setCurrentModal(null, null)}
-            width="565px"
-            height="275px"
-          >
-            <UpdateClusterModal
-              setRefreshClusters={(x: boolean) =>
-                this.setState({ forceRefreshClusters: x })
-              }
-            />
-          </Modal>
-        )}
+
+        {/* We should be careful, as this component is named Update but is for deletion */}
+        {this.props.isAuthorized("cluster", "", ["get", "delete"]) &&
+          currentModal === "UpdateClusterModal" && (
+            <Modal
+              onRequestClose={() => setCurrentModal(null, null)}
+              width="565px"
+              height="275px"
+            >
+              <UpdateClusterModal
+                setRefreshClusters={(x: boolean) =>
+                  this.setState({ forceRefreshClusters: x })
+                }
+              />
+            </Modal>
+          )}
         {currentModal === "IntegrationsModal" && (
           <Modal
             onRequestClose={() => setCurrentModal(null, null)}
@@ -502,24 +519,26 @@ class Home extends Component<PropsType, StateType> {
             <IntegrationsInstructionsModal />
           </Modal>
         )}
-        {currentModal === "NamespaceModal" && (
-          <Modal
-            onRequestClose={() => setCurrentModal(null, null)}
-            width="600px"
-            height="220px"
-          >
-            <NamespaceModal />
-          </Modal>
-        )}
-        {currentModal === "DeleteNamespaceModal" && (
-          <Modal
-            onRequestClose={() => setCurrentModal(null, null)}
-            width="700px"
-            height="280px"
-          >
-            <DeleteNamespaceModal />
-          </Modal>
-        )}
+        {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>
+          )}
 
         {this.renderSidebar()}
 
@@ -548,7 +567,7 @@ class Home extends Component<PropsType, StateType> {
 
 Home.contextType = Context;
 
-export default withRouter(Home);
+export default withRouter(withAuth(Home));
 
 const ViewWrapper = styled.div`
   height: 100%;

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

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

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

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

+ 26 - 20
dashboard/src/main/home/cluster-dashboard/dashboard/NamespaceList.tsx

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 16 - 12
dashboard/src/main/home/dashboard/Dashboard.tsx

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

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

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

+ 32 - 2
dashboard/src/main/home/navbar/Navbar.tsx

@@ -5,19 +5,24 @@ import api from "shared/api";
 import { Context } from "shared/Context";
 
 import Feedback from "./Feedback";
+import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+import { Select, MenuItem } from "@material-ui/core";
+import { AuthContext } from "shared/auth/AuthContext";
 
-type PropsType = {
+type PropsType = WithAuthProps & {
   logOut: () => void;
   currentView: string;
 };
 
 type StateType = {
   showDropdown: boolean;
+  currentPolicy: string;
 };
 
-export default class Navbar extends Component<PropsType, StateType> {
+class Navbar extends Component<PropsType, StateType> {
   state = {
     showDropdown: false,
+    currentPolicy: "admin",
   };
 
   renderSettingsDropdown = () => {
@@ -49,6 +54,22 @@ export default class Navbar extends Component<PropsType, StateType> {
   render() {
     return (
       <StyledNavbar>
+        <AuthContext.Consumer>
+          {(value) => (
+            <PolicySelector
+              value={this.state.currentPolicy}
+              onChange={(e) => {
+                value.setPolicy(e.target.value as any);
+                this.setState({ currentPolicy: e.target.value as string });
+              }}
+            >
+              <MenuItem value={"admin"}>Admin</MenuItem>
+              <MenuItem value={"dev"}>Dev</MenuItem>
+              <MenuItem value={"viewer"}>Viewer</MenuItem>
+            </PolicySelector>
+          )}
+        </AuthContext.Consumer>
+
         {this.renderFeedbackButton()}
         <NavButton
           selected={this.state.showDropdown}
@@ -67,10 +88,19 @@ export default class Navbar extends Component<PropsType, StateType> {
 
 Navbar.contextType = Context;
 
+export default withAuth(Navbar);
+
 const I = styled.i`
   margin-right: 7px;
 `;
 
+const PolicySelector = styled(Select)`
+  height: 30px;
+  width: 100px;
+  margin-right: 15px;
+  color: white !important;
+`;
+
 const CloseOverlay = styled.div`
   position: fixed;
   width: 100vw;

+ 34 - 25
dashboard/src/main/home/sidebar/Sidebar.tsx

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

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

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

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

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

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

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

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

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

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

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

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

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