Feat/brain access rights (#705)

* refactor(BrainUsers)

* feat: give brain share access to EDITORs

* feat(RBAC): add role enum and supports multiple roles check

* feat: make owner right read only for other permissions
This commit is contained in:
Mamadou DICKO 2023-07-19 13:36:23 +02:00 committed by GitHub
parent 6a7bda392c
commit 87458d8de1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 219 additions and 94 deletions

View File

@ -1,4 +1,5 @@
from typing import Optional
from enum import Enum
from typing import List, Optional, Union
from uuid import UUID
from auth.auth_bearer import get_current_user
@ -7,16 +8,27 @@ from models.brains import Brain
from models.users import User
def has_brain_authorization(required_role: Optional[str] = "Owner"):
class RoleEnum(str, Enum):
Viewer = "Viewer"
Editor = "Editor"
Owner = "Owner"
def has_brain_authorization(
required_roles: Optional[Union[RoleEnum, List[RoleEnum]]] = RoleEnum.Owner
):
"""
Decorator to check if the user has the required role for the brain
param: required_role: The role required to access the brain
Decorator to check if the user has the required role(s) for the brain
param: required_roles: The role(s) required to access the brain
return: A wrapper function that checks the authorization
"""
async def wrapper(brain_id: UUID, current_user: User = Depends(get_current_user)):
nonlocal required_roles
if isinstance(required_roles, str):
required_roles = [required_roles] # Convert single role to a list
validate_brain_authorization(
brain_id=brain_id, user_id=current_user.id, required_role=required_role
brain_id=brain_id, user_id=current_user.id, required_roles=required_roles
)
return wrapper
@ -25,17 +37,17 @@ def has_brain_authorization(required_role: Optional[str] = "Owner"):
def validate_brain_authorization(
brain_id: UUID,
user_id: UUID,
required_role: Optional[str] = "Owner",
required_roles: Optional[Union[RoleEnum, List[RoleEnum]]] = RoleEnum.Owner,
):
"""
Function to check if the user has the required role for the brain
Function to check if the user has the required role(s) for the brain
param: brain_id: The id of the brain
param: user_id: The id of the user
param: required_role: The role required to access the brain
param: required_roles: The role(s) required to access the brain
return: None
"""
if required_role is None:
if required_roles is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Missing required role",
@ -49,10 +61,13 @@ def validate_brain_authorization(
detail="You don't have permission for this brain",
)
# TODO: Update this logic when we have more roles
# Eg: Owner > Admin > User ... this should be taken into account
if user_brain.get("rights") != required_role:
# Convert single role to a list to handle both cases
if isinstance(required_roles, str):
required_roles = [required_roles]
# Check if the user has at least one of the required roles
if user_brain.get("rights") not in required_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have the required role for this brain",
detail="You don't have the required role(s) for this brain",
)

View File

@ -18,6 +18,7 @@ from repository.user.get_user_email_by_user_id import get_user_email_by_user_id
from repository.user.get_user_id_by_user_email import get_user_id_by_user_email
from routes.authorizations.brain_authorization import (
RoleEnum,
has_brain_authorization,
validate_brain_authorization,
)
@ -33,7 +34,7 @@ subscription_service = SubscriptionInvitationService()
Depends(
AuthBearer(),
),
Depends(has_brain_authorization),
Depends(has_brain_authorization([RoleEnum.Owner, RoleEnum.Editor])),
Depends(get_origin_header),
],
tags=["BrainSubscription"],
@ -49,7 +50,6 @@ def invite_users_to_brain(
or updates a brain subscription invitation for each user and sends an
invitation email to each user.
"""
for user in users:
subscription = BrainSubscription(
brain_id=brain_id, email=user["email"], rights=user["rights"]
@ -68,7 +68,10 @@ def invite_users_to_brain(
@subscription_router.get(
"/brains/{brain_id}/users",
dependencies=[Depends(AuthBearer()), Depends(has_brain_authorization())],
dependencies=[
Depends(AuthBearer()),
Depends(has_brain_authorization([RoleEnum.Owner, RoleEnum.Editor])),
],
)
def get_brain_users(
brain_id: UUID,
@ -244,15 +247,19 @@ class BrainSubscriptionUpdatableProperties(BaseModel):
email: str
@subscription_router.put("/brains/{brain_id}/subscription")
@subscription_router.put(
"/brains/{brain_id}/subscription",
dependencies=[
Depends(AuthBearer()),
Depends(has_brain_authorization([RoleEnum.Owner, RoleEnum.Editor])),
],
)
def update_brain_subscription(
brain_id: UUID,
subscription: BrainSubscriptionUpdatableProperties,
current_user: User = Depends(get_current_user),
):
validate_brain_authorization(brain_id, current_user.id, "Owner")
user_email = subscription.email
if user_email == current_user.email:
raise HTTPException(
status_code=403,
@ -260,11 +267,40 @@ def update_brain_subscription(
)
user_id = get_user_id_by_user_email(user_email)
brain = Brain(
id=brain_id,
)
# check if user is an editor but trying to give high level permissions
if subscription.rights == "Owner":
try:
validate_brain_authorization(
brain_id,
current_user.id,
RoleEnum.Owner,
)
except HTTPException:
raise HTTPException(
status_code=403,
detail="You don't have the rights to give owner permissions",
)
# check if user is not an editor trying to update an owner right which is not allowed
current_invitation = brain.get_brain_for_user(user_id)
if current_invitation is not None and current_invitation.get("rights") == "Owner":
try:
validate_brain_authorization(
brain_id,
current_user.id,
RoleEnum.Owner,
)
except HTTPException:
raise HTTPException(
status_code=403,
detail="You can't change the permissions of an owner",
)
if subscription.rights is None:
brain = Brain(
id=brain_id,
)
brain.delete_user_from_brain(user_id)
else:
update_brain_user_rights(brain_id, user_id, subscription.rights)

View File

@ -1,15 +1,20 @@
import { MinimalBrainForUser } from "@/lib/context/BrainProvider/types";
import { DeleteBrain, ShareBrain } from "./components";
import { BrainRoleType } from "./types";
type BrainActionsProps = {
brain: MinimalBrainForUser;
};
const requiredAccessToShareBrain: BrainRoleType[] = ["Owner", "Editor"];
export const BrainActions = ({ brain }: BrainActionsProps): JSX.Element => {
return (
<div className="absolute right-0 flex flex-row">
{brain.rights === "Owner" && <ShareBrain brainId={brain.id} />}
{requiredAccessToShareBrain.includes(brain.rights) && (
<ShareBrain brainId={brain.id} name={brain.name} />
)}
<DeleteBrain brainId={brain.id} />
</div>
);

View File

@ -8,15 +8,19 @@ import { MdContentPaste, MdShare } from "react-icons/md";
import Button from "@/lib/components/ui/Button";
import { Modal } from "@/lib/components/ui/Modal";
import { BrainUsers } from "./components/BrainUsers";
import { BrainUsers } from "./components/BrainUsers/BrainUsers";
import { UserToInvite } from "./components/UserToInvite";
import { useShareBrain } from "./hooks/useShareBrain";
type ShareBrainModalProps = {
brainId: UUID;
name: string;
};
export const ShareBrain = ({ brainId }: ShareBrainModalProps): JSX.Element => {
export const ShareBrain = ({
brainId,
name,
}: ShareBrainModalProps): JSX.Element => {
const {
roleAssignations,
brainShareLink,
@ -28,9 +32,6 @@ export const ShareBrain = ({ brainId }: ShareBrainModalProps): JSX.Element => {
sendingInvitation,
setIsShareModalOpen,
isShareModalOpen,
brainUsers,
fetchBrainUsers,
isFetchingBrainUsers,
} = useShareBrain(brainId);
const canAddNewRow =
@ -51,7 +52,7 @@ export const ShareBrain = ({ brainId }: ShareBrainModalProps): JSX.Element => {
</Button>
}
CloseTrigger={<div />}
title="Share brain"
title={`Share brain ${name}`}
isOpen={isShareModalOpen}
setOpen={setIsShareModalOpen}
>
@ -108,12 +109,7 @@ export const ShareBrain = ({ brainId }: ShareBrainModalProps): JSX.Element => {
</form>
<div className="bg-gray-100 h-0.5 mb-5 border-gray-200 dark:border-gray-700" />
<p className="text-lg font-bold">Users with access</p>
<BrainUsers
isFetchingBrainUsers={isFetchingBrainUsers}
brainId={brainId}
fetchBrainUsers={fetchBrainUsers}
users={brainUsers}
/>
<BrainUsers brainId={brainId} />
</Modal>
);
};

View File

@ -1,3 +1,4 @@
/* eslint-disable max-lines */
import { fireEvent, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
@ -43,7 +44,10 @@ describe("ShareBrain", () => {
const { getByTestId } = render(
<SupabaseProviderMock>
<BrainConfigProviderMock>
<ShareBrain brainId="cf9bb422-b1b6-4fd7-abc1-01bd395d2318" />
<ShareBrain
name="test"
brainId="cf9bb422-b1b6-4fd7-abc1-01bd395d2318"
/>
</BrainConfigProviderMock>
</SupabaseProviderMock>
);
@ -56,20 +60,26 @@ describe("ShareBrain", () => {
// Todo: add a custom render function that wraps the component with the providers
<SupabaseProviderMock>
<BrainConfigProviderMock>
<ShareBrain brainId="cf9bb422-b1b6-4fd7-abc1-01bd395d2318" />
<ShareBrain
name="test"
brainId="cf9bb422-b1b6-4fd7-abc1-01bd395d2318"
/>
</BrainConfigProviderMock>
</SupabaseProviderMock>
);
const shareButton = getByTestId("share-brain-button");
fireEvent.click(shareButton);
expect(getByText("Share brain")).toBeDefined();
expect(getByText("Share brain test")).toBeDefined();
});
it('shoud add new user row when "Add new user" button is clicked and only where there is no empty field', async () => {
const { getByTestId, findAllByTestId } = render(
<SupabaseProviderMock>
<BrainConfigProviderMock>
<ShareBrain brainId="cf9bb422-b1b6-4fd7-abc1-01bd395d2318" />
<ShareBrain
name="test"
brainId="cf9bb422-b1b6-4fd7-abc1-01bd395d2318"
/>
</BrainConfigProviderMock>
</SupabaseProviderMock>
);

View File

@ -1,13 +1,16 @@
/* eslint-disable max-lines */
import axios from "axios";
import { useState } from "react";
import { MdOutlineRemoveCircle, MdOutlineTimelapse } from "react-icons/md";
import { useBrainApi } from "@/lib/api/brain/useBrainApi";
import Field from "@/lib/components/ui/Field";
import { Select } from "@/lib/components/ui/Select";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
import { useToast } from "@/lib/hooks";
import { BrainRoleType } from "../../../types";
import { availableRoles } from "../types";
import { BrainRoleType } from "../../../../types";
import { availableRoles } from "../../types";
type BrainUserProps = {
email: string;
@ -18,22 +21,42 @@ type BrainUserProps = {
export const BrainUser = ({
email,
rights: role,
rights,
brainId,
fetchBrainUsers,
}: BrainUserProps): JSX.Element => {
const { updateBrainAccess } = useBrainApi();
const { publish } = useToast();
const [selectedRole, setSelectedRole] = useState<BrainRoleType>(role);
const [selectedRole, setSelectedRole] = useState<BrainRoleType>(rights);
const [isRemovingAccess, setIsRemovingAccess] = useState(false);
const { currentBrain } = useBrainContext();
const updateSelectedRole = async (newRole: BrainRoleType) => {
setSelectedRole(newRole);
await updateBrainAccess(brainId, email, {
rights: newRole,
});
publish({ variant: "success", text: `Updated ${email} to ${newRole}` });
void fetchBrainUsers();
try {
await updateBrainAccess(brainId, email, {
rights: newRole,
});
publish({ variant: "success", text: `Updated ${email} to ${newRole}` });
void fetchBrainUsers();
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 403) {
publish({
variant: "danger",
text: `${JSON.stringify(
(
e.response as {
data: { detail: string };
}
).data.detail
)}`,
});
} else {
publish({
variant: "danger",
text: `Failed to update ${email} to ${newRole}`,
});
}
}
};
const removeUserAccess = async () => {
@ -83,6 +106,7 @@ export const BrainUser = ({
onChange={(newRole) => void updateSelectedRole(newRole)}
value={selectedRole}
options={availableRoles}
readOnly={currentBrain?.rights !== "Owner" && selectedRole === "Owner"}
/>
</div>
);

View File

@ -1,32 +1,25 @@
import { UUID } from "crypto";
import { Subscription } from "@/lib/api/brain/brain";
import { BrainUser } from "./BrainUser";
import { useBrainUsers } from "./hooks/useBrainUsers";
type BrainUsersProps = {
users: Subscription[];
brainId: UUID;
fetchBrainUsers: () => Promise<void>;
isFetchingBrainUsers: boolean;
};
export const BrainUsers = ({
users,
brainId,
fetchBrainUsers,
isFetchingBrainUsers,
}: BrainUsersProps): JSX.Element => {
export const BrainUsers = ({ brainId }: BrainUsersProps): JSX.Element => {
const { brainUsers, fetchBrainUsers, isFetchingBrainUsers } =
useBrainUsers(brainId);
if (isFetchingBrainUsers) {
return <p className="text-gray-500">Loading...</p>;
}
if (users.length === 0) {
return <p className="text-gray-500">No users</p>;
if (brainUsers.length === 0) {
return <p className="text-gray-500">No brainUsers</p>;
}
return (
<>
{users.map((subscription) => (
{brainUsers.map((subscription) => (
<BrainUser
key={subscription.email}
email={subscription.email}

View File

@ -0,0 +1,43 @@
/* eslint-disable max-lines */
import { useEffect, useState } from "react";
import { Subscription } from "@/lib/api/brain/brain";
import { useBrainApi } from "@/lib/api/brain/useBrainApi";
import { useSupabase } from "@/lib/context/SupabaseProvider";
import { useToast } from "@/lib/hooks";
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useBrainUsers = (brainId: string) => {
const [brainUsers, setBrainUsers] = useState<Subscription[]>([]);
const [isFetchingBrainUsers, setFetchingBrainUsers] = useState(false);
const { publish } = useToast();
const { getBrainUsers } = useBrainApi();
const { session } = useSupabase();
const fetchBrainUsers = async () => {
// Optimistic update
setFetchingBrainUsers(brainUsers.length === 0);
try {
const users = await getBrainUsers(brainId);
setBrainUsers(users.filter(({ email }) => email !== session?.user.email));
} catch {
publish({
variant: "danger",
text: "An error occurred while fetching brain users",
});
} finally {
setFetchingBrainUsers(false);
}
};
useEffect(() => {
void fetchBrainUsers();
}, []);
return {
brainUsers,
fetchBrainUsers,
isFetchingBrainUsers,
};
};

View File

@ -1,2 +1,2 @@
export * from "./BrainUser";
export * from "./BrainUsers/BrainUser";
export * from "./UserToInvite";

View File

@ -1,9 +1,7 @@
/* eslint-disable max-lines */
import { useEffect, useState } from "react";
import { useState } from "react";
import { Subscription } from "@/lib/api/brain/brain";
import { useBrainApi } from "@/lib/api/brain/useBrainApi";
import { useSupabase } from "@/lib/context/SupabaseProvider";
import { useToast } from "@/lib/hooks";
import { BrainRoleAssignation } from "../../../types";
@ -11,8 +9,6 @@ import { generateBrainAssignation } from "../utils/generateBrainAssignation";
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useShareBrain = (brainId: string) => {
const [brainUsers, setBrainUsers] = useState<Subscription[]>([]);
const [isFetchingBrainUsers, setFetchingBrainUsers] = useState(false);
const [sendingInvitation, setSendingInvitation] = useState(false);
const [isShareModalOpen, setIsShareModalOpen] = useState(false);
const [roleAssignations, setRoleAssignation] = useState<
@ -23,8 +19,7 @@ export const useShareBrain = (brainId: string) => {
const brainShareLink = `${baseUrl}/invitation/${brainId}`;
const { publish } = useToast();
const { addBrainSubscriptions, getBrainUsers } = useBrainApi();
const { session } = useSupabase();
const { addBrainSubscriptions } = useBrainApi();
const handleCopyInvitationLink = async () => {
await navigator.clipboard.writeText(brainShareLink);
@ -95,26 +90,6 @@ export const useShareBrain = (brainId: string) => {
setRoleAssignation([...roleAssignations, generateBrainAssignation()]);
};
const fetchBrainUsers = async () => {
// Optimistic update
setFetchingBrainUsers(brainUsers.length === 0);
try {
const users = await getBrainUsers(brainId);
setBrainUsers(users.filter(({ email }) => email !== session?.user.email));
} catch {
publish({
variant: "danger",
text: "An error occurred while fetching brain users",
});
} finally {
setFetchingBrainUsers(false);
}
};
useEffect(() => {
void fetchBrainUsers();
}, []);
return {
roleAssignations,
brainShareLink,
@ -126,8 +101,5 @@ export const useShareBrain = (brainId: string) => {
sendingInvitation,
setIsShareModalOpen,
isShareModalOpen,
brainUsers,
fetchBrainUsers,
isFetchingBrainUsers,
};
};

View File

@ -13,6 +13,7 @@ type SelectProps<T> = {
value?: T;
onChange: (option: T) => void;
label?: string;
readOnly?: boolean;
};
const selectedStyle = "rounded-lg bg-black text-white";
@ -22,11 +23,41 @@ export const Select = <T extends string | number>({
options,
value,
label,
readOnly = false,
}: SelectProps<T>): JSX.Element => {
const selectedValueLabel = options.find(
(option) => option.value === value
)?.label;
if (readOnly) {
return (
<div>
{label !== undefined && (
<label
id="listbox-label"
className="block text-sm font-medium leading-6 text-gray-900 mb-2"
>
{label}
</label>
)}
<div className="relative">
<button
type="button"
className="relative w-full cursor-default rounded-md bg-white py-1.5 px-3 text-left text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 sm:text-sm sm:leading-6"
aria-haspopup="listbox"
disabled
>
<span className="flex items-center">
<span className="mx-4 block truncate">
{selectedValueLabel ?? label ?? "Select"}
</span>
</span>
</button>
</div>
</div>
);
}
return (
<div>
{label !== undefined && (