feat(rbac): fixed merge conflicts and resolved some more issues with permission checks

This commit is contained in:
Akhil Mohan
2023-08-30 22:57:13 +05:30
parent bcfe1bda84
commit f1a291a52a
13 changed files with 120 additions and 87 deletions

View File

@@ -201,14 +201,6 @@ export const logout = async (req: Request, res: Response) => {
});
};
export const getCommonPasswords = async (req: Request, res: Response) => {
const commonPasswords = fs
.readFileSync(path.resolve(__dirname, "../../data/" + "common_passwords.txt"), "utf8")
.split("\n");
return res.status(200).send(commonPasswords);
};
export const revokeAllSessions = async (req: Request, res: Response) => {
await TokenVersion.updateMany(
{

View File

@@ -203,7 +203,6 @@ export const getUserWorkspacePermissions = async (req: Request, res: Response) =
params: { workspaceId }
} = await validateRequest(GetUserProjectPermission, req);
const { permission } = await getUserProjectPermissions(req.user.id, workspaceId);
res.status(200).json({
data: {
permissions: packRules(permission.rules)

View File

@@ -77,16 +77,6 @@ export const batchSecrets = async (req: Request, res: Response) => {
const folders = await Folder.findOne({ workspace: workspaceId, environment });
if (req.authData.authPayload instanceof ServiceTokenData) {
await validateServiceTokenDataClientForWorkspace({
serviceTokenData: req.authData.authPayload,
workspaceId: new Types.ObjectId(workspaceId),
environment,
secretPath,
requiredPermissions: [PERMISSION_WRITE_SECRETS]
});
}
if (secretPath) {
folderId = await getFolderIdFromServiceToken(workspaceId, environment, secretPath);
}
@@ -100,7 +90,15 @@ export const batchSecrets = async (req: Request, res: Response) => {
);
}
if (req.user?._id) {
if (req.authData.authPayload instanceof ServiceTokenData) {
await validateServiceTokenDataClientForWorkspace({
serviceTokenData: req.authData.authPayload,
workspaceId: new Types.ObjectId(workspaceId),
environment,
secretPath,
requiredPermissions: [PERMISSION_WRITE_SECRETS]
});
} else {
const { permission } = await getUserProjectPermissions(req.user._id, workspaceId);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Create,

View File

@@ -44,6 +44,4 @@ const membershipOrgSchema = new Schema(
}
);
const MembershipOrg = model<IMembershipOrg>("MembershipOrg", membershipOrgSchema);
export default MembershipOrg;
export const MembershipOrg = model<IMembershipOrg>("MembershipOrg", membershipOrgSchema);

View File

@@ -38,8 +38,6 @@ router.post(
authController.checkAuth
);
router.get("/common-passwords", authLimiter, authController.getCommonPasswords);
router.delete(
// TODO endpoint: deprecate (moved to DELETE v2/users/me/sessions)
"/sessions",

View File

@@ -93,11 +93,11 @@ router.get(
);
router.get(
"/:integrationAuthId/teamcity/build-configs",
requireAuth({
acceptedAuthModes: [AuthMode.JWT],
}),
integrationAuthController.getIntegrationAuthTeamCityBuildConfigs
"/:integrationAuthId/teamcity/build-configs",
requireAuth({
acceptedAuthModes: [AuthMode.JWT]
}),
integrationAuthController.getIntegrationAuthTeamCityBuildConfigs
);
router.delete(

View File

@@ -219,7 +219,6 @@ export const getUserProjectPermissions = async (userId: string, workspaceId: str
}>("customRole")
.exec();
console.log(membership, userId, workspaceId);
if (!membership || (membership.role === "custom" && !membership.customRole)) {
throw UnauthorizedRequestError({ message: "User doesn't belong to organization" });
}

View File

@@ -6,7 +6,6 @@ import { MembershipNotFoundError } from "../utils/errors";
import { AuthData } from "../interfaces/middleware";
import { ActorType } from "../ee/models";
import { z } from "zod";
import { ADMIN, CUSTOM, MEMBER, VIEWER } from "../variables";
/**
* Validate authenticated clients for membership with id [membershipId] based
@@ -66,7 +65,7 @@ export const DeleteMembershipV1 = z.object({
export const ChangeMembershipRoleV1 = z.object({
body: z.object({
role: z.enum([ADMIN, VIEWER, MEMBER, CUSTOM])
role: z.string().trim()
}),
params: z.object({ membershipId: z.string().trim() })
});

View File

@@ -11,7 +11,7 @@ export const CreateRoleSchema = z.object({
.object({
subject: z.string(),
action: z.string(),
condition: z.record(z.union([z.string(), z.number()])).optional()
conditions: z.record(z.union([z.string(), z.number()])).optional()
})
.array()
})
@@ -31,7 +31,7 @@ export const UpdateRoleSchema = z.object({
.object({
subject: z.string(),
action: z.string(),
condition: z.record(z.union([z.string(), z.number()])).optional()
conditions: z.record(z.union([z.string(), z.number()])).optional()
})
.array()
.optional()

View File

@@ -18,13 +18,13 @@ export type TRole<T extends string | undefined> = {
export type TPermission = TWorkspacePermission | TGeneralPermission;
type TGeneralPermission = {
condition?: Record<string, any>;
conditions?: Record<string, any>;
action: "read" | "edit" | "create" | "delete";
subject: "member" | "role" | "incident-contact" | "sso" | "billing" | "settings";
};
type TWorkspacePermission = {
condition?: Record<string, any>;
conditions?: Record<string, any>;
action: "read" | "create";
subject: "workspace";
};
@@ -32,7 +32,7 @@ type TWorkspacePermission = {
export type TProjectPermission = TProjectGeneralPermission | TProjectWorkspacePermission;
type TProjectGeneralPermission = {
condition?: Record<string, any>;
conditions?: Record<string, any>;
action: "read" | "edit" | "create" | "delete";
subject:
| "member"
@@ -46,7 +46,7 @@ type TProjectGeneralPermission = {
};
type TProjectWorkspacePermission = {
condition?: Record<string, any>;
conditions?: Record<string, any>;
action: "delete" | "edit";
subject: "workspace";
};

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { FormProvider, useFieldArray, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useRouter } from "next/router";
import { subject } from "@casl/ability";
import {
closestCenter,
DndContext,
@@ -25,6 +26,7 @@ import {
faEyeSlash,
faFileImport,
faFolderPlus,
faLock,
faMagnifyingGlass,
faPlus
} from "@fortawesome/free-solid-svg-icons";
@@ -60,6 +62,7 @@ import {
ProjectPermissionActions,
ProjectPermissionSub,
useOrganization,
useProjectPermission,
useSubscription,
useWorkspace
} from "@app/context";
@@ -79,7 +82,6 @@ import {
useGetSecretVersion,
useGetSnapshotSecrets,
useGetUserAction,
useGetUserWsEnvironments,
useGetUserWsKey,
useGetWorkspaceSecretSnapshots,
useGetWsSnapshotCount,
@@ -139,6 +141,7 @@ export const DashboardPage = withProjectPermission(
const { createNotification } = useNotificationContext();
const queryClient = useQueryClient();
const envQuery = router.query.env as string;
const permission = useProjectPermission();
const secretContainer = useRef<HTMLDivElement | null>(null);
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
@@ -181,17 +184,6 @@ export const DashboardPage = withProjectPermission(
const { data: userAction } = useGetUserAction(USER_ACTION_PUSH);
const hasUserPushed = Boolean(userAction);
const { data: wsEnv, isLoading: isEnvListLoading } = useGetUserWsEnvironments({
workspaceId,
onSuccess: (data) => {
// get an env with one of the access available
const env = data.find(({ isReadDenied, isWriteDenied }) => !isWriteDenied || !isReadDenied);
if (env && data?.map((wsenv) => wsenv.slug).includes(envQuery)) {
setSelectedEnv(data?.filter((dp) => dp.slug === envQuery)[0]);
}
}
});
const { data: secretVersion } = useGetSecretVersion({
limit: 10,
offset: 0,
@@ -267,6 +259,24 @@ export const DashboardPage = withProjectPermission(
folderId
});
const secretPath = `/${(folderData?.dir || [])
?.filter(({ name }) => name !== "root")
.join("/")}`;
const userAvailableEnvs = currentWorkspace?.environments?.filter(({ slug }) =>
permission.can(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { environment: slug, secretPath })
)
);
useEffect(() => {
if (!isLoading && currentWorkspace) {
const env = userAvailableEnvs?.find(({ slug }) => slug === envQuery);
if (env) setSelectedEnv(env);
}
}, [isLoading, workspaceId, userAvailableEnvs]);
// This is for dnd-kit. As react-query state mutation async
// This will act as a placeholder to avoid a glitching animation on dropping items
const [items, setItems] = useState<
@@ -316,11 +326,19 @@ export const DashboardPage = withProjectPermission(
reset
} = method;
const { fields, prepend, append, remove } = useFieldArray({ control, name: "secrets" });
const isReadOnly = selectedEnv?.isWriteDenied;
const isAddOnly = selectedEnv?.isReadDenied && !selectedEnv?.isWriteDenied;
const canDoRollback = !isReadOnly && !isAddOnly;
const isSubmitDisabled =
isReadOnly || (!isRollbackMode && !isDirty) || isAddOnly || isSubmitting;
const isReadOnly =
permission.can(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { environment: selectedEnvSlug })
) &&
permission.cannot(
ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.Secrets, { environment: selectedEnvSlug })
);
const canDoRollback = !isReadOnly;
const isSubmitDisabled = isReadOnly || (!isRollbackMode && !isDirty) || isSubmitting;
useEffect(() => {
if (!isSnapshotChanging && Boolean(snapshotId)) {
@@ -437,14 +455,12 @@ export const DashboardPage = withProjectPermission(
}
// just closing this if save is triggered from drawer
handlePopUpClose("secretDetails");
// when add only mode remove rest of things not created
const sec = isAddOnly ? userSec.filter(({ _id }) => !_id) : userSec;
// encrypt and format the secrets to batch api format
// requests = [ {method:"", secret:""} ]
const batchedSecret = transformSecretsToBatchSecretReq(
deletedSecretIds.current,
latestFileKey,
sec,
userSec,
secrets?.secrets
);
// type check
@@ -486,7 +502,8 @@ export const DashboardPage = withProjectPermission(
// eslint-disable-next-line no-alert
if (!window.confirm(leaveConfirmDefaultMessage)) return;
}
const env = wsEnv?.find((el) => el.slug === slug);
const env = userAvailableEnvs?.find((el) => el.slug === slug);
if (env) setSelectedEnv(env);
const query: Record<string, string> = { ...router.query, env: slug };
delete query.folderId;
@@ -748,7 +765,7 @@ export const DashboardPage = withProjectPermission(
const isSecretImportEmpty = !secretImportCfg?.imports?.length;
const isEmptyPage = isFoldersEmpty && isSecretEmpty && isSecretImportEmpty;
if (isSecretsLoading || isEnvListLoading) {
if (isSecretsLoading) {
return (
<div className="container mx-auto flex h-1/2 w-full items-center justify-center px-8 text-mineshaft-50 dark:[color-scheme:dark]">
<img src="/images/loading/loading.gif" height={70} width={120} alt="loading animation" />
@@ -756,9 +773,29 @@ export const DashboardPage = withProjectPermission(
);
}
const userAvailableEnvs = wsEnv?.filter(
({ isReadDenied, isWriteDenied }) => !isReadDenied || !isWriteDenied
);
if (
permission.cannot(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { environment: envQuery, secretPath })
)
) {
return (
<div className="container h-full mx-auto flex justify-center items-center">
<div className="rounded-md bg-mineshaft-800 text-bunker-300 p-16 flex space-x-12 items-end">
<div>
<FontAwesomeIcon icon={faLock} size="6x" />
</div>
<div>
<div className="text-4xl font-medium mb-2">Permission Denied</div>
<div className="text-sm">
You do not have permission to this page. <br /> Kindly contact your organization
administrator
</div>
</div>
</div>
</div>
);
}
return (
<div className="container mx-auto h-full px-6 text-mineshaft-50 dark:[color-scheme:dark]">
@@ -768,7 +805,7 @@ export const DashboardPage = withProjectPermission(
<NavHeader
pageName={t("dashboard.title")}
currentEnv={
userAvailableEnvs?.filter((envir) => envir.slug === envQuery)[0].name || ""
userAvailableEnvs?.filter((envir) => envir.slug === envQuery)?.[0]?.name || ""
}
isFolderMode
folders={folderData?.dir}
@@ -869,7 +906,7 @@ export const DashboardPage = withProjectPermission(
}}
leftIcon={<FontAwesomeIcon icon={faCodeCommit} />}
isLoading={isLoadingSnapshotCount}
isDisabled={!canDoRollback && !isAllowed}
isDisabled={!canDoRollback || !isAllowed}
className="h-10"
>
{snapshotCount} Commits
@@ -1037,7 +1074,6 @@ export const DashboardPage = withProjectPermission(
secUniqId={_id}
isReadOnly={isReadOnly}
isRollbackMode={isRollbackMode}
isAddOnly={isAddOnly}
index={index}
searchTerm={searchFilter}
onSecretDelete={onSecretDelete}

View File

@@ -58,14 +58,14 @@ const multiEnvApi2Form = (
formVal: TFormSchema["permissions"]["secrets"],
permission: TProjectPermission
) => {
const isCustomRule = Boolean(permission?.condition?.slug);
const isCustomRule = Boolean(permission?.conditions?.environment);
// full access
if (isCustomRule && formVal && !formVal?.custom) {
formVal.custom = { read: true, edit: true, delete: true, create: true };
}
const secretEnv = permission?.condition?.slug || "all";
const secretPath = permission?.condition?.secretPath;
const secretEnv = permission?.conditions?.environment || "all";
const secretPath = permission?.conditions?.secretPath;
// initialize
if (formVal && !formVal?.[secretEnv]) {
formVal[secretEnv] = { read: false, edit: false, create: false, delete: false, secretPath };
@@ -94,7 +94,7 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
permissions.forEach((permission) => {
if (["secrets", "folders", "secret-imports"].includes(permission.subject)) {
multiEnvApi2Form(formVal?.secrets, permission);
multiEnvApi2Form(formVal[permission.subject], permission);
} else {
// everything else follows same pattern
// formVal[settings][read | write] = true
@@ -134,11 +134,10 @@ const multiEnvForm2Api = (
actions.forEach((action) => {
// if not full access for an action
if (!formVal?.all?.[action] && action !== "secretPath" && formVal?.[slug]?.[action]) {
permissions.push({
action,
subject,
condition: { slug, secretPath: formVal[slug]?.secretPath }
});
const conditions: Record<string, unknown> = { environment: slug };
if (formVal[slug]?.secretPath) conditions.secretPath = formVal[slug].secretPath;
permissions.push({ action, subject, conditions });
}
});
});

View File

@@ -2,7 +2,13 @@ import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import Link from "next/link";
import { useRouter } from "next/router";
import { faArrowDown, faArrowUp, faFolderBlank, faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
import { subject } from "@casl/ability";
import {
faArrowDown,
faArrowUp,
faFolderBlank,
faMagnifyingGlass
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
@@ -27,6 +33,7 @@ import {
ProjectPermissionActions,
ProjectPermissionSub,
useOrganization,
useProjectPermission,
useWorkspace
} from "@app/context";
import { withProjectPermission } from "@app/hoc";
@@ -76,6 +83,7 @@ const SecretOverview = () => {
const { data: latestFileKey } = useGetUserWsKey(workspaceId);
const [searchFilter, setSearchFilter] = useState("");
const secretPath = router.query?.secretPath as string;
const permission = useProjectPermission();
useEffect(() => {
if (!isWorkspaceLoading && !workspaceId && router.isReady) {
@@ -83,11 +91,13 @@ const SecretOverview = () => {
}
}, [isWorkspaceLoading, workspaceId, router.isReady]);
const { data: wsEnv, isLoading: isEnvListLoading } = useGetUserWsEnvironments({
workspaceId
});
const userAvailableEnvs = wsEnv?.filter(({ isReadDenied }) => !isReadDenied) || [];
const userAvailableEnvs =
currentWorkspace?.environments?.filter(({ slug }) =>
permission.can(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { environment: slug, secretPath: secretPath || "/" })
)
) || [];
const {
data: secrets,
@@ -213,7 +223,7 @@ const SecretOverview = () => {
}
};
if (isEnvListLoading) {
if (isWorkspaceLoading) {
return (
<div className="container mx-auto flex h-screen w-full items-center justify-center px-8 text-mineshaft-50 dark:[color-scheme:dark]">
<img src="/images/loading/loading.gif" height={70} width={120} alt="loading animation" />
@@ -225,9 +235,9 @@ const SecretOverview = () => {
folders?.some(({ isLoading }) => !isLoading) && secrets?.some(({ isLoading }) => !isLoading)
);
const filteredSecretNames = secKeys?.filter((name) =>
name.toUpperCase().includes(searchFilter.toUpperCase())
).sort((a, b) => sortDir === "asc" ? a.localeCompare(b) : b.localeCompare(a));
const filteredSecretNames = secKeys
?.filter((name) => name.toUpperCase().includes(searchFilter.toUpperCase()))
.sort((a, b) => (sortDir === "asc" ? a.localeCompare(b) : b.localeCompare(a)));
const filteredFolderNames = folderNames?.filter((name) =>
name.toLowerCase().includes(searchFilter.toLowerCase())
);
@@ -286,7 +296,12 @@ const SecretOverview = () => {
<Th className="sticky left-0 z-20 min-w-[20rem] border-b-0 p-0">
<div className="flex items-center border-b border-r border-mineshaft-600 px-5 pt-4 pb-3.5">
Name
<IconButton variant="plain" className="ml-2" ariaLabel="sort" onClick={() => setSortDir(prev => prev === "asc" ? "desc" : "asc")}>
<IconButton
variant="plain"
className="ml-2"
ariaLabel="sort"
onClick={() => setSortDir((prev) => (prev === "asc" ? "desc" : "asc"))}
>
<FontAwesomeIcon icon={sortDir === "asc" ? faArrowDown : faArrowUp} />
</IconButton>
</div>