فهرست منبع

Merge pull request #866 from porter-dev/0.6.0-implement-rbac-settings-invite

Implement RBAC on settings/invite list page
Nicolas Frati 4 سال پیش
والد
کامیت
2bc8344b5d

+ 101 - 0
api/types/policy.go

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

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

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

+ 11 - 0
dashboard/src/main/home/Home.tsx

@@ -28,6 +28,7 @@ import PageNotFound from "components/PageNotFound";
 import DeleteNamespaceModal from "./modals/DeleteNamespaceModal";
 import { fakeGuardedRoute } from "shared/auth/RouteGuard";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+import EditInviteOrCollaboratorModal from "./modals/EditInviteOrCollaboratorModal";
 
 // Guarded components
 const GuardedProjectSettings = fakeGuardedRoute("settings", "", [
@@ -540,6 +541,16 @@ class Home extends Component<PropsType, StateType> {
             </Modal>
           )}
 
+        {currentModal === "EditInviteOrCollaboratorModal" && (
+          <Modal
+            onRequestClose={() => setCurrentModal(null, null)}
+            width="600px"
+            height="250px"
+          >
+            <EditInviteOrCollaboratorModal />
+          </Modal>
+        )}
+
         {this.renderSidebar()}
 
         <ViewWrapper>

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

@@ -158,9 +158,9 @@ class Dashboard extends Component<PropsType, StateType> {
                     </Overlay>
                   </DashboardIcon>
                   <Title>{currentProject && currentProject.name}</Title>
-                  {this.context.currentProject.roles.filter((obj: any) => {
+                  {this.context.currentProject?.roles?.filter((obj: any) => {
                     return obj.user_id === this.context.user.userId;
-                  })[0].kind === "admin" && (
+                  })[0].kind === "admin" || (
                     <i
                       className="material-icons"
                       onClick={onShowProjectSettings}

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

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

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

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

+ 16 - 18
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -233,6 +233,7 @@ class Sidebar extends Component<PropsType, StateType> {
             <Img src={rocket} />
             Launch
           </NavButton>
+
           {this.props.isAuthorized("integrations", "", ["get"]) && (
             <NavButton
               selected={currentView === "integrations"}
@@ -244,24 +245,21 @@ class Sidebar extends Component<PropsType, StateType> {
               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>
-            )}
+          {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 />
 

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

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

+ 1 - 0
docker-compose.dev.yaml

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

+ 1 - 0
docker/Dockerfile

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

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

@@ -0,0 +1,17 @@
+
+Porter supports setting basic authorization permissions via for other members in a Porter project. At the moment, there are 3 roles that can be assigned in a Porter project:
+- **Admin:** read/write access to all resources, ability to delete the project and manage team members.
+- **Developer:** read/write access to applications, jobs, environment groups, cluster data, and integrations. 
+- **Viewer:** read access to applications, jobs, environment groups, and cluster data. 
+
+# Adding Collaborators
+
+To add a new collaborator to a Porter project, you must be logged in with an **Admin** role. As an admin, you will see a **Settings** tab in the sidebar. Navigate to **Settings** and input the email of the user you would like to add. This will generate an invitation link for the user, which expires in 24 hours. The user will get an email to join the Porter project, but if the email is not delivered, you can copy the invite link and send it to them directly. 
+
+TODO: ADD SCREENSHOT
+
+TODO: ADD NOTE ABOUT LOGGING IN AND ACCEPTING INVITE
+
+# Changing Collaborator Permissions
+
+# Removing Collaborators

+ 2 - 0
internal/forms/invite.go

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

+ 3 - 16
internal/forms/project.go

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

+ 5 - 0
internal/models/invite.go

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

+ 0 - 8
internal/models/project.go

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

+ 3 - 2
internal/models/role.go

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 4 - 0
internal/repository/project.go

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

+ 1 - 0
internal/repository/user.go

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

+ 43 - 1
server/api/invite_handler.go

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

+ 196 - 0
server/api/project_handler.go

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

+ 10 - 5
server/middleware/auth.go

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

+ 98 - 48
server/router/router.go

@@ -254,6 +254,46 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
+			r.Method(
+				"GET",
+				"/projects/{project_id}/policy",
+				auth.DoesUserHaveProjectAccess(
+					requestlog.NewHandler(a.HandleReadProjectPolicy, l),
+					mw.URLParam,
+					mw.ReadAccess,
+				),
+			)
+
+			r.Method(
+				"GET",
+				"/projects/{project_id}/roles",
+				auth.DoesUserHaveProjectAccess(
+					requestlog.NewHandler(a.HandleGetProjectRoles, l),
+					mw.URLParam,
+					mw.AdminAccess,
+				),
+			)
+
+			r.Method(
+				"GET",
+				"/projects/{project_id}/collaborators",
+				auth.DoesUserHaveProjectAccess(
+					requestlog.NewHandler(a.HandleListProjectCollaborators, l),
+					mw.URLParam,
+					mw.AdminAccess,
+				),
+			)
+
+			r.Method(
+				"POST",
+				"/projects/{project_id}/roles/{user_id}",
+				auth.DoesUserHaveProjectAccess(
+					requestlog.NewHandler(a.HandleUpdateProjectRole, l),
+					mw.URLParam,
+					mw.AdminAccess,
+				),
+			)
+
 			r.Method(
 				"POST",
 				"/projects",
@@ -268,7 +308,17 @@ func New(a *api.App) *chi.Mux {
 				auth.DoesUserHaveProjectAccess(
 					requestlog.NewHandler(a.HandleDeleteProject, l),
 					mw.URLParam,
-					mw.WriteAccess,
+					mw.AdminAccess,
+				),
+			)
+
+			r.Method(
+				"DELETE",
+				"/projects/{project_id}/roles/{user_id}",
+				auth.DoesUserHaveProjectAccess(
+					requestlog.NewHandler(a.HandleDeleteProjectRole, l),
+					mw.URLParam,
+					mw.AdminAccess,
 				),
 			)
 
@@ -283,7 +333,7 @@ func New(a *api.App) *chi.Mux {
 						mw.QueryParam,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -294,7 +344,7 @@ func New(a *api.App) *chi.Mux {
 				auth.DoesUserHaveProjectAccess(
 					requestlog.NewHandler(a.HandleCreateInvite, l),
 					mw.URLParam,
-					mw.WriteAccess,
+					mw.AdminAccess,
 				),
 			)
 
@@ -304,7 +354,7 @@ func New(a *api.App) *chi.Mux {
 				auth.DoesUserHaveProjectAccess(
 					requestlog.NewHandler(a.HandleListProjectInvites, l),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.AdminAccess,
 				),
 			)
 
@@ -316,6 +366,20 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
+			r.Method(
+				"POST",
+				"/projects/{project_id}/invites/{invite_id}",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveInviteAccess(
+						requestlog.NewHandler(a.HandleUpdateInviteRole, l),
+						mw.URLParam,
+						mw.URLParam,
+					),
+					mw.URLParam,
+					mw.AdminAccess,
+				),
+			)
+
 			r.Method(
 				"DELETE",
 				"/projects/{project_id}/invites/{invite_id}",
@@ -326,7 +390,7 @@ func New(a *api.App) *chi.Mux {
 						mw.URLParam,
 					),
 					mw.URLParam,
-					mw.WriteAccess,
+					mw.AdminAccess,
 				),
 			)
 
@@ -348,7 +412,7 @@ func New(a *api.App) *chi.Mux {
 				auth.DoesUserHaveProjectAccess(
 					requestlog.NewHandler(a.HandleProvisionTestInfra, l),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -363,7 +427,7 @@ func New(a *api.App) *chi.Mux {
 						false,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -378,7 +442,7 @@ func New(a *api.App) *chi.Mux {
 						false,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -393,7 +457,7 @@ func New(a *api.App) *chi.Mux {
 						false,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -408,7 +472,7 @@ func New(a *api.App) *chi.Mux {
 						false,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -423,7 +487,7 @@ func New(a *api.App) *chi.Mux {
 						false,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -438,7 +502,7 @@ func New(a *api.App) *chi.Mux {
 						false,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -456,20 +520,6 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
-			r.Method(
-				"POST",
-				"/projects/{project_id}/provision/{kind}/{infra_id}/logs",
-				auth.DoesUserHaveProjectAccess(
-					auth.DoesUserHaveInfraAccess(
-						requestlog.NewHandler(a.HandleGetProvisioningLogs, l),
-						mw.URLParam,
-						mw.URLParam,
-					),
-					mw.URLParam,
-					mw.ReadAccess,
-				),
-			)
-
 			r.Method(
 				"POST",
 				"/projects/{project_id}/infra/{infra_id}/ecr/destroy",
@@ -480,7 +530,7 @@ func New(a *api.App) *chi.Mux {
 						mw.URLParam,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -494,7 +544,7 @@ func New(a *api.App) *chi.Mux {
 						mw.URLParam,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -508,7 +558,7 @@ func New(a *api.App) *chi.Mux {
 						mw.URLParam,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -522,7 +572,7 @@ func New(a *api.App) *chi.Mux {
 						mw.URLParam,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -536,7 +586,7 @@ func New(a *api.App) *chi.Mux {
 						mw.URLParam,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -550,7 +600,7 @@ func New(a *api.App) *chi.Mux {
 						mw.URLParam,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -773,7 +823,7 @@ func New(a *api.App) *chi.Mux {
 				auth.DoesUserHaveProjectAccess(
 					requestlog.NewHandler(a.HandleListProjectHelmRepos, l),
 					mw.URLParam,
-					mw.WriteAccess,
+					mw.ReadAccess,
 				),
 			)
 
@@ -783,7 +833,7 @@ func New(a *api.App) *chi.Mux {
 				auth.DoesUserHaveProjectAccess(
 					requestlog.NewHandler(a.HandleListHelmRepoCharts, l),
 					mw.URLParam,
-					mw.WriteAccess,
+					mw.ReadAccess,
 				),
 			)
 
@@ -819,7 +869,7 @@ func New(a *api.App) *chi.Mux {
 				auth.DoesUserHaveProjectAccess(
 					requestlog.NewHandler(a.HandleListProjectRegistries, l),
 					mw.URLParam,
-					mw.WriteAccess,
+					mw.ReadAccess,
 				),
 			)
 
@@ -833,7 +883,7 @@ func New(a *api.App) *chi.Mux {
 						mw.URLParam,
 					),
 					mw.URLParam,
-					mw.WriteAccess,
+					mw.ReadAccess,
 				),
 			)
 
@@ -933,7 +983,7 @@ func New(a *api.App) *chi.Mux {
 						mw.URLParam,
 					),
 					mw.URLParam,
-					mw.WriteAccess,
+					mw.ReadAccess,
 				),
 			)
 
@@ -1366,7 +1416,7 @@ func New(a *api.App) *chi.Mux {
 						mw.QueryParam,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -1394,7 +1444,7 @@ func New(a *api.App) *chi.Mux {
 						mw.QueryParam,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -1408,7 +1458,7 @@ func New(a *api.App) *chi.Mux {
 						mw.QueryParam,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -1450,7 +1500,7 @@ func New(a *api.App) *chi.Mux {
 						mw.QueryParam,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -1478,7 +1528,7 @@ func New(a *api.App) *chi.Mux {
 						mw.QueryParam,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -1493,7 +1543,7 @@ func New(a *api.App) *chi.Mux {
 						mw.QueryParam,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -1515,7 +1565,7 @@ func New(a *api.App) *chi.Mux {
 						mw.QueryParam,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -1529,7 +1579,7 @@ func New(a *api.App) *chi.Mux {
 						mw.QueryParam,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 		})
@@ -1548,7 +1598,7 @@ func New(a *api.App) *chi.Mux {
 						mw.QueryParam,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -1568,7 +1618,7 @@ func New(a *api.App) *chi.Mux {
 						mw.QueryParam,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)
 
@@ -1582,7 +1632,7 @@ func New(a *api.App) *chi.Mux {
 						mw.QueryParam,
 					),
 					mw.URLParam,
-					mw.ReadAccess,
+					mw.WriteAccess,
 				),
 			)