diff --git a/backend/src/db/migrations/20250313124706_add-privilege-upgrade-field.ts b/backend/src/db/migrations/20250313124706_add-privilege-upgrade-field.ts new file mode 100644 index 000000000..9823f4d8e --- /dev/null +++ b/backend/src/db/migrations/20250313124706_add-privilege-upgrade-field.ts @@ -0,0 +1,30 @@ +import { Knex } from "knex"; +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + 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 { + if (await knex.schema.hasColumn(TableName.Organization, "shouldUseNewPrivilegeSystem")) { + await knex.schema.alterTable(TableName.Organization, (t) => { + t.dropColumn("shouldUseNewPrivilegeSystem"); + t.dropColumn("privilegeUpgradeInitiatedByUsername"); + t.dropColumn("privilegeUpgradeInitiatedAt"); + }); + } +} diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts index 3f40447ad..7169df59a 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -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; diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index d117a4303..84c0333f4 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -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 }) } }, diff --git a/backend/src/server/routes/v2/organization-router.ts b/backend/src/server/routes/v2/organization-router.ts index 326b8a497..90b500a5a 100644 --- a/backend/src/server/routes/v2/organization-router.ts +++ b/backend/src/server/routes/v2/organization-router.ts @@ -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 }; + } + }); }; diff --git a/backend/src/services/org/org-schema.ts b/backend/src/services/org/org-schema.ts index 8f5b85403..3d7ea3825 100644 --- a/backend/src/services/org/org-schema.ts +++ b/backend/src/services/org/org-schema.ts @@ -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 }); diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 71e9930e9..b89b6f7fa 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -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 }; }; diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index b9228377b..28de72476 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -75,6 +75,8 @@ export type TUpdateOrgDTO = { }>; } & TOrgPermission; +export type TUpgradePrivilegeSystemDTO = Omit; + export type TGetOrgGroupsDTO = TOrgPermission; export type TListProjectMembershipsByOrgMembershipIdDTO = { diff --git a/frontend/src/components/v2/Checkbox/Checkbox.tsx b/frontend/src/components/v2/Checkbox/Checkbox.tsx index 6a1086996..0cb2fd0e4 100644 --- a/frontend/src/components/v2/Checkbox/Checkbox.tsx +++ b/frontend/src/components/v2/Checkbox/Checkbox.tsx @@ -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} > - + {isIndeterminate ? ( ) : ( @@ -57,7 +63,11 @@ export const Checkbox = ({