misc: added trigger for privilege upgrade

This commit is contained in:
Sheen Capadngan
2025-03-13 22:21:44 +08:00
parent cc2c4b16bf
commit fc651f6645
13 changed files with 417 additions and 13 deletions

View File

@ -0,0 +1,30 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasColumn(TableName.Organization, "shouldUseNewPrivilegeSystem"))) {
await knex.schema.alterTable(TableName.Organization, (t) => {
t.boolean("shouldUseNewPrivilegeSystem");
t.string("privilegeUpgradeInitiatedByUsername");
t.dateTime("privilegeUpgradeInitiatedAt");
});
await knex(TableName.Organization).update({
shouldUseNewPrivilegeSystem: false
});
await knex.schema.alterTable(TableName.Organization, (t) => {
t.boolean("shouldUseNewPrivilegeSystem").defaultTo(true).notNullable().alter();
});
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasColumn(TableName.Organization, "shouldUseNewPrivilegeSystem")) {
await knex.schema.alterTable(TableName.Organization, (t) => {
t.dropColumn("shouldUseNewPrivilegeSystem");
t.dropColumn("privilegeUpgradeInitiatedByUsername");
t.dropColumn("privilegeUpgradeInitiatedAt");
});
}
}

View File

@ -22,7 +22,10 @@ export const OrganizationsSchema = z.object({
kmsEncryptedDataKey: zodBuffer.nullable().optional(),
defaultMembershipRole: z.string().default("member"),
enforceMfa: z.boolean().default(false),
selectedMfaMethod: z.string().nullable().optional()
selectedMfaMethod: z.string().nullable().optional(),
shouldUseNewPrivilegeSystem: z.boolean().default(true),
privilegeUpgradeInitiatedByUsername: z.string().nullable().optional(),
privilegeUpgradeInitiatedAt: z.date().nullable().optional()
});
export type TOrganizations = z.infer<typeof OrganizationsSchema>;

View File

@ -4,7 +4,6 @@ import {
AuditLogsSchema,
GroupsSchema,
IncidentContactsSchema,
OrganizationsSchema,
OrgMembershipsSchema,
OrgRolesSchema,
UsersSchema
@ -57,7 +56,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
}),
response: {
200: z.object({
organization: OrganizationsSchema
organization: sanitizedOrganizationSchema
})
}
},
@ -262,7 +261,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
response: {
200: z.object({
message: z.string(),
organization: OrganizationsSchema
organization: sanitizedOrganizationSchema
})
}
},

View File

@ -1,7 +1,6 @@
import { z } from "zod";
import {
OrganizationsSchema,
OrgMembershipsSchema,
ProjectMembershipsSchema,
ProjectsSchema,
@ -15,6 +14,7 @@ import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { GenericResourceNameSchema } from "@app/server/lib/schemas";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { ActorType, AuthMode } from "@app/services/auth/auth-type";
import { sanitizedOrganizationSchema } from "@app/services/org/org-schema";
export const registerOrgRouter = async (server: FastifyZodProvider) => {
server.route({
@ -335,7 +335,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
}),
response: {
200: z.object({
organization: OrganizationsSchema
organization: sanitizedOrganizationSchema
})
}
},
@ -365,7 +365,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
}),
response: {
200: z.object({
organization: OrganizationsSchema,
organization: sanitizedOrganizationSchema,
accessToken: z.string()
})
}
@ -396,4 +396,30 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
return { organization, accessToken: tokens.accessToken };
}
});
server.route({
method: "POST",
url: "/privilege-system-upgrade",
config: {
rateLimit: writeLimit
},
schema: {
response: {
200: z.object({
organization: sanitizedOrganizationSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const organization = await server.services.org.upgradePrivilegeSystem({
actorId: req.permission.id,
actorOrgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod,
orgId: req.permission.orgId
});
return { organization };
}
});
};

View File

@ -12,5 +12,8 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({
kmsDefaultKeyId: true,
defaultMembershipRole: true,
enforceMfa: true,
selectedMfaMethod: true
selectedMfaMethod: true,
shouldUseNewPrivilegeSystem: true,
privilegeUpgradeInitiatedByUsername: true,
privilegeUpgradeInitiatedAt: true
});

View File

@ -77,6 +77,7 @@ import {
TResendOrgMemberInvitationDTO,
TUpdateOrgDTO,
TUpdateOrgMembershipDTO,
TUpgradePrivilegeSystemDTO,
TVerifyUserToOrgDTO
} from "./org-types";
@ -282,6 +283,45 @@ export const orgServiceFactory = ({
};
};
const upgradePrivilegeSystem = async ({
actorId,
actorOrgId,
actorAuthMethod,
orgId
}: TUpgradePrivilegeSystemDTO) => {
const { membership } = await permissionService.getUserOrgPermission(actorId, orgId, actorAuthMethod, actorOrgId);
if (membership.role != OrgMembershipRole.Admin) {
throw new ForbiddenRequestError({
message: "Insufficient privileges - only the organization admin can upgrade the privilege system."
});
}
return orgDAL.transaction(async (tx) => {
const org = await orgDAL.findById(actorOrgId, tx);
if (org.shouldUseNewPrivilegeSystem) {
throw new BadRequestError({
message: "Privilege system already upgraded"
});
}
const user = await userDAL.findById(actorId, tx);
if (!user) {
throw new NotFoundError({ message: `User with ID '${actorId}' not found` });
}
return orgDAL.updateById(
actorOrgId,
{
shouldUseNewPrivilegeSystem: true,
privilegeUpgradeInitiatedAt: new Date(),
privilegeUpgradeInitiatedByUsername: user.username
},
tx
);
});
};
/*
* Update organization details
* */
@ -1310,6 +1350,7 @@ export const orgServiceFactory = ({
getOrgGroups,
listProjectMembershipsByOrgMembershipId,
findOrgBySlug,
resendOrgMemberInvitation
resendOrgMemberInvitation,
upgradePrivilegeSystem
};
};

View File

@ -75,6 +75,8 @@ export type TUpdateOrgDTO = {
}>;
} & TOrgPermission;
export type TUpgradePrivilegeSystemDTO = Omit<TOrgPermission, "actor">;
export type TGetOrgGroupsDTO = TOrgPermission;
export type TListProjectMembershipsByOrgMembershipIdDTO = {

View File

@ -17,6 +17,8 @@ export type CheckboxProps = Omit<
isError?: boolean;
isIndeterminate?: boolean;
containerClassName?: string;
indicatorClassName?: string;
allowMultilineLabel?: boolean;
};
export const Checkbox = ({
@ -30,6 +32,8 @@ export const Checkbox = ({
isError,
isIndeterminate,
containerClassName,
indicatorClassName,
allowMultilineLabel,
...props
}: CheckboxProps): JSX.Element => {
return (
@ -48,7 +52,9 @@ export const Checkbox = ({
{...props}
id={id}
>
<CheckboxPrimitive.Indicator className={`${checkIndicatorBg || "text-bunker-800"}`}>
<CheckboxPrimitive.Indicator
className={twMerge(`${checkIndicatorBg || "text-bunker-800"}`, indicatorClassName)}
>
{isIndeterminate ? (
<FontAwesomeIcon icon={faMinus} size="sm" />
) : (
@ -57,7 +63,11 @@ export const Checkbox = ({
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
<label
className={twMerge("truncate whitespace-nowrap text-sm", isError && "text-red-400")}
className={twMerge(
"text-sm",
!allowMultilineLabel && "truncate whitespace-nowrap",
isError && "text-red-400"
)}
htmlFor={id}
>
{children}

View File

@ -20,5 +20,6 @@ export {
useGetOrgTaxIds,
useGetOrgTrialUrl,
useUpdateOrg,
useUpdateOrgBillingDetails
useUpdateOrgBillingDetails,
useUpgradePrivilegeSystem
} from "./queries";

View File

@ -127,6 +127,18 @@ export const useUpdateOrg = () => {
});
};
export const useUpgradePrivilegeSystem = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => {
return apiRequest.post("/api/v2/organizations/privilege-system-upgrade");
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: organizationKeys.getUserOrganizations });
}
});
};
export const useGetOrgTrialUrl = () => {
return useMutation({
mutationFn: async ({ orgId, success_url }: { orgId: string; success_url: string }) => {

View File

@ -15,6 +15,7 @@ export type Organization = {
defaultMembershipRole: string;
enforceMfa: boolean;
selectedMfaMethod?: MfaMethod;
shouldUseNewPrivilegeSystem: boolean;
};
export type UpdateOrgDTO = {

View File

@ -2,22 +2,29 @@ import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { Button, PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import {
OrgPermissionActions,
OrgPermissionGroupActions,
OrgPermissionIdentityActions,
OrgPermissionSubjects,
useOrganization,
useOrgPermission
} from "@app/context";
import { OrgAccessControlTabSections } from "@app/types/org";
import { OrgGroupsTab, OrgIdentityTab, OrgMembersTab, OrgRoleTabSection } from "./components";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { UpgradePrivilegeSystemModal } from "./components/UpgradePrivilegeSystemModal/UpgradePrivilegeSystemModal";
import { useState } from "react";
export const AccessManagementPage = () => {
const { t } = useTranslation();
const { permission } = useOrgPermission();
const { currentOrg } = useOrganization();
const navigate = useNavigate({
from: ROUTE_PATHS.Organization.AccessControlPage.path
});
@ -27,6 +34,8 @@ export const AccessManagementPage = () => {
structuralSharing: true
});
const [isUpgradePrivilegeSystemModalOpen, setIsUpgradePrivilegeSystemModalOpen] = useState(false);
const updateSelectedTab = (tab: string) => {
navigate({
search: { selectedTab: tab }
@ -73,6 +82,31 @@ export const AccessManagementPage = () => {
title="Organization Access Control"
description="Manage fine-grained access for users, groups, roles, and identities within your organization resources."
/>
{!currentOrg.shouldUseNewPrivilegeSystem && (
<div className="mb-4 mt-4 flex flex-col rounded-r border-l-2 border-l-primary bg-mineshaft-300/5 px-4 py-2.5">
<div className="mb-1 flex items-center text-sm">
<FontAwesomeIcon icon={faInfoCircle} size="sm" className="mr-1.5 text-primary" />
Your organization is using legacy privilege management
</div>
<p className="mb-2 mt-1 text-sm text-bunker-300">
We've developed an improved privilege management system to better serve your security
needs. Upgrade to our new permission-based approach that allows you to explicitly
designate who can modify specific access levels, rather than relying on traditional
hierarchy comparisons.
</p>
<Button
colorSchema="primary"
className="mt-2 w-fit text-xs"
onClick={() => setIsUpgradePrivilegeSystemModalOpen(true)}
>
Learn More & Upgrade
</Button>
</div>
)}
<UpgradePrivilegeSystemModal
isOpen={isUpgradePrivilegeSystemModalOpen}
onOpenChange={setIsUpgradePrivilegeSystemModalOpen}
/>
<Tabs value={selectedTab} onValueChange={updateSelectedTab}>
<TabList>
{tabSections

View File

@ -0,0 +1,242 @@
import { Controller, useForm } from "react-hook-form";
import {
faCheck,
faCircleInfo,
faExclamationTriangle,
faWarning
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, Checkbox, Modal, ModalContent } from "@app/components/v2";
import { useOrgPermission } from "@app/context";
import { useUpgradePrivilegeSystem } from "@app/hooks/api";
const formSchema = z.object({
isProjectPrivilegesUpdated: z.literal(true),
isOrgPrivilegesUpdated: z.literal(true),
isInfrastructureUpdated: z.literal(true),
acknowledgesPermanentChange: z.literal(true)
});
type Props = {
isOpen?: boolean;
onOpenChange: (isOpen: boolean) => void;
};
export const UpgradePrivilegeSystemModal = ({ isOpen, onOpenChange }: Props) => {
const { membership } = useOrgPermission();
const {
handleSubmit,
control,
formState: { isSubmitting }
} = useForm({ resolver: zodResolver(formSchema) });
const { mutateAsync: upgradePrivilegeSystem } = useUpgradePrivilegeSystem();
const handlePrivilegeSystemUpgrade = async () => {
try {
await upgradePrivilegeSystem();
createNotification({
text: "Privilege system upgrade completed",
type: "success"
});
onOpenChange(false);
} catch {
createNotification({
text: "Failed to upgrade privilege system",
type: "error"
});
}
};
const isAdmin = membership?.role === "admin";
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent title="Privilege Management System Upgrade">
<div className="mb-4">
<h4 className="mb-2 text-lg font-semibold">
Introducing Permission-Based Privilege Management
</h4>
<p className="mb-4 leading-7 text-mineshaft-100">
We've developed an improved privilege management system that enhances how access
controls work in your organization.
</p>
<div className="mb-4 rounded-lg border border-mineshaft-600 bg-mineshaft-800 p-4">
<div className="mb-3">
<div className="mb-2 flex items-start gap-2">
<FontAwesomeIcon icon={faCircleInfo} className="mt-1 text-primary" />
<p className="font-medium">How it works:</p>
</div>
<div className="mb-3 ml-7">
<p className="mb-1">
<strong>Legacy system:</strong> Users with higher privilege levels could modify
access for anyone below them.
</p>
<p>
<strong>New system:</strong> Users need explicit permission to modify specific
access levels, providing targeted control. After upgrading, you'll need to grant
the new 'Manage Privileges' permission at organization or project level.
</p>
</div>
</div>
<div>
<div className="mb-2 flex items-start gap-2">
<FontAwesomeIcon icon={faCheck} className="mt-1 text-primary" />
<p className="font-medium">Benefits:</p>
</div>
<div className="ml-7">
<ul className="list-disc pl-5">
<li>More granular control over who can modify access levels</li>
<li>Improved security through precise permission checks</li>
</ul>
</div>
</div>
</div>
<p className="mb-4 leading-7 text-mineshaft-100">
This upgrade affects operations like updating roles, managing group memberships, and
modifying privileges across your organization and projects.
</p>
</div>
<div className="mt-6 flex max-w-2xl flex-col rounded-lg border border-primary/50 bg-primary/10 px-6 py-5">
<div className="mb-4 flex items-start gap-2">
<FontAwesomeIcon icon={faWarning} size="xl" className="mt-1 text-primary" />
<p className="text-xl font-semibold">Upgrade privilege system</p>
</div>
<p className="mx-1 mb-4 leading-7 text-mineshaft-100">
Your existing access control workflows will continue to function. However, actions that
involve changing privileges or permissions will now use the new permission-based system,
requiring users to have explicit permission to modify access levels.
</p>
<form onSubmit={handleSubmit(handlePrivilegeSystemUpgrade)}>
<div className="mb-4 rounded-lg border border-red-500 bg-red-500/10 p-4">
<div className="mb-3 flex items-start gap-2">
<FontAwesomeIcon
icon={faExclamationTriangle}
className="mt-1 text-red-500"
size="lg"
/>
<p className="font-bold text-red-400">IMPORTANT: THIS CHANGE IS PERMANENT</p>
</div>
<p className="mb-3 ml-7 text-mineshaft-100">
Once upgraded, your organization <span className="font-bold">cannot</span> revert to
the legacy privilege system. Please ensure you've completed all preparations before
proceeding.
</p>
</div>
<div className="mb-4">
<p className="mb-3 font-medium">Required preparation checklist:</p>
<div className="flex flex-col space-y-4">
<Controller
control={control}
name="isProjectPrivilegesUpdated"
defaultValue={false}
render={({ field: { onBlur, value, onChange }, fieldState: { error } }) => (
<Checkbox
containerClassName="items-start"
className="mt-0.5 items-start"
id="is-project-privileges-updated"
indicatorClassName="flex h-full w-full items-center justify-center"
allowMultilineLabel
isChecked={value}
onCheckedChange={onChange}
onBlur={onBlur}
isError={Boolean(error?.message)}
>
I have reviewed project-level privileges and updated them if necessary
</Checkbox>
)}
/>
<Controller
control={control}
name="isOrgPrivilegesUpdated"
defaultValue={false}
render={({ field: { onBlur, value, onChange }, fieldState: { error } }) => (
<Checkbox
containerClassName="items-start"
className="mt-0.5 items-start"
indicatorClassName="flex h-full w-full items-center justify-center"
allowMultilineLabel
id="is-org-privileges-updated"
isChecked={value}
onCheckedChange={onChange}
onBlur={onBlur}
isError={Boolean(error?.message)}
>
I have reviewed organization-level privileges and updated them if necessary
</Checkbox>
)}
/>
<Controller
control={control}
name="isInfrastructureUpdated"
defaultValue={false}
render={({ field: { onBlur, value, onChange }, fieldState: { error } }) => (
<Checkbox
containerClassName="items-start"
className="mt-0.5 items-start"
id="is-infrastructure-updated"
indicatorClassName="flex h-full w-full items-center justify-center"
allowMultilineLabel
isChecked={value}
onCheckedChange={onChange}
onBlur={onBlur}
isError={Boolean(error?.message)}
>
I have checked Terraform configurations and API integrations for compatibility
with the new system
</Checkbox>
)}
/>
<Controller
control={control}
name="acknowledgesPermanentChange"
defaultValue={false}
rules={{ required: true }}
render={({ field: { onBlur, value, onChange }, fieldState: { error } }) => (
<Checkbox
containerClassName="items-start"
className="mt-0.5 items-start"
id="acknowledges-permanent-change"
indicatorClassName="flex h-full w-full items-center justify-center"
allowMultilineLabel
isChecked={value}
onCheckedChange={onChange}
onBlur={onBlur}
isError={Boolean(error?.message)}
>
<span className="font-bold">
I understand that this upgrade is permanent and cannot be reversed
</span>
</Checkbox>
)}
/>
</div>
</div>
<Button
type="submit"
isDisabled={!isAdmin}
isLoading={isSubmitting}
className="mt-5 w-full"
>
{isAdmin ? "Upgrade Privilege System" : "Upgrade requires admin privilege"}
</Button>
</form>
</div>
</ModalContent>
</Modal>
);
};