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 0492011 commit 31cef74
Show file tree
Hide file tree
Showing 3 changed files with 259 additions and 13 deletions.
17 changes: 16 additions & 1 deletion packages/entity/src/EntityFieldDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ export enum EntityEdgeDeletionBehavior {
SET_NULL,
}

export enum EntityEdgeDeletionPermissionInferenceBehavior {
ALLOW_SINGLE_ENTITY_INDUCTION,
}

/**
* Defines an association between entities. An association is primarily used to define cascading deletion behavior.
*/
Expand Down Expand Up @@ -77,7 +81,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 +97,17 @@ export interface EntityAssociationDefinition<
* integrity is recommended.
*/
edgeDeletionBehavior: EntityEdgeDeletionBehavior;

/**
* Optional optimization for canViewerDeleteAsync to indicate whether privacy checks for a single entity of many at the
* other end of this edge is representative of all of the edges. Put more simply, whether having permission to delete the
* entity at the source end of this edge implies permission to update/delete all entities at the ends of this
* edge.
*
* Used for canViewerDeleteAsync to optimize permission checks for edge cascading deletions (and set nulls).
* Not yet used for actual deletions as a safety precaution.
*/
edgeDeletionPermissionInferenceBehavior?: EntityEdgeDeletionPermissionInferenceBehavior;
}

/**
Expand Down
52 changes: 40 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,
EntityEdgeDeletionPermissionInferenceBehavior,
} from '../EntityFieldDefinition';
import { EntityCascadingDeletionInfo } from '../EntityMutationInfo';
import EntityPrivacyPolicy from '../EntityPrivacyPolicy';
import { EntityQueryContext } from '../EntityQueryContext';
Expand Down Expand Up @@ -254,16 +257,39 @@ async function canViewerDeleteInternalAsync<
continue;
}

const entityResultsForInboundEdge = await loader
.withAuthorizationResults()
.loadManyByFieldEqualingAsync(
fieldName,
association.associatedEntityLookupByField
? sourceEntity.getField(association.associatedEntityLookupByField as any)
: sourceEntity.getID(),
);
const edgeDeletionPermissionInferenceBehavior =
association.edgeDeletionPermissionInferenceBehavior;

const failedEntityLoadResults = failedResults(entityResultsForInboundEdge);
let entityResultsToCheckForInboundEdge: readonly Result<any>[];

if (
edgeDeletionPermissionInferenceBehavior ===
EntityEdgeDeletionPermissionInferenceBehavior.ALLOW_SINGLE_ENTITY_INDUCTION
) {
const singleEntityToTestForInboundEdge = await loader
.withAuthorizationResults()
.loadFirstByFieldEqualingAsync(
fieldName,
association.associatedEntityLookupByField
? sourceEntity.getField(association.associatedEntityLookupByField as any)

Check warning on line 274 in packages/entity/src/utils/EntityPrivacyUtils.ts

View check run for this annotation

Codecov / codecov/patch

packages/entity/src/utils/EntityPrivacyUtils.ts#L274

Added line #L274 was not covered by tests
: sourceEntity.getID(),
);
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 +299,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,203 @@
import Entity from '../../Entity';
import { EntityCompanionDefinition } from '../../EntityCompanionProvider';
import EntityConfiguration from '../../EntityConfiguration';
import {
EntityEdgeDeletionBehavior,
EntityEdgeDeletionPermissionInferenceBehavior,
} 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.ALLOW_SINGLE_ENTITY_INDUCTION', 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.ALLOW_SINGLE_ENTITY_INDUCTION
for (let i = 0; i < 10; i++) {
await TestLeafEntity.creator(viewerContext)
.setField('test_entity_id', testEntity.getID())
.enforceCreateAsync();
}

const companion = viewerContext.getViewerScopedEntityCompanionForClass(TestLeafEntity);
const authorizeDeleteSpy = jest.spyOn(
companion.entityCompanion.privacyPolicy,
'authorizeDeleteAsync',
);

const canViewerDelete = await canViewerDeleteAsync(TestEntity, testEntity);
expect(canViewerDelete).toBe(true);

expect(authorizeDeleteSpy).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 TestLeafNoInductionEntity.creator(viewerContext)
.setField('test_entity_id', testEntity.getID())
.enforceCreateAsync();
}

const companion =
viewerContext.getViewerScopedEntityCompanionForClass(TestLeafNoInductionEntity);
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, TestLeafNoInductionEntity],
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,
edgeDeletionPermissionInferenceBehavior:
EntityEdgeDeletionPermissionInferenceBehavior.ALLOW_SINGLE_ENTITY_INDUCTION,
},
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
}),
privacyPolicyClass: AlwaysAllowEntityPrivacyPolicy,
};
}
}

class TestLeafNoInductionEntity extends Entity<TestLeafEntityFields, string, ViewerContext> {
static defineCompanionDefinition(): EntityCompanionDefinition<
TestLeafEntityFields,
string,
ViewerContext,
TestLeafNoInductionEntity,
AlwaysAllowEntityPrivacyPolicy<
TestLeafEntityFields,
string,
ViewerContext,
TestLeafNoInductionEntity
>
> {
return {
entityClass: TestLeafNoInductionEntity,
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 31cef74

Please sign in to comment.