diff --git a/packages/entity/src/EntityFieldDefinition.ts b/packages/entity/src/EntityFieldDefinition.ts index 74dbcf2c..73a61b31 100644 --- a/packages/entity/src/EntityFieldDefinition.ts +++ b/packages/entity/src/EntityFieldDefinition.ts @@ -36,6 +36,35 @@ export enum EntityEdgeDeletionBehavior { SET_NULL, } +export enum EntityEdgeDeletionAuthorizationInferenceBehavior { + /** + * Authorization to delete (when CASCADE_DELETE_INVALIDATE_CACHE_ONLY or CASCADE_DELETE) or update + * (when SET_NULL_INVALIDATE_CACHE_ONLY or SET_NULL) all entities at the ends of edges of this type + * cannot be inferred from authorization of any single entity at the end of an edge of this type. + * + * To evaluate canViewerDeleteAsync for the source entity, canViewerDeleteAsync must be called on all + * entities at the ends of all edges of this type. + */ + NONE, + + /** + * Authorization to delete (when CASCADE_DELETE_INVALIDATE_CACHE_ONLY or CASCADE_DELETE) or update + * (when SET_NULL_INVALIDATE_CACHE_ONLY or SET_NULL) all entities at the ends of edges of this type + * may be inferred from authorization of any single entity at the end of an edge of this type. + * + * To evaluate canViewerDeleteAsync for the source entity, canViewerDeleteAsync must only be called on + * a single entity at the end of one edge of this type chosen at random. + * + * This should only be the case when the entity at the other end of this edge can be implicitly + * deleted/updated by virtue of the source entity deletion being authorized and a single authorization check + * on one edge of this type. + * + * Note that this is not used during actual deletions, only as an optimistic optimization during execution + * of canViewerDeleteAsync. Each entity being deleted will still check deletion privacy during actual deletion. + */ + ONE_IMPLIES_ALL, +} + /** * Defines an association between entities. An association is primarily used to define cascading deletion behavior. */ @@ -77,7 +106,7 @@ export interface EntityAssociationDefinition< associatedEntityLookupByField?: keyof TAssociatedFields; /** - * What action to perform on the current entity when the entity on the referencing end of + * What action to perform on the entity at the other end of this edge when the entity on the source end of * this edge is deleted. * * @remarks @@ -93,6 +122,14 @@ export interface EntityAssociationDefinition< * integrity is recommended. */ edgeDeletionBehavior: EntityEdgeDeletionBehavior; + + /** + * Optimization setting for evaluation of this edge in canViewerDeleteAsync. Can be used to optimize permission checks + * for edge cascading deletions based on application-specific design of the entity association. Not used during actual deletions. + * + * Defaults to EntityEdgeDeletionAuthorizationInferenceBehavior.NONE. + */ + edgeDeletionAuthorizationInferenceBehavior?: EntityEdgeDeletionAuthorizationInferenceBehavior; } /** diff --git a/packages/entity/src/utils/EntityPrivacyUtils.ts b/packages/entity/src/utils/EntityPrivacyUtils.ts index 4155acd3..8bdb7ea8 100644 --- a/packages/entity/src/utils/EntityPrivacyUtils.ts +++ b/packages/entity/src/utils/EntityPrivacyUtils.ts @@ -1,7 +1,10 @@ -import { asyncResult } from '@expo/results'; +import { Result, asyncResult } from '@expo/results'; import Entity, { IEntityClass } from '../Entity'; -import { EntityEdgeDeletionBehavior } from '../EntityFieldDefinition'; +import { + EntityEdgeDeletionBehavior, + EntityEdgeDeletionAuthorizationInferenceBehavior, +} from '../EntityFieldDefinition'; import { EntityCascadingDeletionInfo } from '../EntityMutationInfo'; import EntityPrivacyPolicy from '../EntityPrivacyPolicy'; import { EntityQueryContext } from '../EntityQueryContext'; @@ -254,16 +257,44 @@ async function canViewerDeleteInternalAsync< continue; } - const entityResultsForInboundEdge = await loader - .withAuthorizationResults() - .loadManyByFieldEqualingAsync( - fieldName, - association.associatedEntityLookupByField - ? sourceEntity.getField(association.associatedEntityLookupByField as any) - : sourceEntity.getID(), - ); + const edgeDeletionPermissionInferenceBehavior = + association.edgeDeletionAuthorizationInferenceBehavior; - const failedEntityLoadResults = failedResults(entityResultsForInboundEdge); + let entityResultsToCheckForInboundEdge: readonly Result[]; + + if ( + edgeDeletionPermissionInferenceBehavior === + EntityEdgeDeletionAuthorizationInferenceBehavior.ONE_IMPLIES_ALL + ) { + const singleEntityToTestForInboundEdge = await loader + .withAuthorizationResults() + .loadFirstByFieldEqualityConjunctionAsync( + [ + { + fieldName, + fieldValue: association.associatedEntityLookupByField + ? sourceEntity.getField(association.associatedEntityLookupByField as any) + : sourceEntity.getID(), + }, + ], + { orderBy: [] }, + ); + entityResultsToCheckForInboundEdge = singleEntityToTestForInboundEdge + ? [singleEntityToTestForInboundEdge] + : []; + } else { + const entityResultsForInboundEdge = await loader + .withAuthorizationResults() + .loadManyByFieldEqualingAsync( + fieldName, + association.associatedEntityLookupByField + ? sourceEntity.getField(association.associatedEntityLookupByField as any) + : sourceEntity.getID(), + ); + entityResultsToCheckForInboundEdge = entityResultsForInboundEdge; + } + + const failedEntityLoadResults = failedResults(entityResultsToCheckForInboundEdge); for (const failedResult of failedEntityLoadResults) { if (failedResult.reason instanceof EntityNotAuthorizedError) { return false; @@ -273,7 +304,9 @@ async function canViewerDeleteInternalAsync< } // all results should be success at this point due to check above - const entitiesForInboundEdge = entityResultsForInboundEdge.map((r) => r.enforceValue()); + const entitiesForInboundEdge = entityResultsToCheckForInboundEdge.map((r) => + r.enforceValue(), + ); switch (association.edgeDeletionBehavior) { case EntityEdgeDeletionBehavior.CASCADE_DELETE: diff --git a/packages/entity/src/utils/__tests__/canViewerDeleteAsync-edgeDeletionPermissionInferenceBehavior-test.ts b/packages/entity/src/utils/__tests__/canViewerDeleteAsync-edgeDeletionPermissionInferenceBehavior-test.ts new file mode 100644 index 00000000..a6126bd6 --- /dev/null +++ b/packages/entity/src/utils/__tests__/canViewerDeleteAsync-edgeDeletionPermissionInferenceBehavior-test.ts @@ -0,0 +1,254 @@ +import Entity from '../../Entity'; +import { EntityCompanionDefinition } from '../../EntityCompanionProvider'; +import EntityConfiguration from '../../EntityConfiguration'; +import { + EntityEdgeDeletionBehavior, + EntityEdgeDeletionAuthorizationInferenceBehavior, +} from '../../EntityFieldDefinition'; +import { UUIDField } from '../../EntityFields'; +import EntityPrivacyPolicy from '../../EntityPrivacyPolicy'; +import ReadonlyEntity from '../../ReadonlyEntity'; +import ViewerContext from '../../ViewerContext'; +import AlwaysAllowPrivacyPolicyRule from '../../rules/AlwaysAllowPrivacyPolicyRule'; +import AlwaysDenyPrivacyPolicyRule from '../../rules/AlwaysDenyPrivacyPolicyRule'; +import { canViewerDeleteAsync } from '../EntityPrivacyUtils'; +import { createUnitTestEntityCompanionProvider } from '../testing/createUnitTestEntityCompanionProvider'; + +describe(canViewerDeleteAsync, () => { + describe('edgeDeletionPermissionInferenceBehavior', () => { + it('optimizes when EntityEdgeDeletionPermissionInferenceBehavior.ONE_IMPLIES_ALL', async () => { + const companionProvider = createUnitTestEntityCompanionProvider(); + const viewerContext = new ViewerContext(companionProvider); + + // create root + const testEntity = await TestEntity.creator(viewerContext).enforceCreateAsync(); + + // create a bunch of leaves referencing root with + // edgeDeletionPermissionInferenceBehavior = EntityEdgeDeletionPermissionInferenceBehavior.ONE_IMPLIES_ALL + for (let i = 0; i < 10; i++) { + await TestLeafEntity.creator(viewerContext) + .setField('test_entity_id', testEntity.getID()) + .enforceCreateAsync(); + } + + for (let i = 0; i < 10; i++) { + await TestLeafLookupByFieldEntity.creator(viewerContext) + .setField('test_entity_id', testEntity.getID()) + .enforceCreateAsync(); + } + + const testLeafEntityCompanion = + viewerContext.getViewerScopedEntityCompanionForClass(TestLeafEntity); + const testLeafEntityAuthorizeDeleteSpy = jest.spyOn( + testLeafEntityCompanion.entityCompanion.privacyPolicy, + 'authorizeDeleteAsync', + ); + + const testLeafLookupByFieldEntityCompanion = + viewerContext.getViewerScopedEntityCompanionForClass(TestLeafLookupByFieldEntity); + const testLeafLookupByFieldEntityAuthorizeDeleteSpy = jest.spyOn( + testLeafLookupByFieldEntityCompanion.entityCompanion.privacyPolicy, + 'authorizeDeleteAsync', + ); + + const canViewerDelete = await canViewerDeleteAsync(TestEntity, testEntity); + expect(canViewerDelete).toBe(true); + + expect(testLeafEntityAuthorizeDeleteSpy).toHaveBeenCalledTimes(1); + expect(testLeafLookupByFieldEntityAuthorizeDeleteSpy).toHaveBeenCalledTimes(1); + }); + + it('does not optimize when undefined', async () => { + const companionProvider = createUnitTestEntityCompanionProvider(); + const viewerContext = new ViewerContext(companionProvider); + + // create root + const testEntity = await TestEntity.creator(viewerContext).enforceCreateAsync(); + + // create a bunch of leaves with no edgeDeletionPermissionInferenceBehavior + for (let i = 0; i < 10; i++) { + await TestLeafNoInferenceEntity.creator(viewerContext) + .setField('test_entity_id', testEntity.getID()) + .enforceCreateAsync(); + } + + const companion = + viewerContext.getViewerScopedEntityCompanionForClass(TestLeafNoInferenceEntity); + const authorizeDeleteSpy = jest.spyOn( + companion.entityCompanion.privacyPolicy, + 'authorizeDeleteAsync', + ); + + const canViewerDelete = await canViewerDeleteAsync(TestEntity, testEntity); + expect(canViewerDelete).toBe(true); + + expect(authorizeDeleteSpy).toHaveBeenCalledTimes(10); + }); + }); +}); + +type TestEntityFields = { + id: string; +}; + +type TestLeafEntityFields = { + id: string; + test_entity_id: string | null; +}; + +class AlwaysAllowEntityPrivacyPolicy< + TFields extends object, + TID extends NonNullable, + TViewerContext extends ViewerContext, + TEntity extends ReadonlyEntity, + TSelectedFields extends keyof TFields = keyof TFields, +> extends EntityPrivacyPolicy { + protected override readonly readRules = [ + new AlwaysAllowPrivacyPolicyRule(), + ]; + protected override readonly createRules = [ + new AlwaysAllowPrivacyPolicyRule(), + ]; + protected override readonly updateRules = [ + new AlwaysDenyPrivacyPolicyRule(), + ]; + protected override readonly deleteRules = [ + new AlwaysAllowPrivacyPolicyRule(), + ]; +} + +class TestEntity extends Entity { + static defineCompanionDefinition(): EntityCompanionDefinition< + TestEntityFields, + string, + ViewerContext, + TestEntity, + AlwaysAllowEntityPrivacyPolicy + > { + return { + entityClass: TestEntity, + entityConfiguration: new EntityConfiguration({ + idField: 'id', + tableName: 'blah', + inboundEdges: [TestLeafEntity, TestLeafLookupByFieldEntity, TestLeafNoInferenceEntity], + schema: { + id: new UUIDField({ + columnName: 'custom_id', + }), + }, + databaseAdapterFlavor: 'postgres', + cacheAdapterFlavor: 'redis', + }), + privacyPolicyClass: AlwaysAllowEntityPrivacyPolicy, + }; + } +} + +class TestLeafEntity extends Entity { + static defineCompanionDefinition(): EntityCompanionDefinition< + TestLeafEntityFields, + string, + ViewerContext, + TestLeafEntity, + AlwaysAllowEntityPrivacyPolicy + > { + return { + entityClass: TestLeafEntity, + entityConfiguration: new EntityConfiguration({ + idField: 'id', + tableName: 'blah_2', + schema: { + id: new UUIDField({ + columnName: 'custom_id', + }), + test_entity_id: new UUIDField({ + columnName: 'test_entity_id', + association: { + associatedEntityClass: TestEntity, + edgeDeletionBehavior: EntityEdgeDeletionBehavior.CASCADE_DELETE, + edgeDeletionAuthorizationInferenceBehavior: + EntityEdgeDeletionAuthorizationInferenceBehavior.ONE_IMPLIES_ALL, + }, + }), + }, + databaseAdapterFlavor: 'postgres', + cacheAdapterFlavor: 'redis', + }), + privacyPolicyClass: AlwaysAllowEntityPrivacyPolicy, + }; + } +} + +class TestLeafLookupByFieldEntity extends Entity { + static defineCompanionDefinition(): EntityCompanionDefinition< + TestLeafEntityFields, + string, + ViewerContext, + TestLeafEntity, + AlwaysAllowEntityPrivacyPolicy + > { + return { + entityClass: TestLeafEntity, + entityConfiguration: new EntityConfiguration({ + idField: 'id', + tableName: 'blah_4', + schema: { + id: new UUIDField({ + columnName: 'custom_id', + }), + test_entity_id: new UUIDField({ + columnName: 'test_entity_id', + association: { + associatedEntityClass: TestEntity, + edgeDeletionBehavior: EntityEdgeDeletionBehavior.CASCADE_DELETE, + associatedEntityLookupByField: 'id', + edgeDeletionAuthorizationInferenceBehavior: + EntityEdgeDeletionAuthorizationInferenceBehavior.ONE_IMPLIES_ALL, + }, + }), + }, + databaseAdapterFlavor: 'postgres', + cacheAdapterFlavor: 'redis', + }), + privacyPolicyClass: AlwaysAllowEntityPrivacyPolicy, + }; + } +} + +class TestLeafNoInferenceEntity extends Entity { + static defineCompanionDefinition(): EntityCompanionDefinition< + TestLeafEntityFields, + string, + ViewerContext, + TestLeafNoInferenceEntity, + AlwaysAllowEntityPrivacyPolicy< + TestLeafEntityFields, + string, + ViewerContext, + TestLeafNoInferenceEntity + > + > { + return { + entityClass: TestLeafNoInferenceEntity, + entityConfiguration: new EntityConfiguration({ + idField: 'id', + tableName: 'blah_3', + schema: { + id: new UUIDField({ + columnName: 'custom_id', + }), + test_entity_id: new UUIDField({ + columnName: 'test_entity_id', + association: { + associatedEntityClass: TestEntity, + edgeDeletionBehavior: EntityEdgeDeletionBehavior.CASCADE_DELETE, + }, + }), + }, + databaseAdapterFlavor: 'postgres', + cacheAdapterFlavor: 'redis', + }), + privacyPolicyClass: AlwaysAllowEntityPrivacyPolicy, + }; + } +}