From 19a403f46739653e4d1325f909b0b4f58efc8f77 Mon Sep 17 00:00:00 2001 From: x032205 Date: Fri, 13 Jun 2025 14:37:15 -0400 Subject: [PATCH] feat(machine-identities): Delete protection --- ..._add-machine-identity-delete-protection.ts | 21 +++++++++++ backend/src/db/schemas/identities.ts | 3 +- .../ee/services/audit-log/audit-log-types.ts | 2 ++ backend/src/lib/api-docs/constants.ts | 6 ++-- .../src/server/routes/v1/identity-router.ts | 13 ++++--- .../identity-project/identity-project-dal.ts | 3 ++ .../src/services/identity/identity-org-dal.ts | 15 ++++++-- .../src/services/identity/identity-service.ts | 11 ++++-- .../src/services/identity/identity-types.ts | 2 ++ .../src/services/telemetry/telemetry-types.ts | 1 + frontend/src/hooks/api/auditLogs/types.tsx | 2 ++ .../src/hooks/api/identities/mutations.tsx | 3 +- frontend/src/hooks/api/identities/types.ts | 3 ++ .../IdentitySection/IdentityModal.tsx | 35 ++++++++++++++++--- .../IdentitySection/IdentityTable.tsx | 2 +- .../components/IdentityDetailsSection.tsx | 17 +++++++-- .../ViewIdentityContentWrapper.tsx | 2 +- 17 files changed, 120 insertions(+), 21 deletions(-) create mode 100644 backend/src/db/migrations/20250613153957_add-machine-identity-delete-protection.ts diff --git a/backend/src/db/migrations/20250613153957_add-machine-identity-delete-protection.ts b/backend/src/db/migrations/20250613153957_add-machine-identity-delete-protection.ts new file mode 100644 index 000000000..2cf19c1b8 --- /dev/null +++ b/backend/src/db/migrations/20250613153957_add-machine-identity-delete-protection.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasCol = await knex.schema.hasColumn(TableName.Identity, "hasDeleteProtection"); + if (!hasCol) { + await knex.schema.alterTable(TableName.Identity, (t) => { + t.boolean("hasDeleteProtection").notNullable().defaultTo(false); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasCol = await knex.schema.hasColumn(TableName.Identity, "hasDeleteProtection"); + if (hasCol) { + await knex.schema.alterTable(TableName.Identity, (t) => { + t.dropColumn("hasDeleteProtection"); + }); + } +} diff --git a/backend/src/db/schemas/identities.ts b/backend/src/db/schemas/identities.ts index adf3a6ef2..a592e2480 100644 --- a/backend/src/db/schemas/identities.ts +++ b/backend/src/db/schemas/identities.ts @@ -12,7 +12,8 @@ export const IdentitiesSchema = z.object({ name: z.string(), authMethod: z.string().nullable().optional(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + hasDeleteProtection: z.boolean().default(false) }); export type TIdentities = z.infer; diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 87a98305f..11f6657f5 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -754,6 +754,7 @@ interface CreateIdentityEvent { metadata: { identityId: string; name: string; + hasDeleteProtection: boolean; }; } @@ -762,6 +763,7 @@ interface UpdateIdentityEvent { metadata: { identityId: string; name?: string; + hasDeleteProtection?: boolean; }; } diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 1f5f5a4ea..6c8d420e7 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -111,12 +111,14 @@ export const IDENTITIES = { CREATE: { name: "The name of the identity to create.", organizationId: "The organization ID to which the identity belongs.", - role: "The role of the identity. Possible values are 'no-access', 'member', and 'admin'." + role: "The role of the identity. Possible values are 'no-access', 'member', and 'admin'.", + hasDeleteProtection: "Whether the identity has delete protection or not." }, UPDATE: { identityId: "The ID of the identity to update.", name: "The new name of the identity.", - role: "The new role of the identity." + role: "The new role of the identity.", + hasDeleteProtection: "Whether the identity now has delete protection or not." }, DELETE: { identityId: "The ID of the identity to delete." diff --git a/backend/src/server/routes/v1/identity-router.ts b/backend/src/server/routes/v1/identity-router.ts index 0e127796a..2ea70af7a 100644 --- a/backend/src/server/routes/v1/identity-router.ts +++ b/backend/src/server/routes/v1/identity-router.ts @@ -44,6 +44,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { name: z.string().trim().describe(IDENTITIES.CREATE.name), organizationId: z.string().trim().describe(IDENTITIES.CREATE.organizationId), role: z.string().trim().min(1).default(OrgMembershipRole.NoAccess).describe(IDENTITIES.CREATE.role), + hasDeleteProtection: z.boolean().default(false).describe(IDENTITIES.CREATE.hasDeleteProtection), metadata: z .object({ key: z.string().trim().min(1), value: z.string().trim().min(1) }) .array() @@ -75,6 +76,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { type: EventType.CREATE_IDENTITY, metadata: { name: identity.name, + hasDeleteProtection: identity.hasDeleteProtection, identityId: identity.id } } @@ -86,6 +88,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { properties: { orgId: req.body.organizationId, name: identity.name, + hasDeleteProtection: identity.hasDeleteProtection, identityId: identity.id, ...req.auditLogInfo } @@ -117,6 +120,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { body: z.object({ name: z.string().trim().optional().describe(IDENTITIES.UPDATE.name), role: z.string().trim().min(1).optional().describe(IDENTITIES.UPDATE.role), + hasDeleteProtection: z.boolean().optional().describe(IDENTITIES.UPDATE.hasDeleteProtection), metadata: z .object({ key: z.string().trim().min(1), value: z.string().trim().min(1) }) .array() @@ -148,6 +152,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { type: EventType.UPDATE_IDENTITY, metadata: { name: identity.name, + hasDeleteProtection: identity.hasDeleteProtection, identityId: identity.id } } @@ -243,7 +248,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { permissions: true, description: true }).optional(), - identity: IdentitiesSchema.pick({ name: true, id: true }).extend({ + identity: IdentitiesSchema.pick({ name: true, id: true, hasDeleteProtection: true }).extend({ authMethods: z.array(z.string()) }) }) @@ -292,7 +297,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { permissions: true, description: true }).optional(), - identity: IdentitiesSchema.pick({ name: true, id: true }).extend({ + identity: IdentitiesSchema.pick({ name: true, id: true, hasDeleteProtection: true }).extend({ authMethods: z.array(z.string()) }) }).array(), @@ -386,7 +391,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { permissions: true, description: true }).optional(), - identity: IdentitiesSchema.pick({ name: true, id: true }).extend({ + identity: IdentitiesSchema.pick({ name: true, id: true, hasDeleteProtection: true }).extend({ authMethods: z.array(z.string()) }) }).array(), @@ -451,7 +456,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { temporaryAccessEndTime: z.date().nullable().optional() }) ), - identity: IdentitiesSchema.pick({ name: true, id: true }).extend({ + identity: IdentitiesSchema.pick({ name: true, id: true, hasDeleteProtection: true }).extend({ authMethods: z.array(z.string()) }), project: SanitizedProjectSchema.pick({ name: true, id: true, type: true }) diff --git a/backend/src/services/identity-project/identity-project-dal.ts b/backend/src/services/identity-project/identity-project-dal.ts index 433f5ebd9..e5e59607d 100644 --- a/backend/src/services/identity-project/identity-project-dal.ts +++ b/backend/src/services/identity-project/identity-project-dal.ts @@ -101,6 +101,7 @@ export const identityProjectDALFactory = (db: TDbClient) => { db.ref("id").as("identityId").withSchema(TableName.Identity), db.ref("name").as("identityName").withSchema(TableName.Identity), + db.ref("hasDeleteProtection").withSchema(TableName.Identity), db.ref("id").withSchema(TableName.IdentityProjectMembership), db.ref("role").withSchema(TableName.IdentityProjectMembershipRole), db.ref("id").withSchema(TableName.IdentityProjectMembershipRole).as("membershipRoleId"), @@ -130,6 +131,7 @@ export const identityProjectDALFactory = (db: TDbClient) => { data: docs, parentMapper: ({ identityName, + hasDeleteProtection, uaId, awsId, gcpId, @@ -151,6 +153,7 @@ export const identityProjectDALFactory = (db: TDbClient) => { identity: { id: identityId, name: identityName, + hasDeleteProtection, authMethods: buildAuthMethods({ uaId, awsId, diff --git a/backend/src/services/identity/identity-org-dal.ts b/backend/src/services/identity/identity-org-dal.ts index b54a12679..28064c9bb 100644 --- a/backend/src/services/identity/identity-org-dal.ts +++ b/backend/src/services/identity/identity-org-dal.ts @@ -114,16 +114,18 @@ export const identityOrgDALFactory = (db: TDbClient) => { db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth), db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth), db.ref("id").as("ldapId").withSchema(TableName.IdentityLdapAuth), - db.ref("name").withSchema(TableName.Identity) + db.ref("name").withSchema(TableName.Identity), + db.ref("hasDeleteProtection").withSchema(TableName.Identity) ); if (data) { - const { name } = data; + const { name, hasDeleteProtection } = data; return { ...data, identity: { id: data.identityId, name, + hasDeleteProtection, authMethods: buildAuthMethods(data) } }; @@ -155,7 +157,8 @@ export const identityOrgDALFactory = (db: TDbClient) => { .orderBy(`${TableName.Identity}.${orderBy}`, orderDirection) .select( selectAllTableCols(TableName.IdentityOrgMembership), - db.ref("name").withSchema(TableName.Identity).as("identityName") + db.ref("name").withSchema(TableName.Identity).as("identityName"), + db.ref("hasDeleteProtection").withSchema(TableName.Identity) ) .where(filter) .as("paginatedIdentity"); @@ -245,6 +248,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { db.ref("updatedAt").withSchema("paginatedIdentity"), db.ref("identityId").withSchema("paginatedIdentity").as("identityId"), db.ref("identityName").withSchema("paginatedIdentity"), + db.ref("hasDeleteProtection").withSchema("paginatedIdentity"), db.ref("id").as("uaId").withSchema(TableName.IdentityUniversalAuth), db.ref("id").as("gcpId").withSchema(TableName.IdentityGcpAuth), @@ -286,6 +290,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { crName, identityId, identityName, + hasDeleteProtection, role, roleId, id, @@ -324,6 +329,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { identity: { id: identityId, name: identityName, + hasDeleteProtection, authMethods: buildAuthMethods({ uaId, alicloudId, @@ -476,6 +482,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { db.ref("updatedAt").withSchema(TableName.IdentityOrgMembership), db.ref("identityId").withSchema(TableName.IdentityOrgMembership).as("identityId"), db.ref("name").withSchema(TableName.Identity).as("identityName"), + db.ref("hasDeleteProtection").withSchema(TableName.Identity), db.ref("id").as("uaId").withSchema(TableName.IdentityUniversalAuth), db.ref("id").as("gcpId").withSchema(TableName.IdentityGcpAuth), @@ -518,6 +525,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { crName, identityId, identityName, + hasDeleteProtection, role, roleId, total_count, @@ -556,6 +564,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { identity: { id: identityId, name: identityName, + hasDeleteProtection, authMethods: buildAuthMethods({ uaId, alicloudId, diff --git a/backend/src/services/identity/identity-service.ts b/backend/src/services/identity/identity-service.ts index fd893713e..c3651ceb7 100644 --- a/backend/src/services/identity/identity-service.ts +++ b/backend/src/services/identity/identity-service.ts @@ -47,6 +47,7 @@ export const identityServiceFactory = ({ const createIdentity = async ({ name, role, + hasDeleteProtection, actor, orgId, actorId, @@ -96,7 +97,7 @@ export const identityServiceFactory = ({ } const identity = await identityDAL.transaction(async (tx) => { - const newIdentity = await identityDAL.create({ name }, tx); + const newIdentity = await identityDAL.create({ name, hasDeleteProtection }, tx); await identityOrgMembershipDAL.create( { identityId: newIdentity.id, @@ -138,6 +139,7 @@ export const identityServiceFactory = ({ const updateIdentity = async ({ id, role, + hasDeleteProtection, name, actor, actorId, @@ -189,7 +191,9 @@ export const identityServiceFactory = ({ } const identity = await identityDAL.transaction(async (tx) => { - const newIdentity = name ? await identityDAL.updateById(id, { name }, tx) : await identityDAL.findById(id, tx); + const newIdentity = name + ? await identityDAL.updateById(id, { name, hasDeleteProtection }, tx) + : await identityDAL.findById(id, tx); if (role) { await identityOrgMembershipDAL.updateById( identityOrgMembership.id, @@ -272,6 +276,9 @@ export const identityServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Delete, OrgPermissionSubjects.Identity); + if (identityOrgMembership.identity.hasDeleteProtection) + throw new BadRequestError({ message: "Identity has delete protection" }); + const deletedIdentity = await identityDAL.deleteById(id); await licenseService.updateSubscriptionOrgMemberCount(identityOrgMembership.orgId); diff --git a/backend/src/services/identity/identity-types.ts b/backend/src/services/identity/identity-types.ts index 363d42a88..8d23f34fe 100644 --- a/backend/src/services/identity/identity-types.ts +++ b/backend/src/services/identity/identity-types.ts @@ -5,12 +5,14 @@ import { OrderByDirection, TOrgPermission } from "@app/lib/types"; export type TCreateIdentityDTO = { role: string; name: string; + hasDeleteProtection: boolean; metadata?: { key: string; value: string }[]; } & TOrgPermission; export type TUpdateIdentityDTO = { id: string; role?: string; + hasDeleteProtection?: boolean; name?: string; metadata?: { key: string; value: string }[]; isActorSuperAdmin?: boolean; diff --git a/backend/src/services/telemetry/telemetry-types.ts b/backend/src/services/telemetry/telemetry-types.ts index a370d0332..ab8fc51c5 100644 --- a/backend/src/services/telemetry/telemetry-types.ts +++ b/backend/src/services/telemetry/telemetry-types.ts @@ -81,6 +81,7 @@ export type TMachineIdentityCreatedEvent = { event: PostHogEventTypes.MachineIdentityCreated; properties: { name: string; + hasDeleteProtection: boolean; orgId: string; identityId: string; }; diff --git a/frontend/src/hooks/api/auditLogs/types.tsx b/frontend/src/hooks/api/auditLogs/types.tsx index 006a3d7ca..56dc11aeb 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -242,6 +242,7 @@ interface CreateIdentityEvent { metadata: { identityId: string; name: string; + hasDeleteProtection: boolean; }; } @@ -250,6 +251,7 @@ interface UpdateIdentityEvent { metadata: { identityId: string; name?: string; + hasDeleteProtection?: boolean; }; } diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx index 52fe52dc2..7abf1a015 100644 --- a/frontend/src/hooks/api/identities/mutations.tsx +++ b/frontend/src/hooks/api/identities/mutations.tsx @@ -85,12 +85,13 @@ export const useCreateIdentity = () => { export const useUpdateIdentity = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ identityId, name, role, metadata }) => { + mutationFn: async ({ identityId, name, role, hasDeleteProtection, metadata }) => { const { data: { identity } } = await apiRequest.patch(`/api/v1/identities/${identityId}`, { name, role, + hasDeleteProtection, metadata }); diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index 116030131..873338f09 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -14,6 +14,7 @@ export type IdentityTrustedIp = { export type Identity = { id: string; name: string; + hasDeleteProtection: boolean; authMethods: IdentityAuthMethod[]; createdAt: string; updatedAt: string; @@ -83,6 +84,7 @@ export type CreateIdentityDTO = { name: string; organizationId: string; role?: string; + hasDeleteProtection: boolean; metadata?: { key: string; value: string }[]; }; @@ -90,6 +92,7 @@ export type UpdateIdentityDTO = { identityId: string; name?: string; role?: string; + hasDeleteProtection?: boolean; organizationId: string; metadata?: { key: string; value: string }[]; }; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx index 2b09143dc..70518ad3b 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx @@ -15,7 +15,8 @@ import { IconButton, Input, Modal, - ModalContent + ModalContent, + Switch } from "@app/components/v2"; import { useOrganization } from "@app/context"; import { findOrgMembershipRole } from "@app/helpers/roles"; @@ -27,6 +28,7 @@ const schema = z .object({ name: z.string().min(1, "Required"), role: z.object({ slug: z.string(), name: z.string() }), + hasDeleteProtection: z.boolean(), metadata: z .object({ key: z.string().trim().min(1), @@ -64,7 +66,8 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { } = useForm({ resolver: zodResolver(schema), defaultValues: { - name: "" + name: "", + hasDeleteProtection: false } }); @@ -78,6 +81,7 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { identityId: string; name: string; role: string; + hasDeleteProtection: boolean; metadata?: { key: string; value: string }[]; customRole: { name: string; @@ -91,22 +95,25 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { reset({ name: identity.name, role: identity.customRole ?? findOrgMembershipRole(roles, identity.role), + hasDeleteProtection: identity.hasDeleteProtection, metadata: identity.metadata }); } else { reset({ name: "", - role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole) + role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole), + hasDeleteProtection: false }); } }, [popUp?.identity?.data, roles]); - const onFormSubmit = async ({ name, role, metadata }: FormData) => { + const onFormSubmit = async ({ name, role, metadata, hasDeleteProtection }: FormData) => { try { const identity = popUp?.identity?.data as { identityId: string; name: string; role: string; + hasDeleteProtection: boolean; }; if (identity) { @@ -116,6 +123,7 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { identityId: identity.identityId, name, role: role.slug || undefined, + hasDeleteProtection, organizationId: orgId, metadata }); @@ -127,6 +135,7 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { const { id: createdId } = await createMutateAsync({ name, role: role.slug || undefined, + hasDeleteProtection, organizationId: orgId, metadata }); @@ -215,6 +224,24 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { )} /> + ( + + +

Delete Protection {value ? "Enabled" : "Disabled"}

+
+
+ )} + />
diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx index d499a7b36..f445505e0 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx @@ -329,7 +329,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { - + - + Name

{data.identity.name}

+
+

Delete Protection

+

+ {data.identity.hasDeleteProtection ? "On" : "Off"} +

+

Organization Role

{data.role}

diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityContentWrapper.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityContentWrapper.tsx index 05106c658..7ea2c8ba3 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityContentWrapper.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityContentWrapper.tsx @@ -35,7 +35,7 @@ export const ViewIdentityContentWrapper = ({ children, onDelete, onEdit }: Props Options - +