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

Merge branch 'nico/rbac-crud-operations' into dev

Mohammed Nafees 3 лет назад
Родитель
Сommit
abb920c3a9

+ 0 - 4
README.md

@@ -63,10 +63,6 @@ Below are instructions for a quickstart. For full documentation, please visit ou
 
 3. 🚀 Deploy your applications from a [git repository](https://docs.getporter.dev/docs/applications) or [Docker image registry](https://docs.getporter.dev/docs/cli-documentation#porter-docker-configure).
 
-## Running Porter Locally
-
-While it requires a few additional steps, it is possible to run Porter locally. Follow [this guide](https://docs.getporter.dev/docs/running-porter-locally) to run the local version of Porter.
-
 ## Want to Help?
 
 We welcome all contributions. If you're interested in contributing, please read our [contributing guide](https://github.com/porter-dev/porter/blob/master/CONTRIBUTING.md) and [join our Discord community](https://discord.gg/GJynMR3KXK).

+ 7 - 0
api/server/handlers/project_role/create.go

@@ -35,6 +35,13 @@ func (c *CreateProjectRoleHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 	user, _ := r.Context().Value(types.UserScope).(*models.User)
 
+	if !project.AdvancedRBACEnabled {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			errors.New("advanced RBAC is not enabled for this project"), http.StatusPreconditionFailed,
+		))
+		return
+	}
+
 	request := &types.CreateProjectRoleRequest{}
 
 	if ok := c.DecodeAndValidate(w, r, request); !ok {

+ 7 - 0
api/server/handlers/project_role/delete.go

@@ -32,6 +32,13 @@ func NewDeleteProjectRoleHandler(
 func (c *DeleteProjectRoleHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 
+	if !project.AdvancedRBACEnabled {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			errors.New("advanced RBAC is not enabled for this project"), http.StatusPreconditionFailed,
+		))
+		return
+	}
+
 	roleUID, reqErr := requestutils.GetURLParamString(r, types.URLParamProjectRoleID)
 
 	if reqErr != nil {

+ 2 - 9
api/server/handlers/project_role/update.go

@@ -58,14 +58,7 @@ func (c *UpdateProjectRoleHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
-	if role.IsDefaultRole() {
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
-			fmt.Errorf("cannot update default project roles"), http.StatusBadRequest,
-		))
-		return
-	}
-
-	if request.Name != "" && request.Name != role.Name {
+	if project.AdvancedRBACEnabled && !role.IsDefaultRole() && request.Name != "" && request.Name != role.Name {
 		if request.Name == string(types.RoleAdmin) ||
 			request.Name == string(types.RoleDeveloper) ||
 			request.Name == string(types.RoleViewer) {
@@ -110,7 +103,7 @@ func (c *UpdateProjectRoleHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		}
 	}
 
-	if request.Policy != nil {
+	if project.AdvancedRBACEnabled && !role.IsDefaultRole() && request.Policy != nil {
 		policy, err := c.Repo().Policy().ReadPolicy(project.ID, role.PolicyUID)
 
 		if err != nil {

+ 1 - 0
api/types/project.go

@@ -9,6 +9,7 @@ type Project struct {
 	ManagedInfraEnabled bool    `json:"managed_infra_enabled"`
 	APITokensEnabled    bool    `json:"api_tokens_enabled"`
 	StacksEnabled       bool    `json:"stacks_enabled"`
+	AdvancedRBACEnabled bool    `json:"advanced_rbac_enabled"`
 }
 
 type FeatureFlags struct {

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

@@ -27,11 +27,12 @@ export type Collaborator = {
   roles: string[];
 };
 
-const InvitePage: React.FunctionComponent<Props> = ({ }) => {
+const InvitePage: React.FunctionComponent<Props> = ({}) => {
   const {
     currentProject,
     setCurrentModal,
     setCurrentError,
+    setCurrentOverlay,
     user,
     usage,
     hasBillingEnabled,
@@ -40,8 +41,6 @@ const InvitePage: React.FunctionComponent<Props> = ({ }) => {
   const [isLoading, setIsLoading] = useState(true);
   const [invites, setInvites] = useState<Array<InviteType>>([]);
   const [email, setEmail] = useState("");
-  const [role, setRole] = useState("developer");
-  const [roleList, setRoleList] = useState([]);
   const [isInvalidEmail, setIsInvalidEmail] = useState(false);
   const [isHTTPS] = useState(() => window.location.protocol === "https:");
 
@@ -49,17 +48,6 @@ const InvitePage: React.FunctionComponent<Props> = ({ }) => {
   const [selectedRoles, setSelectedRoles] = useState<Role[]>([]);
 
   useEffect(() => {
-    api
-      .getAvailableRoles("<token>", {}, { project_id: currentProject?.id })
-      .then(({ data }: { data: string[] }) => {
-        const availableRoleList = data?.map((role) => ({
-          value: role,
-          label: capitalizeFirstLetter(role),
-        }));
-        setRoleList(availableRoleList);
-        setRole("developer");
-      });
-
     api
       .listRoles("<token>", {}, { project_id: currentProject?.id })
       .then((res) => setRoles(res.data));
@@ -67,10 +55,6 @@ const InvitePage: React.FunctionComponent<Props> = ({ }) => {
     getData();
   }, [currentProject]);
 
-  const capitalizeFirstLetter = (string: string) => {
-    return string.charAt(0).toUpperCase() + string.slice(1);
-  };
-
   const getData = async () => {
     setIsLoading(true);
     let invites = [];
@@ -132,7 +116,11 @@ const InvitePage: React.FunctionComponent<Props> = ({ }) => {
     api
       .createInvite(
         "<token>",
-        { email, kind: role, roles: selectedRoles.map((role) => role.id) },
+        {
+          email,
+          kind: "developer",
+          roles: selectedRoles.map((role) => role.id),
+        },
         { id: currentProject.id }
       )
       .then(() => {
@@ -217,13 +205,14 @@ const InvitePage: React.FunctionComponent<Props> = ({ }) => {
   };
 
   const removeCollaborator = (user_id: number) => {
+    const project_id = currentProject.id;
     try {
       api.removeCollaborator(
         "<token>",
         {},
         {
-          project_id: currentProject.id,
-          user_id: user.id
+          project_id,
+          user_id,
         }
       );
       getData();
@@ -319,7 +308,18 @@ const InvitePage: React.FunctionComponent<Props> = ({ }) => {
                 </SettingsButton>
                 <DeleteButton
                   invis={row.original.currentUser}
-                  onClick={() => removeCollaborator(row.original.id)}
+                  onClick={() => {
+                    setCurrentOverlay({
+                      message: `Are you sure you want to remove user ${row.original.email} from the project?`,
+                      onYes: () => {
+                        removeCollaborator(row.original.id);
+                        setCurrentOverlay(null);
+                      },
+                      onNo: () => {
+                        setCurrentOverlay(null);
+                      },
+                    });
+                  }}
                 >
                   <i className="material-icons">delete</i>
                 </DeleteButton>
@@ -350,11 +350,12 @@ const InvitePage: React.FunctionComponent<Props> = ({ }) => {
 
   const data = useMemo(() => {
     const inviteList = [...invites];
-    inviteList.sort((a: any, b: any) => (a.email > b.email ? 1 : -1));
-    inviteList.sort((a: any, b: any) => (a.accepted > b.accepted ? 1 : -1));
+    inviteList.sort((a, b) => (a.email > b.email ? 1 : -1));
+    inviteList.sort((a, b) => (a.accepted > b.accepted ? 1 : -1));
     const buildInviteLink = (token: string) => `
-      ${isHTTPS ? "https://" : ""}${window.location.host}/api/projects/${currentProject.id
-      }/invites/${token}
+      ${isHTTPS ? "https://" : ""}${window.location.host}/api/projects/${
+      currentProject.id
+    }/invites/${token}
     `;
 
     if (!user) {

+ 28 - 0
dashboard/src/main/home/project-settings/roles-admin/RolesAdmin.tsx

@@ -1,5 +1,8 @@
+import Helper from "components/form-components/Helper";
+import Placeholder from "components/Placeholder";
 import React, { useContext, useEffect, useState } from "react";
 import { Context } from "shared/Context";
+import styled from "styled-components";
 import CreateRole from "./pages/CreateRole";
 import EditRole from "./pages/EditRole";
 import ListRoles from "./pages/ListRoles";
@@ -12,12 +15,30 @@ type AVAILABLE_PAGES_TYPE = typeof AVAILABLE_PAGES[number];
 export type Navigate = (page: AVAILABLE_PAGES_TYPE) => void;
 
 export const RolesAdmin = () => {
+  const { currentProject } = useContext(Context);
   const [page, setPage] = useState<AVAILABLE_PAGES_TYPE>("index");
 
   const navigate: Navigate = (page) => {
     setPage(page);
   };
 
+  if (!currentProject.advanced_rbac_enabled) {
+    return (
+      <Placeholder height="250px">
+        <PlaceHolderContent>
+          <h2>Advanced RBAC is not enabled for this project.</h2>
+          <Helper>
+            Please{" "}
+            <a target="_blank" href="mailto:contact@porter.run">
+              contact us
+            </a>{" "}
+            to view plans.
+          </Helper>
+        </PlaceHolderContent>
+      </Placeholder>
+    );
+  }
+
   return (
     <>
       <RolesAdminProvider>
@@ -28,3 +49,10 @@ export const RolesAdmin = () => {
     </>
   );
 };
+
+const PlaceHolderContent = styled.div`
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+`;

+ 90 - 21
dashboard/src/main/home/project-settings/roles-admin/components/PolicyDocumentRenderer.tsx

@@ -1,5 +1,5 @@
-import { capitalize, get, set } from "lodash";
-import React, { useCallback, useContext, useEffect } from "react";
+import _, { capitalize, get, set } from "lodash";
+import React, { useContext, useEffect, useRef } from "react";
 import api from "shared/api";
 import {
   POLICY_HIERARCHY_TREE,
@@ -30,8 +30,28 @@ const PolicyDocumentRenderer = ({
   onChange: (data: PolicyDocType) => void;
   readOnly?: boolean;
 }) => {
-  const { currentProject } = useContext(Context);
+  const { currentProject, setCurrentOverlay } = useContext(Context);
   const [scopeHierarchy, setScopeHierarchy] = React.useState<any>(null);
+  const emptyPolicyDoc = useRef(
+    populatePolicy({
+      scope: "project",
+      verbs: [],
+    })
+  );
+
+  useEffect(() => {
+    if (!scopeHierarchy) {
+      return;
+    }
+
+    emptyPolicyDoc.current = populatePolicy(
+      {
+        scope: "project",
+        verbs: [],
+      },
+      scopeHierarchy
+    );
+  }, [scopeHierarchy]);
 
   useEffect(() => {
     api
@@ -44,12 +64,41 @@ const PolicyDocumentRenderer = ({
       });
   }, [currentProject?.id]);
 
-  const handleChangeVerbs = (dataPath: string, verbs: Verbs[]) => {
+  const handleChangeVerbs = (verbsPath: string, verbs: Verbs[]) => {
     const newPolicyDoc = structuredClone(value) as PolicyDocType;
+    const pathToChildren = verbsPath
+      .split(".")
+      .slice(0, -1)
+      .concat(["children"])
+      .join(".");
+
+    const isReadRemovedTransitive =
+      !verbs.includes("get") &&
+      Object.keys(_.get(newPolicyDoc, pathToChildren, {})).length;
+
+    if (isReadRemovedTransitive) {
+      const emptyPolicyDocumentForChildren = get(
+        emptyPolicyDoc.current,
+        pathToChildren
+      );
+
+      set(newPolicyDoc, pathToChildren, emptyPolicyDocumentForChildren);
+    }
 
-    set(newPolicyDoc, dataPath, verbs);
+    set(newPolicyDoc, verbsPath, verbs);
 
-    onChange(newPolicyDoc);
+    if (isReadRemovedTransitive) {
+      setCurrentOverlay({
+        message: `Dummy text?`,
+        onYes: () => {
+          onChange(newPolicyDoc);
+          setCurrentOverlay(null);
+        },
+        onNo: () => setCurrentOverlay(null),
+      });
+    } else {
+      onChange(newPolicyDoc);
+    }
   };
 
   if (!scopeHierarchy) {
@@ -111,13 +160,13 @@ const RenderComponents = (
 
   const Component = (
     <>
-      <Card anidationLevel={anidationLevel}>
-        <ScopePermissionsHandler
-          name={scope}
-          dataPath={verbsPath}
-          readOnly={readOnly}
-        />
-      </Card>
+      <ScopePermissionsHandler
+        name={scope}
+        parent={dataPath.split(".").slice(0, -1).join(".")}
+        dataPath={verbsPath}
+        readOnly={readOnly}
+        anidationLevel={anidationLevel}
+      />
       {components.map((c) => c)}
     </>
   );
@@ -135,28 +184,48 @@ const Card = styled.div<{ anidationLevel: number }>`
 `;
 
 const ScopePermissionsHandler = ({
+  anidationLevel,
+  parent,
   name,
   dataPath,
   readOnly,
 }: {
+  anidationLevel: number;
+  parent: string;
   name: string;
   dataPath: string;
   readOnly: boolean;
 }) => {
   const { handleChangeVerbs, data } = React.useContext(Store);
+  const { setCurrentError } = React.useContext(Context);
 
   const verbs = get(data, dataPath);
 
+  const onChange = (newVerbs: Verbs[]) => {
+    const pathToParentsVerbs = parent.split(".").concat("verbs").join(".");
+
+    const isActionAllowed =
+      anidationLevel === 0
+        ? true
+        : get(data, pathToParentsVerbs, []).includes("get");
+
+    if (isActionAllowed) {
+      handleChangeVerbs(dataPath, newVerbs);
+      return;
+    }
+
+    setCurrentError(
+      "This action is not allowed since the parent does not have read permissions enabled."
+    );
+
+    // show something to tell user
+  };
+
   return (
-    <>
+    <Card anidationLevel={anidationLevel}>
       {name}
-      {readOnly ? null : (
-        <Select
-          values={verbs}
-          onChange={(newVerbs) => handleChangeVerbs(dataPath, newVerbs)}
-        />
-      )}
-    </>
+      {readOnly ? null : <Select values={verbs} onChange={onChange} />}
+    </Card>
   );
 };
 

+ 4 - 7
dashboard/src/shared/Context.tsx

@@ -4,6 +4,7 @@ import {
   CapabilityType,
   ClusterType,
   ContextProps,
+  OverlayData,
   ProjectType,
   UsageData,
 } from "shared/types";
@@ -27,12 +28,8 @@ export interface GlobalContextType {
   currentModal: string;
   currentModalData: any;
   setCurrentModal: (currentModal: string, currentModalData?: any) => void;
-  currentOverlay: {
-    message: string;
-    onYes: any;
-    onNo: any;
-  };
-  setCurrentOverlay: (x: any) => void;
+  currentOverlay: OverlayData;
+  setCurrentOverlay: (x: OverlayData) => void;
   currentError: string | null;
   setCurrentError: (currentError: string) => void;
   currentCluster: ClusterType;
@@ -85,7 +82,7 @@ class ContextProvider extends Component<PropsType, StateType> {
       this.setState({ currentModal, currentModalData });
     },
     currentOverlay: null,
-    setCurrentOverlay: (x: any) => this.setState({ currentOverlay: x }),
+    setCurrentOverlay: (x: OverlayData) => this.setState({ currentOverlay: x }),
     currentError: null,
     setCurrentError: (currentError: string) => {
       this.setState({ currentError });

+ 9 - 6
dashboard/src/shared/types.tsx

@@ -260,6 +260,7 @@ export interface ProjectType {
   managed_infra_enabled: boolean;
   api_tokens_enabled: boolean;
   stacks_enabled: boolean;
+  advanced_rbac_enabled: boolean;
   roles: {
     id: number;
     kind: string;
@@ -328,16 +329,18 @@ export interface CapabilityType {
   provisioner: boolean;
 }
 
+export type OverlayData = {
+  message: string;
+  onYes: () => void;
+  onNo: () => void;
+};
+
 export interface ContextProps {
   currentModal?: string;
   currentModalData: any;
   setCurrentModal: (currentModal: string, currentModalData?: any) => void;
-  currentOverlay: {
-    message: string;
-    onYes: any;
-    onNo: any;
-  };
-  setCurrentOverlay: (x: any) => void;
+  currentOverlay: OverlayData;
+  setCurrentOverlay: (x: OverlayData) => void;
   currentError?: string;
   setCurrentError: (currentError: string) => void;
   currentCluster?: ClusterType;

+ 2 - 0
internal/models/project.go

@@ -63,6 +63,7 @@ type Project struct {
 	ManagedInfraEnabled bool
 	StacksEnabled       bool
 	APITokensEnabled    bool
+	AdvancedRBACEnabled bool
 }
 
 // ToProjectType generates an external types.Project to be shared over REST
@@ -82,5 +83,6 @@ func (p *Project) ToProjectType() *types.Project {
 		ManagedInfraEnabled: p.ManagedInfraEnabled,
 		StacksEnabled:       p.StacksEnabled,
 		APITokensEnabled:    p.APITokensEnabled,
+		AdvancedRBACEnabled: p.AdvancedRBACEnabled,
 	}
 }

+ 18 - 1
internal/repository/gorm/project_role.go

@@ -1,6 +1,9 @@
 package gorm
 
 import (
+	"fmt"
+
+	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 	"gorm.io/gorm"
@@ -18,6 +21,16 @@ func NewProjectRoleRepository(db *gorm.DB) repository.ProjectRoleRepository {
 }
 
 func (repo *ProjectRoleRepository) CreateProjectRole(role *models.ProjectRole) (*models.ProjectRole, error) {
+	proj := &models.Project{}
+
+	if err := repo.db.Where("id = ?", role.ProjectID).First(proj).Error; err != nil {
+		return nil, fmt.Errorf("error creating role for project: %w", err)
+	}
+
+	if !role.IsDefaultRole() && !proj.AdvancedRBACEnabled {
+		return nil, fmt.Errorf("advanced RBAC is not enabled for this project")
+	}
+
 	if err := repo.db.Create(role).Error; err != nil {
 		return nil, err
 	}
@@ -99,10 +112,14 @@ func (repo *ProjectRoleRepository) UpdateUsersInProjectRole(projectID uint, role
 func (repo *ProjectRoleRepository) ClearUsersInProjectRole(projectID uint, roleUID string) error {
 	role := &models.ProjectRole{}
 
-	if err := repo.db.Where("project_id = ? AND unique_id = ?", projectID, roleUID).First(role).Error; err != nil {
+	if err := repo.db.Preload("Users").Where("project_id = ? AND unique_id = ?", projectID, roleUID).First(role).Error; err != nil {
 		return err
 	}
 
+	if role.UniqueID == fmt.Sprintf("%d-%s", role.ProjectID, types.RoleAdmin) && len(role.Users) == 1 {
+		return fmt.Errorf("cannot remove the last admin from this project")
+	}
+
 	assoc := repo.db.Model(&role).Association("Users")
 
 	if assoc.Error != nil {