Skip to content

Commit

Permalink
feat: add EntityEdgeDeletionPermissionInferenceBehavior for canViewer…
Browse files Browse the repository at this point in the history
…DeleteAsync
  • Loading branch information
wschurman committed Jun 25, 2024
1 parent 15117fc commit 265b641
Show file tree
Hide file tree
Showing 3 changed files with 337 additions and 13 deletions.
39 changes: 38 additions & 1 deletion packages/entity/src/EntityFieldDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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
Expand All @@ -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;
}

/**
Expand Down
57 changes: 45 additions & 12 deletions packages/entity/src/utils/EntityPrivacyUtils.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<any>[];

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;
Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields = keyof TFields,
> extends EntityPrivacyPolicy<TFields, TID, TViewerContext, TEntity, TSelectedFields> {
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<TFields, TID, TViewerContext, TEntity, TSelectedFields>(),
];
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<TFields, TID, TViewerContext, TEntity, TSelectedFields>(),
];
protected override readonly updateRules = [
new AlwaysDenyPrivacyPolicyRule<TFields, TID, TViewerContext, TEntity, TSelectedFields>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<TFields, TID, TViewerContext, TEntity, TSelectedFields>(),
];
}

class TestEntity extends Entity<TestEntityFields, string, ViewerContext> {
static defineCompanionDefinition(): EntityCompanionDefinition<
TestEntityFields,
string,
ViewerContext,
TestEntity,
AlwaysAllowEntityPrivacyPolicy<TestEntityFields, string, ViewerContext, TestEntity>
> {
return {
entityClass: TestEntity,
entityConfiguration: new EntityConfiguration<TestEntityFields>({
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<TestLeafEntityFields, string, ViewerContext> {
static defineCompanionDefinition(): EntityCompanionDefinition<
TestLeafEntityFields,
string,
ViewerContext,
TestLeafEntity,
AlwaysAllowEntityPrivacyPolicy<TestLeafEntityFields, string, ViewerContext, TestLeafEntity>
> {
return {
entityClass: TestLeafEntity,
entityConfiguration: new EntityConfiguration<TestLeafEntityFields>({
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<TestLeafEntityFields, string, ViewerContext> {
static defineCompanionDefinition(): EntityCompanionDefinition<
TestLeafEntityFields,
string,
ViewerContext,
TestLeafEntity,
AlwaysAllowEntityPrivacyPolicy<TestLeafEntityFields, string, ViewerContext, TestLeafEntity>
> {
return {
entityClass: TestLeafEntity,
entityConfiguration: new EntityConfiguration<TestLeafEntityFields>({
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<TestLeafEntityFields, string, ViewerContext> {
static defineCompanionDefinition(): EntityCompanionDefinition<
TestLeafEntityFields,
string,
ViewerContext,
TestLeafNoInferenceEntity,
AlwaysAllowEntityPrivacyPolicy<
TestLeafEntityFields,
string,
ViewerContext,
TestLeafNoInferenceEntity
>
> {
return {
entityClass: TestLeafNoInferenceEntity,
entityConfiguration: new EntityConfiguration<TestLeafEntityFields>({
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,
};
}
}

0 comments on commit 265b641

Please sign in to comment.