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) => {
-
+
}
+ rightIcon={
+
+ }
colorSchema="secondary"
+ className="group select-none"
>
Options
-
+
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
-
+