diff --git a/packages/entity/src/Entity.ts b/packages/entity/src/Entity.ts index 280d37fe..11f07e62 100644 --- a/packages/entity/src/Entity.ts +++ b/packages/entity/src/Entity.ts @@ -1,4 +1,4 @@ -import { Result, asyncResult } from '@expo/results'; +import { Result } from '@expo/results'; import { EntityCompanionDefinition } from './EntityCompanionProvider'; import { CreateMutator, UpdateMutator } from './EntityMutator'; @@ -197,121 +197,6 @@ export default abstract class Entity< .forDelete(existingEntity, queryContext) .enforceDeleteAsync(); } - - /** - * Check whether an entity loaded by a viewer can be updated by that same viewer. - * - * @remarks - * - * This may be useful in situations relying upon the thrown privacy policy thrown authorization error - * is insufficient for the task at hand. When dealing with purely a sequence of mutations it is easy - * to roll back all mutations given a single authorization error by wrapping them in a single transaction. - * When certain portions of a mutation cannot be rolled back transactionally (third pary calls, - * legacy code, etc), using this method can help decide whether the sequence of mutations will fail before - * attempting them. Note that if any privacy policy rules use a piece of data being updated in the mutations - * the result of this method and the update mutation itself may differ. - * - * @param existingEntity - entity loaded by viewer - * @param queryContext - query context in which to perform the check - */ - static async canViewerUpdateAsync< - TMFields extends object, - TMID extends NonNullable, - TMViewerContext extends ViewerContext, - TMEntity extends Entity, - TMPrivacyPolicy extends EntityPrivacyPolicy< - TMFields, - TMID, - TMViewerContext, - TMEntity, - TMSelectedFields - >, - TMSelectedFields extends keyof TMFields = keyof TMFields - >( - this: IEntityClass< - TMFields, - TMID, - TMViewerContext, - TMEntity, - TMPrivacyPolicy, - TMSelectedFields - >, - existingEntity: TMEntity, - queryContext: EntityQueryContext = existingEntity - .getViewerContext() - .getViewerScopedEntityCompanionForClass(this) - .getQueryContextProvider() - .getQueryContext() - ): Promise { - const companion = existingEntity - .getViewerContext() - .getViewerScopedEntityCompanionForClass(this); - const privacyPolicy = companion.entityCompanion.privacyPolicy; - const evaluationResult = await asyncResult( - privacyPolicy.authorizeUpdateAsync( - existingEntity.getViewerContext(), - queryContext, - { cascadingDeleteCause: null }, - existingEntity, - companion.getMetricsAdapter() - ) - ); - return evaluationResult.ok; - } - - /** - * Check whether an entity loaded by a viewer can be deleted by that same viewer. - * - * @remarks - * See remarks for canViewerUpdate. - * - * @param existingEntity - entity loaded by viewer - * @param queryContext - query context in which to perform the check - */ - static async canViewerDeleteAsync< - TMFields extends object, - TMID extends NonNullable, - TMViewerContext extends ViewerContext, - TMEntity extends Entity, - TMPrivacyPolicy extends EntityPrivacyPolicy< - TMFields, - TMID, - TMViewerContext, - TMEntity, - TMSelectedFields - >, - TMSelectedFields extends keyof TMFields = keyof TMFields - >( - this: IEntityClass< - TMFields, - TMID, - TMViewerContext, - TMEntity, - TMPrivacyPolicy, - TMSelectedFields - >, - existingEntity: TMEntity, - queryContext: EntityQueryContext = existingEntity - .getViewerContext() - .getViewerScopedEntityCompanionForClass(this) - .getQueryContextProvider() - .getQueryContext() - ): Promise { - const companion = existingEntity - .getViewerContext() - .getViewerScopedEntityCompanionForClass(this); - const privacyPolicy = companion.entityCompanion.privacyPolicy; - const evaluationResult = await asyncResult( - privacyPolicy.authorizeDeleteAsync( - existingEntity.getViewerContext(), - queryContext, - { cascadingDeleteCause: null }, - existingEntity, - companion.getMetricsAdapter() - ) - ); - return evaluationResult.ok; - } } /** diff --git a/packages/entity/src/EntityMutator.ts b/packages/entity/src/EntityMutator.ts index 26788e4b..8fa0a7c4 100644 --- a/packages/entity/src/EntityMutator.ts +++ b/packages/entity/src/EntityMutator.ts @@ -20,7 +20,6 @@ import EntityMutationTriggerConfiguration, { import EntityMutationValidator from './EntityMutationValidator'; import EntityPrivacyPolicy from './EntityPrivacyPolicy'; import { EntityQueryContext, EntityTransactionalQueryContext } from './EntityQueryContext'; -import ReadonlyEntity from './ReadonlyEntity'; import ViewerContext from './ViewerContext'; import EntityInvalidFieldValueError from './errors/EntityInvalidFieldValueError'; import { timeAndLogMutationEventAsync } from './metrics/EntityMetricsUtils'; @@ -741,8 +740,23 @@ export class DeleteMutator< ).entityCompanionDefinition; const entityConfiguration = companionDefinition.entityConfiguration; const inboundEdges = entityConfiguration.inboundEdges; + + const newCascadingDeleteCause = { + entity, + cascadingDeleteCause, + }; + await Promise.all( inboundEdges.map(async (entityClass) => { + const loaderFactory = entity + .getViewerContext() + .getViewerScopedEntityCompanionForClass(entityClass) + .getLoaderFactory(); + const mutatorFactory = entity + .getViewerContext() + .getViewerScopedEntityCompanionForClass(entityClass) + .getMutatorFactory(); + return await mapMapAsync( this.companionProvider.getCompanionForEntity(entityClass).entityCompanionDefinition .entityConfiguration.schema, @@ -759,37 +773,15 @@ export class DeleteMutator< return; } - const associatedEntityLookupByField = association.associatedEntityLookupByField; - - const loaderFactory = entity - .getViewerContext() - .getViewerScopedEntityCompanionForClass(entityClass) - .getLoaderFactory(); - const mutatorFactory = entity - .getViewerContext() - .getViewerScopedEntityCompanionForClass(entityClass) - .getMutatorFactory(); - - const newCascadingDeleteCause = { - entity, - cascadingDeleteCause, - }; - - let inboundReferenceEntities: readonly ReadonlyEntity[]; - if (associatedEntityLookupByField) { - inboundReferenceEntities = await loaderFactory - .forLoad(queryContext, { cascadingDeleteCause: newCascadingDeleteCause }) - .enforcing() - .loadManyByFieldEqualingAsync( - fieldName, - entity.getField(associatedEntityLookupByField as any) - ); - } else { - inboundReferenceEntities = await loaderFactory - .forLoad(queryContext, { cascadingDeleteCause: newCascadingDeleteCause }) - .enforcing() - .loadManyByFieldEqualingAsync(fieldName, entity.getID()); - } + const inboundReferenceEntities = await loaderFactory + .forLoad(queryContext, { cascadingDeleteCause: newCascadingDeleteCause }) + .enforcing() + .loadManyByFieldEqualingAsync( + fieldName, + association.associatedEntityLookupByField + ? entity.getField(association.associatedEntityLookupByField as any) + : entity.getID() + ); switch (association.edgeDeletionBehavior) { case EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY: { diff --git a/packages/entity/src/__tests__/Entity-test.ts b/packages/entity/src/__tests__/Entity-test.ts index 74fde329..331254b1 100644 --- a/packages/entity/src/__tests__/Entity-test.ts +++ b/packages/entity/src/__tests__/Entity-test.ts @@ -1,12 +1,6 @@ import Entity from '../Entity'; -import { EntityCompanionDefinition } from '../EntityCompanionProvider'; -import EntityConfiguration from '../EntityConfiguration'; -import { UUIDField } from '../EntityFields'; import { CreateMutator, UpdateMutator } from '../EntityMutator'; -import EntityPrivacyPolicy from '../EntityPrivacyPolicy'; import ViewerContext from '../ViewerContext'; -import AlwaysAllowPrivacyPolicyRule from '../rules/AlwaysAllowPrivacyPolicyRule'; -import AlwaysDenyPrivacyPolicyRule from '../rules/AlwaysDenyPrivacyPolicyRule'; import SimpleTestEntity from '../testfixtures/SimpleTestEntity'; import { createUnitTestEntityCompanionProvider } from '../utils/testing/createUnitTestEntityCompanionProvider'; @@ -35,200 +29,4 @@ describe(Entity, () => { expect(SimpleTestEntity.updater(testEntity)).toBeInstanceOf(UpdateMutator); }); }); - - describe('canViewerUpdateAsync', () => { - it('appropriately executes update privacy policy', async () => { - const companionProvider = createUnitTestEntityCompanionProvider(); - const viewerContext = new ViewerContext(companionProvider); - const data = { - id: 'what', - }; - const testEntity = new SimpleTestDenyDeleteEntity({ - viewerContext, - id: 'what', - databaseFields: data, - selectedFields: data, - }); - const canViewerUpdate = await SimpleTestDenyDeleteEntity.canViewerUpdateAsync(testEntity); - expect(canViewerUpdate).toBe(true); - }); - - it('denies when policy denies', async () => { - const companionProvider = createUnitTestEntityCompanionProvider(); - const viewerContext = new ViewerContext(companionProvider); - const data = { - id: 'what', - }; - const testEntity = new SimpleTestDenyUpdateEntity({ - viewerContext, - id: 'what', - databaseFields: data, - selectedFields: data, - }); - const canViewerUpdate = await SimpleTestDenyUpdateEntity.canViewerUpdateAsync(testEntity); - expect(canViewerUpdate).toBe(false); - }); - }); - - describe('canViewerDeleteAsync', () => { - it('appropriately executes update privacy policy', async () => { - const companionProvider = createUnitTestEntityCompanionProvider(); - const viewerContext = new ViewerContext(companionProvider); - const data = { - id: 'what', - }; - const testEntity = new SimpleTestDenyUpdateEntity({ - viewerContext, - id: 'what', - databaseFields: data, - selectedFields: data, - }); - const canViewerDelete = await SimpleTestDenyUpdateEntity.canViewerDeleteAsync(testEntity); - expect(canViewerDelete).toBe(true); - }); - - it('denies when policy denies', async () => { - const companionProvider = createUnitTestEntityCompanionProvider(); - const viewerContext = new ViewerContext(companionProvider); - const data = { - id: 'what', - }; - const testEntity = new SimpleTestDenyDeleteEntity({ - viewerContext, - id: 'what', - databaseFields: data, - selectedFields: data, - }); - const canViewerDelete = await SimpleTestDenyDeleteEntity.canViewerDeleteAsync(testEntity); - expect(canViewerDelete).toBe(false); - }); - }); }); - -type TestEntityFields = { - id: string; -}; - -const testEntityConfiguration = new EntityConfiguration({ - idField: 'id', - tableName: 'blah', - schema: { - id: new UUIDField({ - columnName: 'custom_id', - }), - }, - databaseAdapterFlavor: 'postgres', - cacheAdapterFlavor: 'redis', -}); - -class SimpleTestDenyUpdateEntityPrivacyPolicy extends EntityPrivacyPolicy< - TestEntityFields, - string, - ViewerContext, - SimpleTestDenyUpdateEntity -> { - protected override readonly readRules = [ - new AlwaysAllowPrivacyPolicyRule< - TestEntityFields, - string, - ViewerContext, - SimpleTestDenyUpdateEntity - >(), - ]; - protected override readonly createRules = [ - new AlwaysAllowPrivacyPolicyRule< - TestEntityFields, - string, - ViewerContext, - SimpleTestDenyUpdateEntity - >(), - ]; - protected override readonly updateRules = [ - new AlwaysDenyPrivacyPolicyRule< - TestEntityFields, - string, - ViewerContext, - SimpleTestDenyUpdateEntity - >(), - ]; - protected override readonly deleteRules = [ - new AlwaysAllowPrivacyPolicyRule< - TestEntityFields, - string, - ViewerContext, - SimpleTestDenyUpdateEntity - >(), - ]; -} - -class SimpleTestDenyDeleteEntityPrivacyPolicy extends EntityPrivacyPolicy< - TestEntityFields, - string, - ViewerContext, - SimpleTestDenyDeleteEntity -> { - protected override readonly readRules = [ - new AlwaysAllowPrivacyPolicyRule< - TestEntityFields, - string, - ViewerContext, - SimpleTestDenyDeleteEntity - >(), - ]; - protected override readonly createRules = [ - new AlwaysAllowPrivacyPolicyRule< - TestEntityFields, - string, - ViewerContext, - SimpleTestDenyDeleteEntity - >(), - ]; - protected override readonly updateRules = [ - new AlwaysAllowPrivacyPolicyRule< - TestEntityFields, - string, - ViewerContext, - SimpleTestDenyDeleteEntity - >(), - ]; - protected override readonly deleteRules = [ - new AlwaysDenyPrivacyPolicyRule< - TestEntityFields, - string, - ViewerContext, - SimpleTestDenyDeleteEntity - >(), - ]; -} - -class SimpleTestDenyUpdateEntity extends Entity { - static defineCompanionDefinition(): EntityCompanionDefinition< - TestEntityFields, - string, - ViewerContext, - SimpleTestDenyUpdateEntity, - SimpleTestDenyUpdateEntityPrivacyPolicy - > { - return { - entityClass: SimpleTestDenyUpdateEntity, - entityConfiguration: testEntityConfiguration, - privacyPolicyClass: SimpleTestDenyUpdateEntityPrivacyPolicy, - }; - } -} - -class SimpleTestDenyDeleteEntity extends Entity { - static defineCompanionDefinition(): EntityCompanionDefinition< - TestEntityFields, - string, - ViewerContext, - SimpleTestDenyDeleteEntity, - SimpleTestDenyDeleteEntityPrivacyPolicy - > { - return { - entityClass: SimpleTestDenyDeleteEntity, - entityConfiguration: testEntityConfiguration, - privacyPolicyClass: SimpleTestDenyDeleteEntityPrivacyPolicy, - }; - } -} diff --git a/packages/entity/src/__tests__/EntityEdges-test.ts b/packages/entity/src/__tests__/EntityEdges-test.ts index b0678bf8..20c0cd04 100644 --- a/packages/entity/src/__tests__/EntityEdges-test.ts +++ b/packages/entity/src/__tests__/EntityEdges-test.ts @@ -18,12 +18,17 @@ import TestViewerContext from '../testfixtures/TestViewerContext'; import { InMemoryFullCacheStubCacheAdapter } from '../utils/testing/StubCacheAdapter'; import { createUnitTestEntityCompanionProvider } from '../utils/testing/createUnitTestEntityCompanionProvider'; +interface OtherFields { + id: string; +} + interface ParentFields { id: string; } interface ChildFields { id: string; + unused_other_edge_id: string | null; parent_id: string; } @@ -321,6 +326,22 @@ const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) => } } + class OtherEntity extends Entity { + static defineCompanionDefinition(): EntityCompanionDefinition< + OtherFields, + string, + TestViewerContext, + OtherEntity, + TestEntityPrivacyPolicy + > { + return { + entityClass: ParentEntity, + entityConfiguration: otherEntityConfiguration, + privacyPolicyClass: TestEntityPrivacyPolicy, + }; + } + } + class ParentEntity extends Entity { static defineCompanionDefinition(): EntityCompanionDefinition< ParentFields, @@ -390,6 +411,19 @@ const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) => } } + const otherEntityConfiguration = new EntityConfiguration({ + idField: 'id', + tableName: 'others', + schema: { + id: new UUIDField({ + columnName: 'id', + cache: true, + }), + }, + databaseAdapterFlavor: 'postgres', + cacheAdapterFlavor: 'redis', + }); + const parentEntityConfiguration = new EntityConfiguration({ idField: 'id', tableName: 'parents', @@ -413,11 +447,19 @@ const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) => columnName: 'id', cache: true, }), + unused_other_edge_id: new UUIDField({ + columnName: 'unused_other_edge_id', + association: { + associatedEntityClass: OtherEntity, + edgeDeletionBehavior, + }, + }), parent_id: new UUIDField({ columnName: 'parent_id', cache: true, association: { associatedEntityClass: ParentEntity, + associatedEntityLookupByField: 'id', // sanity check that this functionality works by using it for one edge edgeDeletionBehavior, }, }), diff --git a/packages/entity/src/index.ts b/packages/entity/src/index.ts index d46a411e..b75eb8f9 100644 --- a/packages/entity/src/index.ts +++ b/packages/entity/src/index.ts @@ -76,3 +76,4 @@ export { default as StubDatabaseAdapterProvider } from './utils/testing/StubData export { default as StubQueryContextProvider } from './utils/testing/StubQueryContextProvider'; export * from './utils/testing/createUnitTestEntityCompanionProvider'; export * from './utils/collections/maps'; +export * from './utils/EntityPrivacyUtils'; diff --git a/packages/entity/src/utils/EntityPrivacyUtils.ts b/packages/entity/src/utils/EntityPrivacyUtils.ts new file mode 100644 index 00000000..fd74e405 --- /dev/null +++ b/packages/entity/src/utils/EntityPrivacyUtils.ts @@ -0,0 +1,311 @@ +import { asyncResult } from '@expo/results'; + +import Entity, { IEntityClass } from '../Entity'; +import { EntityEdgeDeletionBehavior } from '../EntityFieldDefinition'; +import { EntityCascadingDeletionInfo } from '../EntityMutationInfo'; +import EntityPrivacyPolicy from '../EntityPrivacyPolicy'; +import { EntityQueryContext } from '../EntityQueryContext'; +import ViewerContext from '../ViewerContext'; +import { successfulResults } from '../entityUtils'; +import EntityNotAuthorizedError from '../errors/EntityNotAuthorizedError'; + +/** + * Check whether an entity loaded by a viewer can be updated by that same viewer. + * + * @remarks + * + * This may be useful in situations relying upon the thrown privacy policy thrown authorization error + * is insufficient for the task at hand. When dealing with purely a sequence of mutations it is easy + * to roll back all mutations given a single authorization error by wrapping them in a single transaction. + * When certain portions of a mutation cannot be rolled back transactionally (third pary calls, + * legacy code, etc), using this method can help decide whether the sequence of mutations will fail before + * attempting them. Note that if any privacy policy rules use a piece of data being updated in the mutations + * the result of this method and the update mutation itself may differ. + * + * @param entityClass - class of entity + * @param sourceEntity - entity loaded by viewer + * @param queryContext - query context in which to perform the check + */ +export async function canViewerUpdateAsync< + TMFields extends object, + TMID extends NonNullable, + TMViewerContext extends ViewerContext, + TMEntity extends Entity, + TMPrivacyPolicy extends EntityPrivacyPolicy< + TMFields, + TMID, + TMViewerContext, + TMEntity, + TMSelectedFields + >, + TMSelectedFields extends keyof TMFields = keyof TMFields +>( + entityClass: IEntityClass< + TMFields, + TMID, + TMViewerContext, + TMEntity, + TMPrivacyPolicy, + TMSelectedFields + >, + sourceEntity: TMEntity, + queryContext: EntityQueryContext = sourceEntity + .getViewerContext() + .getViewerScopedEntityCompanionForClass(entityClass) + .getQueryContextProvider() + .getQueryContext() +): Promise { + return await canViewerUpdateInternalAsync( + entityClass, + sourceEntity, + /* cascadingDeleteCause */ null, + queryContext + ); +} + +async function canViewerUpdateInternalAsync< + TMFields extends object, + TMID extends NonNullable, + TMViewerContext extends ViewerContext, + TMEntity extends Entity, + TMPrivacyPolicy extends EntityPrivacyPolicy< + TMFields, + TMID, + TMViewerContext, + TMEntity, + TMSelectedFields + >, + TMSelectedFields extends keyof TMFields = keyof TMFields +>( + entityClass: IEntityClass< + TMFields, + TMID, + TMViewerContext, + TMEntity, + TMPrivacyPolicy, + TMSelectedFields + >, + sourceEntity: TMEntity, + cascadingDeleteCause: EntityCascadingDeletionInfo | null, + queryContext: EntityQueryContext +): Promise { + const companion = sourceEntity + .getViewerContext() + .getViewerScopedEntityCompanionForClass(entityClass); + const privacyPolicy = companion.entityCompanion.privacyPolicy; + const evaluationResult = await asyncResult( + privacyPolicy.authorizeUpdateAsync( + sourceEntity.getViewerContext(), + queryContext, + { cascadingDeleteCause }, + sourceEntity, + companion.getMetricsAdapter() + ) + ); + return evaluationResult.ok; +} + +/** + * Check whether a single entity loaded by a viewer can be deleted by that same viewer. + * This recursively checks edge cascade permissions (EntityEdgeDeletionBehavior) as well. + * + * @remarks + * See remarks for canViewerUpdate. + * + * @param entityClass - class of entity + * @param sourceEntity - entity loaded by viewer + * @param queryContext - query context in which to perform the check + */ +export async function canViewerDeleteAsync< + TFields extends object, + TID extends NonNullable, + TMViewerContext extends ViewerContext, + TEntity extends Entity, + TPrivacyPolicy extends EntityPrivacyPolicy< + TFields, + TID, + TMViewerContext, + TEntity, + TSelectedFields + >, + TSelectedFields extends keyof TFields = keyof TFields +>( + entityClass: IEntityClass< + TFields, + TID, + TMViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + >, + sourceEntity: TEntity, + queryContext: EntityQueryContext = sourceEntity + .getViewerContext() + .getViewerScopedEntityCompanionForClass(entityClass) + .getQueryContextProvider() + .getQueryContext() +): Promise { + return await canViewerDeleteInternalAsync( + entityClass, + sourceEntity, + /* cascadingDeleteCause */ null, + queryContext + ); +} + +async function canViewerDeleteInternalAsync< + TFields extends object, + TID extends NonNullable, + TMViewerContext extends ViewerContext, + TEntity extends Entity, + TPrivacyPolicy extends EntityPrivacyPolicy< + TFields, + TID, + TMViewerContext, + TEntity, + TSelectedFields + >, + TSelectedFields extends keyof TFields = keyof TFields +>( + entityClass: IEntityClass< + TFields, + TID, + TMViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + >, + sourceEntity: TEntity, + cascadingDeleteCause: EntityCascadingDeletionInfo | null, + queryContext: EntityQueryContext +): Promise { + const viewerContext = sourceEntity.getViewerContext(); + const entityCompanionProvider = viewerContext.entityCompanionProvider; + + const viewerScopedCompanion = sourceEntity + .getViewerContext() + .getViewerScopedEntityCompanionForClass(entityClass); + { + const privacyPolicy = viewerScopedCompanion.entityCompanion.privacyPolicy; + const evaluationResult = await asyncResult( + privacyPolicy.authorizeDeleteAsync( + sourceEntity.getViewerContext(), + queryContext, + { cascadingDeleteCause }, + sourceEntity, + viewerScopedCompanion.getMetricsAdapter() + ) + ); + if (!evaluationResult.ok) { + if (evaluationResult.reason instanceof EntityNotAuthorizedError) { + return false; + } else { + throw evaluationResult.reason; + } + } + } + + const newCascadingDeleteCause = { + entity: sourceEntity, + cascadingDeleteCause, + }; + + // Take entity X which is proposed to be deleted, look at inbound edges (entities that reference X). + // These inbound edges are the entities that will either get deleted + // or updated with null based on the edge definiton when entity X is deleted. + // For each of these inboundEdge entities Y, look at the field(s) on Y that reference X. + // For each of the field(s) on Y that reference X, + // - if cascade set null, check if user can update Y + // - if cascade delete, run canViewerDeleteAsync on Y + // Return the conjunction (returning eagerly when false) of all checks recursively. + + const entityConfiguration = + viewerScopedCompanion.entityCompanion.entityCompanionDefinition.entityConfiguration; + const inboundEdges = entityConfiguration.inboundEdges; + + for (const inboundEdge of inboundEdges) { + const configurationForInboundEdge = + entityCompanionProvider.getCompanionForEntity(inboundEdge).entityCompanionDefinition + .entityConfiguration; + + const loader = viewerContext + .getViewerScopedEntityCompanionForClass(inboundEdge) + .getLoaderFactory() + .forLoad(queryContext, { cascadingDeleteCause: newCascadingDeleteCause }); + + for (const [fieldName, fieldDefinition] of configurationForInboundEdge.schema) { + const association = fieldDefinition.association; + if (!association) { + continue; + } + + const associatedConfiguration = entityCompanionProvider.getCompanionForEntity( + association.associatedEntityClass + ).entityCompanionDefinition.entityConfiguration; + if (associatedConfiguration !== entityConfiguration) { + continue; + } + + const entityResultsForInboundEdge = await loader.loadManyByFieldEqualingAsync( + fieldName, + association.associatedEntityLookupByField + ? sourceEntity.getField(association.associatedEntityLookupByField as any) + : sourceEntity.getID() + ); + + const entitiesForInboundEdge = successfulResults(entityResultsForInboundEdge).map( + (r) => r.value + ); + if (entitiesForInboundEdge.length !== entityResultsForInboundEdge.length) { + // can't read some of the edges, therefore can't delete + return false; + } + + switch (association.edgeDeletionBehavior) { + case EntityEdgeDeletionBehavior.CASCADE_DELETE: + case EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE_ONLY: { + const canDeleteAll = ( + await Promise.all( + entitiesForInboundEdge.map((entity) => + canViewerDeleteInternalAsync( + inboundEdge, + entity, + newCascadingDeleteCause, + queryContext + ) + ) + ) + ).every((b) => b); + + if (!canDeleteAll) { + return false; + } + break; + } + + case EntityEdgeDeletionBehavior.SET_NULL: + case EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY: { + const canUpdateAll = ( + await Promise.all( + entitiesForInboundEdge.map((entity) => + canViewerUpdateInternalAsync( + inboundEdge, + entity, + newCascadingDeleteCause, + queryContext + ) + ) + ) + ).every((b) => b); + + if (!canUpdateAll) { + return false; + } + break; + } + } + } + } + + return true; +} diff --git a/packages/entity/src/utils/__tests__/EntityPrivacyUtils-test.ts b/packages/entity/src/utils/__tests__/EntityPrivacyUtils-test.ts new file mode 100644 index 00000000..f6bad01b --- /dev/null +++ b/packages/entity/src/utils/__tests__/EntityPrivacyUtils-test.ts @@ -0,0 +1,493 @@ +import Entity from '../../Entity'; +import { EntityCompanionDefinition } from '../../EntityCompanionProvider'; +import EntityConfiguration from '../../EntityConfiguration'; +import { EntityEdgeDeletionBehavior } from '../../EntityFieldDefinition'; +import { UUIDField } from '../../EntityFields'; +import EntityPrivacyPolicy, { + EntityPrivacyPolicyEvaluationContext, +} from '../../EntityPrivacyPolicy'; +import { EntityQueryContext } from '../../EntityQueryContext'; +import ReadonlyEntity from '../../ReadonlyEntity'; +import ViewerContext from '../../ViewerContext'; +import AlwaysAllowPrivacyPolicyRule from '../../rules/AlwaysAllowPrivacyPolicyRule'; +import AlwaysDenyPrivacyPolicyRule from '../../rules/AlwaysDenyPrivacyPolicyRule'; +import { RuleEvaluationResult } from '../../rules/PrivacyPolicyRule'; +import { canViewerDeleteAsync, canViewerUpdateAsync } from '../EntityPrivacyUtils'; +import { createUnitTestEntityCompanionProvider } from '../testing/createUnitTestEntityCompanionProvider'; + +describe(canViewerUpdateAsync, () => { + it('appropriately executes update privacy policy', async () => { + const companionProvider = createUnitTestEntityCompanionProvider(); + const viewerContext = new ViewerContext(companionProvider); + const testEntity = await SimpleTestDenyDeleteEntity.creator(viewerContext).enforceCreateAsync(); + const canViewerUpdate = await canViewerUpdateAsync(SimpleTestDenyDeleteEntity, testEntity); + expect(canViewerUpdate).toBe(true); + }); + + it('denies when policy denies', async () => { + const companionProvider = createUnitTestEntityCompanionProvider(); + const viewerContext = new ViewerContext(companionProvider); + const testEntity = await SimpleTestDenyUpdateEntity.creator(viewerContext).enforceCreateAsync(); + const canViewerUpdate = await canViewerUpdateAsync(SimpleTestDenyUpdateEntity, testEntity); + expect(canViewerUpdate).toBe(false); + }); +}); + +describe(canViewerDeleteAsync, () => { + it('appropriately executes update privacy policy', async () => { + const companionProvider = createUnitTestEntityCompanionProvider(); + const viewerContext = new ViewerContext(companionProvider); + const testEntity = await SimpleTestDenyUpdateEntity.creator(viewerContext).enforceCreateAsync(); + const canViewerDelete = await canViewerDeleteAsync(SimpleTestDenyUpdateEntity, testEntity); + expect(canViewerDelete).toBe(true); + }); + + it('denies when policy denies', async () => { + const companionProvider = createUnitTestEntityCompanionProvider(); + const viewerContext = new ViewerContext(companionProvider); + const testEntity = await SimpleTestDenyDeleteEntity.creator(viewerContext).enforceCreateAsync(); + const canViewerDelete = await canViewerDeleteAsync(SimpleTestDenyDeleteEntity, testEntity); + expect(canViewerDelete).toBe(false); + }); + + it('denies when recursive policy denies for CASCADE_DELETE', async () => { + const companionProvider = createUnitTestEntityCompanionProvider(); + const viewerContext = new ViewerContext(companionProvider); + const testEntity = await SimpleTestDenyUpdateEntity.creator(viewerContext).enforceCreateAsync(); + // add another entity referencing testEntity that would cascade deletion to itself when testEntity is deleted + await LeafDenyDeleteEntity.creator(viewerContext) + .setField('simple_test_deny_update_cascade_delete_id', testEntity.getID()) + .enforceCreateAsync(); + const canViewerDelete = await canViewerDeleteAsync(SimpleTestDenyUpdateEntity, testEntity); + expect(canViewerDelete).toBe(false); + }); + + it('denies when recursive policy denies for SET_NULL', async () => { + const companionProvider = createUnitTestEntityCompanionProvider(); + const viewerContext = new ViewerContext(companionProvider); + const testEntity = await SimpleTestDenyUpdateEntity.creator(viewerContext).enforceCreateAsync(); + // add another entity referencing testEntity that would set null to its column when testEntity is deleted + await LeafDenyUpdateEntity.creator(viewerContext) + .setField('simple_test_deny_update_set_null_id', testEntity.getID()) + .enforceCreateAsync(); + const canViewerDelete = await canViewerDeleteAsync(SimpleTestDenyUpdateEntity, testEntity); + expect(canViewerDelete).toBe(false); + }); + + it('allows when recursive policy allows for CASCADE_DELETE and SET_NULL', async () => { + const companionProvider = createUnitTestEntityCompanionProvider(); + const viewerContext = new ViewerContext(companionProvider); + const testEntity = await SimpleTestDenyUpdateEntity.creator(viewerContext).enforceCreateAsync(); + // add another entity referencing testEntity that would cascade deletion to itself when testEntity is deleted + await LeafDenyUpdateEntity.creator(viewerContext) + .setField('simple_test_deny_update_cascade_delete_id', testEntity.getID()) + .enforceCreateAsync(); + // add another entity referencing testEntity that would set null to its column when testEntity is deleted + await LeafDenyDeleteEntity.creator(viewerContext) + .setField('simple_test_deny_update_set_null_id', testEntity.getID()) + .enforceCreateAsync(); + + const canViewerDelete = await canViewerDeleteAsync(SimpleTestDenyUpdateEntity, testEntity); + expect(canViewerDelete).toBe(true); + }); + + it('rethrows non-authorization errors', async () => { + const companionProvider = createUnitTestEntityCompanionProvider(); + const viewerContext = new ViewerContext(companionProvider); + const testEntity = await SimpleTestThrowOtherErrorEntity.creator( + viewerContext + ).enforceCreateAsync(); + await expect( + canViewerDeleteAsync(SimpleTestThrowOtherErrorEntity, testEntity) + ).rejects.toThrowError('other error'); + }); + + it('returns false when edge cannot be read', async () => { + const companionProvider = createUnitTestEntityCompanionProvider(); + const viewerContext = new ViewerContext(companionProvider); + const testEntity = await SimpleTestDenyUpdateEntity.creator(viewerContext).enforceCreateAsync(); + await LeafDenyReadEntity.creator(viewerContext) + .setField('simple_test_id', testEntity.getID()) + .enforceCreateAsync(); + const canViewerDelete = await canViewerDeleteAsync(SimpleTestDenyUpdateEntity, testEntity); + expect(canViewerDelete).toBe(false); + }); + + it('supports running within a transaction', async () => { + const companionProvider = createUnitTestEntityCompanionProvider(); + const viewerContext = new ViewerContext(companionProvider); + const canViewerDelete = await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync( + 'postgres', + async (queryContext) => { + const testEntity = await SimpleTestDenyUpdateEntity.creator( + viewerContext, + queryContext + ).enforceCreateAsync(); + await LeafDenyReadEntity.creator(viewerContext, queryContext) + .setField('simple_test_id', testEntity.getID()) + .enforceCreateAsync(); + // this would fail if transactions weren't supported or correctly passed through + return await canViewerDeleteAsync(SimpleTestDenyUpdateEntity, testEntity, queryContext); + } + ); + + expect(canViewerDelete).toBe(true); + }); +}); + +type TestEntityFields = { + id: string; +}; + +type TestLeafDenyDeleteFields = { + id: string; + simple_test_deny_update_cascade_delete_id: string | null; + simple_test_deny_update_set_null_id: string | null; +}; + +type TestLeafDenyUpdateFields = { + id: string; + unused_other_association: string | null; + simple_test_deny_update_set_null_id: string | null; + simple_test_deny_update_cascade_delete_id: string | null; +}; + +type TestLeafDenyReadFields = { + id: string; + simple_test_id: string | null; +}; + +class DenyUpdateEntityPrivacyPolicy< + 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 DenyDeleteEntityPrivacyPolicy< + 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 AlwaysAllowPrivacyPolicyRule(), + ]; + protected override readonly deleteRules = [ + new AlwaysDenyPrivacyPolicyRule(), + ]; +} + +class ThrowOtherErrorDeleteEntityPrivacyPolicy< + 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 AlwaysAllowPrivacyPolicyRule(), + ]; + protected override readonly deleteRules = [ + { + async evaluateAsync(): Promise { + throw new Error('other error'); + }, + }, + ]; +} + +class DenyReadEntityPrivacyPolicy< + TFields extends object, + TID extends NonNullable, + TViewerContext extends ViewerContext, + TEntity extends ReadonlyEntity, + TSelectedFields extends keyof TFields = keyof TFields +> extends EntityPrivacyPolicy { + protected override readonly readRules = [ + { + async evaluateAsync( + _viewerContext: TViewerContext, + queryContext: EntityQueryContext, + evaluationContext: EntityPrivacyPolicyEvaluationContext, + _entity: TEntity + ): Promise { + if (queryContext.isInTransaction()) { + return RuleEvaluationResult.ALLOW; + } + return evaluationContext.cascadingDeleteCause + ? RuleEvaluationResult.SKIP + : RuleEvaluationResult.ALLOW; + }, + }, + ]; + protected override readonly createRules = [ + new AlwaysAllowPrivacyPolicyRule(), + ]; + protected override readonly updateRules = [ + new AlwaysAllowPrivacyPolicyRule(), + ]; + protected override readonly deleteRules = [ + new AlwaysAllowPrivacyPolicyRule(), + ]; +} + +class LeafDenyUpdateEntity extends Entity { + static defineCompanionDefinition(): EntityCompanionDefinition< + TestLeafDenyUpdateFields, + string, + ViewerContext, + LeafDenyUpdateEntity, + DenyUpdateEntityPrivacyPolicy< + TestLeafDenyUpdateFields, + string, + ViewerContext, + LeafDenyUpdateEntity + > + > { + return { + entityClass: LeafDenyUpdateEntity, + entityConfiguration: new EntityConfiguration({ + idField: 'id', + tableName: 'leaf_1', + schema: { + id: new UUIDField({ + columnName: 'custom_id', + }), + // to ensure edge traversal doesn't process other edges + unused_other_association: new UUIDField({ + columnName: 'unused_other_association', + association: { + associatedEntityClass: LeafDenyDeleteEntity, + edgeDeletionBehavior: EntityEdgeDeletionBehavior.SET_NULL, + }, + }), + // deletion behavior should fail since this entity can't be updated and a SET NULL does an update + simple_test_deny_update_set_null_id: new UUIDField({ + columnName: 'simple_test_deny_update_set_null_id', + association: { + associatedEntityClass: SimpleTestDenyUpdateEntity, + edgeDeletionBehavior: EntityEdgeDeletionBehavior.SET_NULL, + }, + }), + // deletion behavior should succeed since this entity can be deleted + simple_test_deny_update_cascade_delete_id: new UUIDField({ + columnName: 'simple_test_deny_update_cascade_delete_id', + association: { + associatedEntityClass: SimpleTestDenyUpdateEntity, + edgeDeletionBehavior: EntityEdgeDeletionBehavior.CASCADE_DELETE, + }, + }), + }, + databaseAdapterFlavor: 'postgres', + cacheAdapterFlavor: 'redis', + }), + privacyPolicyClass: DenyUpdateEntityPrivacyPolicy, + }; + } +} + +class LeafDenyDeleteEntity extends Entity { + static defineCompanionDefinition(): EntityCompanionDefinition< + TestLeafDenyDeleteFields, + string, + ViewerContext, + LeafDenyDeleteEntity, + DenyDeleteEntityPrivacyPolicy< + TestLeafDenyDeleteFields, + string, + ViewerContext, + LeafDenyDeleteEntity + > + > { + return { + entityClass: LeafDenyDeleteEntity, + entityConfiguration: new EntityConfiguration({ + idField: 'id', + tableName: 'leaf_2', + schema: { + id: new UUIDField({ + columnName: 'custom_id', + }), + // deletion behavior should fail since this entity can't be deleted + simple_test_deny_update_cascade_delete_id: new UUIDField({ + columnName: 'simple_test_deny_update_cascade_delete_id', + association: { + associatedEntityClass: SimpleTestDenyUpdateEntity, + edgeDeletionBehavior: EntityEdgeDeletionBehavior.CASCADE_DELETE, + }, + }), + // deletion behavior should succeed since this entity can be updated and a SET NULL does an update + simple_test_deny_update_set_null_id: new UUIDField({ + columnName: 'simple_test_deny_update_set_null_id', + association: { + associatedEntityClass: SimpleTestDenyUpdateEntity, + edgeDeletionBehavior: EntityEdgeDeletionBehavior.SET_NULL, + }, + }), + }, + databaseAdapterFlavor: 'postgres', + cacheAdapterFlavor: 'redis', + }), + privacyPolicyClass: DenyDeleteEntityPrivacyPolicy, + }; + } +} + +class LeafDenyReadEntity extends Entity { + static defineCompanionDefinition(): EntityCompanionDefinition< + TestLeafDenyReadFields, + string, + ViewerContext, + LeafDenyReadEntity, + DenyReadEntityPrivacyPolicy + > { + return { + entityClass: LeafDenyReadEntity, + entityConfiguration: new EntityConfiguration({ + idField: 'id', + tableName: 'leaf_4', + inboundEdges: [], + schema: { + id: new UUIDField({ + columnName: 'custom_id', + }), + simple_test_id: new UUIDField({ + columnName: 'simple_test_id', + association: { + associatedEntityClass: SimpleTestDenyUpdateEntity, + associatedEntityLookupByField: 'id', + edgeDeletionBehavior: EntityEdgeDeletionBehavior.SET_NULL, + }, + }), + }, + databaseAdapterFlavor: 'postgres', + cacheAdapterFlavor: 'redis', + }), + privacyPolicyClass: DenyReadEntityPrivacyPolicy, + }; + } +} + +class SimpleTestDenyUpdateEntity extends Entity { + static defineCompanionDefinition(): EntityCompanionDefinition< + TestEntityFields, + string, + ViewerContext, + SimpleTestDenyUpdateEntity, + DenyUpdateEntityPrivacyPolicy< + TestEntityFields, + string, + ViewerContext, + SimpleTestDenyUpdateEntity + > + > { + return { + entityClass: SimpleTestDenyUpdateEntity, + entityConfiguration: new EntityConfiguration({ + idField: 'id', + tableName: 'blah', + inboundEdges: [LeafDenyUpdateEntity, LeafDenyDeleteEntity, LeafDenyReadEntity], + schema: { + id: new UUIDField({ + columnName: 'custom_id', + }), + }, + databaseAdapterFlavor: 'postgres', + cacheAdapterFlavor: 'redis', + }), + privacyPolicyClass: DenyUpdateEntityPrivacyPolicy, + }; + } +} + +class SimpleTestDenyDeleteEntity extends Entity { + static defineCompanionDefinition(): EntityCompanionDefinition< + TestEntityFields, + string, + ViewerContext, + SimpleTestDenyDeleteEntity, + DenyDeleteEntityPrivacyPolicy< + TestEntityFields, + string, + ViewerContext, + SimpleTestDenyDeleteEntity + > + > { + return { + entityClass: SimpleTestDenyDeleteEntity, + entityConfiguration: new EntityConfiguration({ + idField: 'id', + tableName: 'blah_2', + inboundEdges: [LeafDenyUpdateEntity, LeafDenyDeleteEntity, LeafDenyReadEntity], + schema: { + id: new UUIDField({ + columnName: 'custom_id', + }), + }, + databaseAdapterFlavor: 'postgres', + cacheAdapterFlavor: 'redis', + }), + privacyPolicyClass: DenyDeleteEntityPrivacyPolicy, + }; + } +} + +class SimpleTestThrowOtherErrorEntity extends Entity { + static defineCompanionDefinition(): EntityCompanionDefinition< + TestEntityFields, + string, + ViewerContext, + SimpleTestThrowOtherErrorEntity, + ThrowOtherErrorDeleteEntityPrivacyPolicy< + TestEntityFields, + string, + ViewerContext, + SimpleTestThrowOtherErrorEntity + > + > { + return { + entityClass: SimpleTestThrowOtherErrorEntity, + entityConfiguration: new EntityConfiguration({ + idField: 'id', + tableName: 'blah_3', + inboundEdges: [], + schema: { + id: new UUIDField({ + columnName: 'custom_id', + }), + }, + databaseAdapterFlavor: 'postgres', + cacheAdapterFlavor: 'redis', + }), + privacyPolicyClass: ThrowOtherErrorDeleteEntityPrivacyPolicy, + }; + } +}