Skip to content

Commit

Permalink
[backend] Prevent retention rules to delete user individuals (#7787)
Browse files Browse the repository at this point in the history
  • Loading branch information
SouadHadjiat authored Nov 22, 2024
1 parent 62f648c commit 15873b0
Show file tree
Hide file tree
Showing 10 changed files with 210 additions and 88 deletions.
87 changes: 87 additions & 0 deletions opencti-platform/opencti-graphql/src/database/data-consistency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { getEntityFromCache } from './cache';
import { FunctionalError } from '../config/errors';
import { FilterMode } from '../generated/graphql';
import { countAllThings, listEntitiesThroughRelationsPaginated } from './middleware-loader';
import { type BasicStoreEntityOrganization, ENTITY_TYPE_IDENTITY_ORGANIZATION } from '../modules/organization/organization-types';
import { ENTITY_TYPE_SETTINGS, ENTITY_TYPE_USER } from '../schema/internalObject';
import { RELATION_PARTICIPATE_TO } from '../schema/internalRelationship';
import { ENTITY_TYPE_IDENTITY_INDIVIDUAL } from '../schema/stixDomainObject';
import type { AuthContext, AuthUser } from '../types/user';
import type { BasicStoreEntity, StoreObject } from '../types/store';
import type { BasicStoreSettings } from '../types/settings';
import { isEmptyField, READ_INDEX_INTERNAL_OBJECTS } from './utils';
import { isUserHasCapability, SETTINGS_SET_ACCESSES, SYSTEM_USER } from '../utils/access';

export const isIndividualAssociatedToUser = async (context: AuthContext, individual: BasicStoreEntity) => {
const individualContactInformation = individual.contact_information;
if (isEmptyField(individualContactInformation)) {
return false;
}
const args = {
filters: {
mode: FilterMode.And,
filters: [{ key: ['user_email'], values: [individualContactInformation] }],
filterGroups: [],
},
noFiltersChecking: true,
indices: [READ_INDEX_INTERNAL_OBJECTS],
};
const usersCount = await countAllThings(context, SYSTEM_USER, args);
return usersCount > 0;
};

export const verifyCanDeleteIndividual = async (context: AuthContext, user: AuthUser, individual: BasicStoreEntity, throwErrors = true) => {
const isAssociatedToUser = await isIndividualAssociatedToUser(context, individual);
if (isAssociatedToUser) {
if (throwErrors) throw FunctionalError('Cannot delete an individual corresponding to a user');
return false;
}
return true;
};

export const verifyCanDeleteOrganization = async (context: AuthContext, user: AuthUser, organization: BasicStoreEntityOrganization, throwErrors = true) => {
const settings = await getEntityFromCache<BasicStoreSettings>(context, SYSTEM_USER, ENTITY_TYPE_SETTINGS);
if (organization.id === settings.platform_organization) {
if (throwErrors) throw FunctionalError('Cannot delete the platform organization.');
return false;
}
if (organization.authorized_authorities && organization.authorized_authorities.length > 0) {
if (isUserHasCapability(user, SETTINGS_SET_ACCESSES)) {
if (throwErrors) throw FunctionalError('Cannot delete an organization that has an administrator.');
return false;
}
// no information leakage about the organization administrators or members
if (throwErrors) throw FunctionalError('Cannot delete the organization.');
return false;
}
// organizationMembersPaginated
const members = await listEntitiesThroughRelationsPaginated(
context,
user,
organization.id,
RELATION_PARTICIPATE_TO,
ENTITY_TYPE_USER,
true,
{ first: 1, indices: [READ_INDEX_INTERNAL_OBJECTS] }
);
if (members.pageInfo.globalCount > 0) {
if (isUserHasCapability(user, SETTINGS_SET_ACCESSES)) {
if (throwErrors) throw FunctionalError('Cannot delete an organization that has members.');
return false;
}
// no information leakage about the organization administrators or members
if (throwErrors) throw FunctionalError('Cannot delete the organization.');
return false;
}
return true;
};

export const canDeleteElement = async (context: AuthContext, user: AuthUser, element: StoreObject) => {
if (element.entity_type === ENTITY_TYPE_IDENTITY_INDIVIDUAL) {
return verifyCanDeleteIndividual(context, user, element as BasicStoreEntity, false);
}
if (element.entity_type === ENTITY_TYPE_IDENTITY_ORGANIZATION) {
return verifyCanDeleteOrganization(context, user, element as unknown as BasicStoreEntityOrganization, false);
}
return true;
};
35 changes: 10 additions & 25 deletions opencti-platform/opencti-graphql/src/database/middleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -195,13 +195,15 @@ import { ENTITY_TYPE_INDICATOR } from '../modules/indicator/indicator-types';
import { ENTITY_TYPE_CONTAINER_FEEDBACK } from '../modules/case/feedback/feedback-types';
import { FilterMode, FilterOperator } from '../generated/graphql';
import { getMandatoryAttributesForSetting } from '../modules/entitySetting/entitySetting-attributeUtils';
import { ENTITY_TYPE_IDENTITY_ORGANIZATION } from '../modules/organization/organization-types';
import {
adaptUpdateInputsConfidence,
controlCreateInputWithUserConfidence,
controlUpsertInputWithUserConfidence,
controlUserConfidenceAgainstElement
} from '../utils/confidence-level';
import { buildEntityData, buildInnerRelation, buildRelationData } from './data-builder';
import { isIndividualAssociatedToUser, verifyCanDeleteIndividual, verifyCanDeleteOrganization } from './data-consistency';
import { deleteAllObjectFiles, moveAllFilesFromEntityToAnother, uploadToStorage } from './file-storage-helper';
import { storeFileConverter } from './file-storage';
import { getDraftContext } from '../utils/draftContext';
Expand Down Expand Up @@ -1883,17 +1885,8 @@ export const updateAttributeMetaResolved = async (context, user, initial, inputs
// Individual check
const { bypassIndividualUpdate } = opts;
if (initial.entity_type === ENTITY_TYPE_IDENTITY_INDIVIDUAL && !isEmptyField(initial.contact_information) && !bypassIndividualUpdate) {
const args = {
filters: {
mode: 'and',
filters: [{ key: 'user_email', values: [initial.contact_information] }],
filterGroups: [],
},
noFiltersChecking: true,
connectionFormat: false
};
const users = await listEntities(context, SYSTEM_USER, [ENTITY_TYPE_USER], args);
if (users.length > 0) {
const isIndividualUser = await isIndividualAssociatedToUser(context, initial);
if (isIndividualUser) {
throw FunctionalError('Cannot update an individual corresponding to a user');
}
}
Expand Down Expand Up @@ -3188,20 +3181,12 @@ export const internalDeleteElementById = async (context, user, id, opts = {}) =>
}
// endregion
// Prevent individual deletion if linked to a user
if (element.entity_type === ENTITY_TYPE_IDENTITY_INDIVIDUAL && !isEmptyField(element.contact_information)) {
const args = {
filters: {
mode: 'and',
filters: [{ key: 'user_email', values: [element.contact_information] }],
filterGroups: [],
},
noFiltersChecking: true,
connectionFormat: false
};
const users = await listEntities(context, SYSTEM_USER, [ENTITY_TYPE_USER], args);
if (users.length > 0) {
throw FunctionalError('Cannot delete an individual corresponding to a user');
}
if (element.entity_type === ENTITY_TYPE_IDENTITY_INDIVIDUAL) {
await verifyCanDeleteIndividual(context, user, element);
}
// Prevent organization deletion if platform orga or has members
if (element.entity_type === ENTITY_TYPE_IDENTITY_ORGANIZATION) {
await verifyCanDeleteOrganization(context, user, element);
}
if (!validateUserAccessOperation(user, element, 'delete')) {
throw ForbiddenAccess();
Expand Down
20 changes: 3 additions & 17 deletions opencti-platform/opencti-graphql/src/domain/individual.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,11 @@ import * as R from 'ramda';
import { createEntity } from '../database/middleware';
import { listEntities, listEntitiesThroughRelationsPaginated, storeLoadById } from '../database/middleware-loader';
import { BUS_TOPICS } from '../config/conf';
import { isIndividualAssociatedToUser } from '../database/data-consistency';
import { notify } from '../database/redis';
import { ENTITY_TYPE_IDENTITY_INDIVIDUAL } from '../schema/stixDomainObject';
import { ABSTRACT_STIX_DOMAIN_OBJECT } from '../schema/general';
import { RELATION_PART_OF } from '../schema/stixCoreRelationship';
import { SYSTEM_USER } from '../utils/access';
import { ENTITY_TYPE_USER } from '../schema/internalObject';
import { isEmptyField } from '../database/utils';
import { ENTITY_TYPE_IDENTITY_ORGANIZATION } from '../modules/organization/organization-types';

export const findById = (context, user, individualId) => {
Expand All @@ -29,18 +27,6 @@ export const partOfOrganizationsPaginated = async (context, user, individualId,
return listEntitiesThroughRelationsPaginated(context, user, individualId, RELATION_PART_OF, ENTITY_TYPE_IDENTITY_ORGANIZATION, false, args);
};

export const isUser = async (context, individualContactInformation) => {
if (isEmptyField(individualContactInformation)) {
return false;
}
const args = {
filters: {
mode: 'and',
filters: [{ key: 'user_email', values: [individualContactInformation] }],
filterGroups: [],
},
connectionFormat: false
};
const users = await listEntities(context, SYSTEM_USER, [ENTITY_TYPE_USER], args);
return users.length > 0;
export const isUser = async (context, individual) => {
return isIndividualAssociatedToUser(context, individual);
};
29 changes: 16 additions & 13 deletions opencti-platform/opencti-graphql/src/manager/retentionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,9 @@ import { registerManager } from './managerModule';
import type { AuthContext } from '../types/user';
import type { FileEdge, RetentionRule } from '../generated/graphql';
import { RetentionRuleScope, RetentionUnit } from '../generated/graphql';
import { canDeleteElement } from '../database/data-consistency';
import { deleteFile } from '../database/file-storage';
import { DELETABLE_FILE_STATUSES, paginatedForPathWithEnrichment } from '../modules/internal/document/document-domain';
import { ENTITY_TYPE_IDENTITY_ORGANIZATION } from '../modules/organization/organization-types';
import { organizationDelete } from '../modules/organization/organization-domain';
import type { BasicStoreCommonEdge, StoreObject } from '../types/store';
import { ALREADY_DELETED_ERROR } from '../config/errors';

Expand All @@ -35,12 +34,7 @@ export const RETENTION_UNIT_VALUES = Object.values(RetentionUnit);

export const deleteElement = async (context: AuthContext, scope: string, nodeId: string, nodeEntityType?: string) => {
if (scope === 'knowledge') {
if (nodeEntityType === ENTITY_TYPE_IDENTITY_ORGANIZATION) {
// call organizationDelete which will ensure protections (for platform organization & members)
await organizationDelete(context, RETENTION_MANAGER_USER, nodeId);
} else {
await deleteElementById(context, RETENTION_MANAGER_USER, nodeId, nodeEntityType, { forceDelete: true });
}
await deleteElementById(context, RETENTION_MANAGER_USER, nodeId, nodeEntityType, { forceDelete: true });
} else if (scope === 'file' || scope === 'workbench') {
await deleteFile(context, RETENTION_MANAGER_USER, nodeId);
} else {
Expand Down Expand Up @@ -73,18 +67,27 @@ const executeProcessing = async (context: AuthContext, retentionRule: RetentionR
logApp.debug(`[OPENCTI] Executing retention manager rule ${name}`);
const before = utcDate().subtract(maxNumber, unit ?? 'days');
const result = await getElementsToDelete(context, scope, before, filters);
const remainingDeletions = result.pageInfo.globalCount;
let remainingDeletions = result.pageInfo.globalCount;
const elements = result.edges;
let deletedCount = elements.length;
if (elements.length > 0) {
logApp.debug(`[OPENCTI] Retention manager clearing ${elements.length} elements`);
const start = new Date().getTime();
const deleteFn = async (element: BasicStoreCommonEdge<StoreObject>) => {
const { node } = element;
const { updated_at: up } = node;
try {
const humanDuration = moment.duration(utcDate(up).diff(utcDate())).humanize();
await deleteElement(context, scope, scope === 'knowledge' ? node.internal_id : node.id, node.entity_type);
logApp.debug(`[OPENCTI] Retention manager deleting ${node.id} after ${humanDuration}`);
const canElementBeDeleted = await canDeleteElement(context, RETENTION_MANAGER_USER, node);
if (canElementBeDeleted) { // filter elements that can't be deleted (ex: user individuals)
const humanDuration = moment.duration(utcDate(up).diff(utcDate())).humanize();
await deleteElement(context, scope, scope === 'knowledge' ? node.internal_id : node.id, node.entity_type);
logApp.debug(`[OPENCTI] Retention manager deleting ${node.id} after ${humanDuration}`);
} else {
// remove element from counters, since we can't delete it
remainingDeletions -= 1;
deletedCount -= 1;
logApp.debug(`[OPENCTI] Retention manager cannot delete ${node.id}.`);
}
} catch (err: any) {
// Only log the error if not an already deleted message (that can happen though concurrency deletion)
if (err?.extensions?.code !== ALREADY_DELETED_ERROR) {
Expand All @@ -99,7 +102,7 @@ const executeProcessing = async (context: AuthContext, retentionRule: RetentionR
const patch = {
last_execution_date: now(),
remaining_count: remainingDeletions,
last_deleted_count: elements.length,
last_deleted_count: deletedCount,
};
await patchAttribute(context, RETENTION_MANAGER_USER, id, ENTITY_TYPE_RETENTION_RULE, patch);
};
Expand Down
4 changes: 0 additions & 4 deletions opencti-platform/opencti-graphql/src/manager/taskManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,6 @@ import { BackgroundTaskScope } from '../generated/graphql';
import { ENTITY_TYPE_INTERNAL_FILE } from '../schema/internalObject';
import { deleteFile } from '../database/file-storage';
import { checkUserIsAdminOnDashboard } from '../modules/publicDashboard/publicDashboard-utils';
import { organizationDelete } from '../modules/organization/organization-domain';
import { ENTITY_TYPE_IDENTITY_ORGANIZATION } from '../modules/organization/organization-types';

// Task manager responsible to execute long manual tasks
Expand Down Expand Up @@ -238,9 +237,6 @@ const executeDelete = async (context, user, element, scope) => {
}
if (scope === BackgroundTaskScope.Import) {
await deleteFile(context, user, element.id);
} else if (element.entity_type === ENTITY_TYPE_IDENTITY_ORGANIZATION) {
// call organizationDelete which will ensure protections (for platform organization & members)
await organizationDelete(context, user, element.internal_id);
} else {
await deleteElementById(context, user, element.internal_id, element.entity_type);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,18 @@ import {
} from '../../database/middleware-loader';
import { BUS_TOPICS } from '../../config/conf';
import { notify } from '../../database/redis';
import { getEntityFromCache } from '../../database/cache';
import { READ_INDEX_INTERNAL_OBJECTS } from '../../database/utils';
import { verifyCanDeleteOrganization } from '../../database/data-consistency';
import { ENTITY_TYPE_IDENTITY_SECTOR } from '../../schema/stixDomainObject';
import { RELATION_PART_OF } from '../../schema/stixCoreRelationship';
import { ABSTRACT_STIX_DOMAIN_OBJECT } from '../../schema/general';
import { RELATION_PARTICIPATE_TO } from '../../schema/internalRelationship';
import { ENTITY_TYPE_SETTINGS, ENTITY_TYPE_USER } from '../../schema/internalObject';
import { ENTITY_TYPE_USER } from '../../schema/internalObject';
import { type BasicStoreEntityOrganization, ENTITY_TYPE_IDENTITY_ORGANIZATION } from './organization-types';
import type { AuthContext, AuthUser } from '../../types/user';
import type { BasicObject, OrganizationAddInput, ResolversTypes } from '../../generated/graphql';
import { AlreadyDeletedError, FunctionalError } from '../../config/errors';
import { isUserHasCapability, SETTINGS_SET_ACCESSES, SYSTEM_USER } from '../../utils/access';
import { isUserHasCapability, SETTINGS_SET_ACCESSES } from '../../utils/access';
import { publishUserAction } from '../../listener/UserActionListener';
import type { BasicStoreSettings } from '../../types/settings';
import type { BasicStoreCommon, BasicStoreEntity } from '../../types/store';
import { userSessionRefresh } from '../../domain/user';

Expand Down Expand Up @@ -142,25 +140,7 @@ export const organizationDelete = async (context: AuthContext, user: AuthUser, o
if (!organization) {
throw AlreadyDeletedError({ organizationId });
}
const settings = await getEntityFromCache<BasicStoreSettings>(context, SYSTEM_USER, ENTITY_TYPE_SETTINGS);
if (organization.id === settings.platform_organization) {
throw FunctionalError('Cannot delete the platform organization.');
}
if (organization.authorized_authorities && organization.authorized_authorities.length > 0) {
if (isUserHasCapability(user, SETTINGS_SET_ACCESSES)) {
throw FunctionalError('Cannot delete an organization that has an administrator.');
}
// no information leakage about the organization administrators or members
throw FunctionalError('Cannot delete the organization.');
}
const members = await organizationMembersPaginated(context, user, organization.id, { first: 1, indices: [READ_INDEX_INTERNAL_OBJECTS] });
if (members.pageInfo.globalCount > 0) {
if (isUserHasCapability(user, SETTINGS_SET_ACCESSES)) {
throw FunctionalError('Cannot delete an organization that has members.');
}
// no information leakage about the organization administrators or members
throw FunctionalError('Cannot delete the organization.');
}
await verifyCanDeleteOrganization(context, user, organization);
await deleteElementById(context, user, organizationId, ENTITY_TYPE_IDENTITY_ORGANIZATION);
await notify(BUS_TOPICS[ABSTRACT_STIX_DOMAIN_OBJECT].DELETE_TOPIC, organizationId, user);
return organizationId;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const individualResolvers = {
},
Individual: {
organizations: (individual, args, context) => partOfOrganizationsPaginated(context, context.user, individual.id, args),
isUser: (individual, _, context) => isUser(context, individual.contact_information),
isUser: (individual, _, context) => isUser(context, individual),
},
Mutation: {
individualEdit: (_, { id }, context) => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
updateAttribute,
} from '../../../src/database/middleware';
import { elFindByIds, elLoadById, elRawSearch } from '../../../src/database/engine';
import { ADMIN_USER, buildStandardUser, testContext } from '../../utils/testQuery';
import { ADMIN_USER, buildStandardUser, TEST_ORGANIZATION, testContext } from '../../utils/testQuery';
import {
ENTITY_TYPE_ATTACK_PATTERN,
ENTITY_TYPE_CAMPAIGN,
Expand Down Expand Up @@ -1423,3 +1423,15 @@ describe('Elements deduplication behaviors', () => {
await deleteElementById(testContext, ADMIN_USER, group2.id, ENTITY_TYPE_THREAT_ACTOR_GROUP);
});
});

describe('Delete functional errors behaviors', () => {
it('should not be able to delete organization that has members', async () => {
await expect(() => deleteElementById(testContext, ADMIN_USER, TEST_ORGANIZATION.id, ENTITY_TYPE_IDENTITY_ORGANIZATION))
.rejects.toThrowError('Cannot delete an organization that has members.');
});
it.skip('should not be able to delete individual associated to user', async () => {
const individualUserId = 'identity--cfb1de38-c40a-5f51-81f3-35036a4e3b91'; // admin individual
await expect(() => deleteElementById(testContext, ADMIN_USER, individualUserId, ENTITY_TYPE_IDENTITY_INDIVIDUAL))
.rejects.toThrowError('Cannot delete an individual corresponding to a user');
});
});
Loading

0 comments on commit 15873b0

Please sign in to comment.