Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add EntityEdgeDeletionAuthorizationInferenceBehavior for canViewerDeleteAsync #243

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
wschurman marked this conversation as resolved.
Show resolved Hide resolved
}

/**
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,
};
}
}
Loading