Compare commits

...
Author SHA1 Message Date
Will Schurman 58797a50b6 Change boolean decider to be async 2024-05-07 09:25:04 -07:00
Will Schurman d09a735264 Address comment 2024-05-07 09:20:21 -07:00
Will Schurman dc3aafc6ab feat: add privacy policy rule reordering 2024-05-06 13:02:27 -07:00
16 changed files with 714 additions and 48 deletions

No files matched your search

@@ -1,9 +1,10 @@
import {
PrivacyPolicyRule,
ReadonlyEntity,
EntityQueryContext,
RuleEvaluationResult,
EntityPrivacyPolicyEvaluationContext,
RuleComplexity,
AllowOrSkipPrivacyPolicyRule,
} from '@expo/entity';
import { ExampleViewerContext } from '../viewerContexts';
@@ -27,17 +28,19 @@ export default class AllowIfUserOwnerPrivacyRule<
TID extends NonNullable<TFields[TSelectedFields]>,
TEntity extends ReadonlyEntity<TFields, TID, ExampleViewerContext>,
TSelectedFields extends keyof TFields = keyof TFields
> extends PrivacyPolicyRule<TFields, TID, ExampleViewerContext, TEntity> {
> extends AllowOrSkipPrivacyPolicyRule<TFields, TID, ExampleViewerContext, TEntity> {
constructor(private readonly entityOwnerField: keyof TFields) {
super();
}
override complexity = RuleComplexity.LOW;
async evaluateAsync(
viewerContext: ExampleViewerContext,
_queryContext: EntityQueryContext,
_evaluationContext: EntityPrivacyPolicyEvaluationContext,
entity: TEntity
): Promise<RuleEvaluationResult> {
): Promise<RuleEvaluationResult.ALLOW | RuleEvaluationResult.SKIP> {
if (viewerContext.isUserViewerContext()) {
if (String(entity.getField(this.entityOwnerField)) === viewerContext.userID) {
return RuleEvaluationResult.ALLOW;
+28 -2
View File
@@ -6,7 +6,9 @@ import EntityNotAuthorizedError from './errors/EntityNotAuthorizedError';
import IEntityMetricsAdapter, {
EntityMetricsAuthorizationResult,
} from './metrics/IEntityMetricsAdapter';
import PrivacyPolicyRule, { RuleEvaluationResult } from './rules/PrivacyPolicyRule';
import { RuleEvaluationResult } from './rules/PrivacyPolicyRuleEnums';
import { PrivacyPolicyRule } from './rules/PrivacyPolicyRuleTypes';
import { reorderRulesByRuleComplexityGroups } from './rules/internal/PrivacyPolicyRuleOrdering';
/**
* Information about the reason this privacy policy is being evaluated.
@@ -143,6 +145,15 @@ export default abstract class EntityPrivacyPolicy<
};
}
protected async shouldAutoReorderRulesAccordingToComplexityAsync(
_viewerContext: TViewerContext,
_queryContext: EntityQueryContext,
_evaluationContext: EntityPrivacyPolicyEvaluationContext,
_action: EntityAuthorizationAction
): Promise<boolean> {
return false;
}
/**
* Authorize an entity against creation policy.
* @param viewerContext - viewer context of user creating the entity
@@ -248,7 +259,13 @@ export default abstract class EntityPrivacyPolicy<
}
private async authorizeForRulesetAsync(
ruleset: readonly PrivacyPolicyRule<TFields, TID, TViewerContext, TEntity, TSelectedFields>[],
rulesetOriginalOrder: readonly PrivacyPolicyRule<
TFields,
TID,
TViewerContext,
TEntity,
TSelectedFields
>[],
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext,
@@ -257,6 +274,15 @@ export default abstract class EntityPrivacyPolicy<
metricsAdapter: IEntityMetricsAdapter
): Promise<TEntity> {
const privacyPolicyEvaluator = this.getPrivacyPolicyEvaluator(viewerContext);
const shouldAutoReorder = await this.shouldAutoReorderRulesAccordingToComplexityAsync(
viewerContext,
queryContext,
evaluationContext,
action
);
const ruleset = shouldAutoReorder
? reorderRulesByRuleComplexityGroups(rulesetOriginalOrder)
: rulesetOriginalOrder;
switch (privacyPolicyEvaluator.mode) {
case EntityPrivacyPolicyEvaluationMode.ENFORCE:
try {
@@ -12,7 +12,8 @@ import { enforceResultsAsync } from '../entityUtils';
import EntityNotAuthorizedError from '../errors/EntityNotAuthorizedError';
import AlwaysAllowPrivacyPolicyRule from '../rules/AlwaysAllowPrivacyPolicyRule';
import AlwaysDenyPrivacyPolicyRule from '../rules/AlwaysDenyPrivacyPolicyRule';
import PrivacyPolicyRule, { RuleEvaluationResult } from '../rules/PrivacyPolicyRule';
import { RuleComplexity, RuleEvaluationResult } from '../rules/PrivacyPolicyRuleEnums';
import { DenyOrSkipPrivacyPolicyRule } from '../rules/PrivacyPolicyRuleTypes';
import { createUnitTestEntityCompanionProvider } from '../utils/testing/createUnitTestEntityCompanionProvider';
class TestUserViewerContext extends ViewerContext {
@@ -60,18 +61,20 @@ class BlahEntity extends Entity<BlahFields, string, TestUserViewerContext> {
}
}
class DenyIfNotOwnerPrivacyPolicyRule extends PrivacyPolicyRule<
class DenyIfNotOwnerPrivacyPolicyRule extends DenyOrSkipPrivacyPolicyRule<
BlahFields,
string,
TestUserViewerContext,
BlahEntity
> {
override complexity = RuleComplexity.LOW;
async evaluateAsync(
viewerContext: TestUserViewerContext,
_queryContext: EntityQueryContext,
_evaluationContext: EntityPrivacyPolicyEvaluationContext,
entity: BlahEntity
): Promise<RuleEvaluationResult> {
): Promise<RuleEvaluationResult.DENY | RuleEvaluationResult.SKIP> {
if (viewerContext.getUserID() === entity.getField('ownerID')) {
return RuleEvaluationResult.SKIP;
}
@@ -13,7 +13,8 @@ import EntityPrivacyPolicy, {
} from '../EntityPrivacyPolicy';
import { EntityTransactionalQueryContext, EntityQueryContext } from '../EntityQueryContext';
import { CacheStatus } from '../internal/ReadThroughEntityCache';
import PrivacyPolicyRule, { RuleEvaluationResult } from '../rules/PrivacyPolicyRule';
import { RuleComplexity, RuleEvaluationResult } from '../rules/PrivacyPolicyRuleEnums';
import { AllowOrSkipPrivacyPolicyRule } from '../rules/PrivacyPolicyRuleTypes';
import TestViewerContext from '../testfixtures/TestViewerContext';
import { InMemoryFullCacheStubCacheAdapter } from '../utils/testing/StubCacheAdapter';
import { createUnitTestEntityCompanionProvider } from '../utils/testing/createUnitTestEntityCompanionProvider';
@@ -66,13 +67,15 @@ const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) =>
},
};
class AlwaysAllowPrivacyPolicyRuleThatRecords extends PrivacyPolicyRule<
class AlwaysAllowPrivacyPolicyRuleThatRecords extends AllowOrSkipPrivacyPolicyRule<
any,
string,
TestViewerContext,
any,
any
> {
override complexity = RuleComplexity.LOW;
constructor(private readonly action: EntityAuthorizationAction) {
super();
}
@@ -82,7 +85,7 @@ const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) =>
_queryContext: EntityQueryContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext,
entity: any
): Promise<RuleEvaluationResult> {
): Promise<RuleEvaluationResult.ALLOW> {
if (privacyPolicyEvaluationRecords.shouldRecord) {
(privacyPolicyEvaluationRecords as any)[entity.constructor.name][this.action].push(
evaluationContext
@@ -19,7 +19,8 @@ import IEntityMetricsAdapter, {
import AlwaysAllowPrivacyPolicyRule from '../rules/AlwaysAllowPrivacyPolicyRule';
import AlwaysDenyPrivacyPolicyRule from '../rules/AlwaysDenyPrivacyPolicyRule';
import AlwaysSkipPrivacyPolicyRule from '../rules/AlwaysSkipPrivacyPolicyRule';
import PrivacyPolicyRule, { RuleEvaluationResult } from '../rules/PrivacyPolicyRule';
import { RuleComplexity, RuleEvaluationResult } from '../rules/PrivacyPolicyRuleEnums';
import { AllowOrSkipPrivacyPolicyRule } from '../rules/PrivacyPolicyRuleTypes';
type BlahFields = {
id: string;
@@ -172,18 +173,20 @@ class SkipAllPolicy extends EntityPrivacyPolicy<BlahFields, string, ViewerContex
];
}
class AlwaysThrowPrivacyPolicyRule extends PrivacyPolicyRule<
class AlwaysThrowPrivacyPolicyRule extends AllowOrSkipPrivacyPolicyRule<
BlahFields,
string,
ViewerContext,
BlahEntity
> {
override complexity = RuleComplexity.LOW;
evaluateAsync(
_viewerContext: ViewerContext,
_queryContext: EntityQueryContext,
_evaluationContext: EntityPrivacyPolicyEvaluationContext,
_entity: BlahEntity
): Promise<RuleEvaluationResult> {
): Promise<RuleEvaluationResult.ALLOW> {
throw new Error('WooHoo!');
}
}
@@ -0,0 +1,271 @@
import { mock, instance } from 'ts-mockito';
import Entity from '../Entity';
import { EntityCompanionDefinition } from '../EntityCompanionProvider';
import EntityConfiguration from '../EntityConfiguration';
import { UUIDField } from '../EntityFields';
import EntityPrivacyPolicy, {
EntityAuthorizationAction,
EntityPrivacyPolicyEvaluationContext,
} from '../EntityPrivacyPolicy';
import { EntityQueryContext } from '../EntityQueryContext';
import ViewerContext from '../ViewerContext';
import IEntityMetricsAdapter from '../metrics/IEntityMetricsAdapter';
import { RuleComplexity, RuleEvaluationResult } from '../rules/PrivacyPolicyRuleEnums';
import {
AllowOrSkipPrivacyPolicyRule,
DenyOrSkipPrivacyPolicyRule,
SkipPrivacyPolicyRule,
} from '../rules/PrivacyPolicyRuleTypes';
type BlahFields = {
id: string;
};
class BlahEntity extends Entity<BlahFields, string, ViewerContext> {
static defineCompanionDefinition(): EntityCompanionDefinition<
BlahFields,
string,
ViewerContext,
BlahEntity,
any
> {
return {
entityClass: BlahEntity,
entityConfiguration: new EntityConfiguration<BlahFields>({
idField: 'id',
tableName: 'blah_table',
schema: {
id: new UUIDField({
columnName: 'id',
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
}),
privacyPolicyClass: ReorderablePolicy,
};
}
}
class LargeAsyncComplexityAllowRule extends AllowOrSkipPrivacyPolicyRule<
BlahFields,
string,
ViewerContext,
BlahEntity
> {
override async evaluateAsync(): Promise<RuleEvaluationResult.SKIP | RuleEvaluationResult.ALLOW> {
return RuleEvaluationResult.SKIP;
}
override complexity = RuleComplexity.HIGH;
}
class ConstantTimeComplexityAllowRule extends AllowOrSkipPrivacyPolicyRule<
BlahFields,
string,
ViewerContext,
BlahEntity
> {
override async evaluateAsync(): Promise<RuleEvaluationResult.SKIP | RuleEvaluationResult.ALLOW> {
return RuleEvaluationResult.SKIP;
}
override complexity = RuleComplexity.LOW;
}
class LargeAsyncComplexitySkipRule extends SkipPrivacyPolicyRule<
BlahFields,
string,
ViewerContext,
BlahEntity
> {
override async evaluateAsync(): Promise<RuleEvaluationResult.SKIP> {
return RuleEvaluationResult.SKIP;
}
override complexity = RuleComplexity.HIGH;
}
class ConstantTimeComplexitySkipRule extends SkipPrivacyPolicyRule<
BlahFields,
string,
ViewerContext,
BlahEntity
> {
override async evaluateAsync(): Promise<RuleEvaluationResult.SKIP> {
return RuleEvaluationResult.SKIP;
}
override complexity = RuleComplexity.LOW;
}
class LargeAsyncComplexityDenyRule extends DenyOrSkipPrivacyPolicyRule<
BlahFields,
string,
ViewerContext,
BlahEntity
> {
override async evaluateAsync(): Promise<RuleEvaluationResult.SKIP | RuleEvaluationResult.DENY> {
return RuleEvaluationResult.SKIP;
}
override complexity = RuleComplexity.HIGH;
}
class ConstantTimeComplexityDenyRule extends DenyOrSkipPrivacyPolicyRule<
BlahFields,
string,
ViewerContext,
BlahEntity
> {
override async evaluateAsync(): Promise<RuleEvaluationResult.SKIP | RuleEvaluationResult.DENY> {
return RuleEvaluationResult.SKIP;
}
override complexity = RuleComplexity.LOW;
}
class ReorderablePolicy extends EntityPrivacyPolicy<BlahFields, string, ViewerContext, BlahEntity> {
protected override async shouldAutoReorderRulesAccordingToComplexityAsync(
_viewerContext: ViewerContext,
_queryContext: EntityQueryContext,
_evaluationContext: EntityPrivacyPolicyEvaluationContext,
_action: EntityAuthorizationAction
): Promise<boolean> {
return true;
}
protected override readonly createRules = [
new LargeAsyncComplexityDenyRule(),
new ConstantTimeComplexityDenyRule(),
new LargeAsyncComplexityAllowRule(),
new ConstantTimeComplexitySkipRule(),
new ConstantTimeComplexityAllowRule(),
new LargeAsyncComplexityDenyRule(),
new LargeAsyncComplexitySkipRule(),
];
protected override readonly readRules = [];
protected override readonly updateRules = [];
protected override readonly deleteRules = [];
}
class NonReorderablePolicy extends EntityPrivacyPolicy<
BlahFields,
string,
ViewerContext,
BlahEntity
> {
protected override readonly createRules = [
new LargeAsyncComplexityDenyRule(),
new ConstantTimeComplexityDenyRule(),
new LargeAsyncComplexityAllowRule(),
new ConstantTimeComplexitySkipRule(),
new ConstantTimeComplexityAllowRule(),
new LargeAsyncComplexityDenyRule(),
new LargeAsyncComplexitySkipRule(),
];
protected override readonly readRules = [];
protected override readonly updateRules = [];
protected override readonly deleteRules = [];
}
describe(EntityPrivacyPolicy, () => {
test('reorderable policy', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity({
viewerContext,
id: '1',
databaseFields: { id: '1' },
selectedFields: { id: '1' },
});
const policy = new ReorderablePolicy();
const spy0 = jest.spyOn(policy['createRules'][0]!, 'evaluateAsync');
const spy1 = jest.spyOn(policy['createRules'][1]!, 'evaluateAsync');
const spy2 = jest.spyOn(policy['createRules'][2]!, 'evaluateAsync');
const spy3 = jest.spyOn(policy['createRules'][3]!, 'evaluateAsync');
const spy4 = jest.spyOn(policy['createRules'][4]!, 'evaluateAsync');
const spy5 = jest.spyOn(policy['createRules'][5]!, 'evaluateAsync');
const spy6 = jest.spyOn(policy['createRules'][6]!, 'evaluateAsync');
// this will deny since all rules skip, but we only care about rule evaluation order
try {
await policy.authorizeCreateAsync(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
entity,
metricsAdapter
);
} catch {}
const spy0CallOrder = spy0.mock.invocationCallOrder[0]!;
const spy1CallOrder = spy1.mock.invocationCallOrder[0]!;
const spy2CallOrder = spy2.mock.invocationCallOrder[0]!;
const spy3CallOrder = spy3.mock.invocationCallOrder[0]!;
const spy4CallOrder = spy4.mock.invocationCallOrder[0]!;
const spy5CallOrder = spy5.mock.invocationCallOrder[0]!;
const spy6CallOrder = spy6.mock.invocationCallOrder[0]!;
// expected reordering: 1, 0, 3, 4, 2, 5, 6
expect(spy1CallOrder).toBeLessThan(spy0CallOrder);
expect(spy0CallOrder).toBeLessThan(spy3CallOrder);
expect(spy3CallOrder).toBeLessThan(spy4CallOrder);
expect(spy4CallOrder).toBeLessThan(spy2CallOrder);
expect(spy2CallOrder).toBeLessThan(spy5CallOrder);
expect(spy5CallOrder).toBeLessThan(spy6CallOrder);
});
test('non-reorderable policy', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const privacyPolicyEvaluationContext = instance(mock<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock);
const entity = new BlahEntity({
viewerContext,
id: '1',
databaseFields: { id: '1' },
selectedFields: { id: '1' },
});
const policy = new NonReorderablePolicy();
const spy0 = jest.spyOn(policy['createRules'][0]!, 'evaluateAsync');
const spy1 = jest.spyOn(policy['createRules'][1]!, 'evaluateAsync');
const spy2 = jest.spyOn(policy['createRules'][2]!, 'evaluateAsync');
const spy3 = jest.spyOn(policy['createRules'][3]!, 'evaluateAsync');
const spy4 = jest.spyOn(policy['createRules'][4]!, 'evaluateAsync');
const spy5 = jest.spyOn(policy['createRules'][5]!, 'evaluateAsync');
const spy6 = jest.spyOn(policy['createRules'][6]!, 'evaluateAsync');
// this will deny since all rules skip, but we only care about rule evaluation order
try {
await policy.authorizeCreateAsync(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
entity,
metricsAdapter
);
} catch {}
const spy0CallOrder = spy0.mock.invocationCallOrder[0]!;
const spy1CallOrder = spy1.mock.invocationCallOrder[0]!;
const spy2CallOrder = spy2.mock.invocationCallOrder[0]!;
const spy3CallOrder = spy3.mock.invocationCallOrder[0]!;
const spy4CallOrder = spy4.mock.invocationCallOrder[0]!;
const spy5CallOrder = spy5.mock.invocationCallOrder[0]!;
const spy6CallOrder = spy6.mock.invocationCallOrder[0]!;
// expected reordering: 0, 1, 2, 3, 4, 5, 6
expect(spy0CallOrder).toBeLessThan(spy1CallOrder);
expect(spy1CallOrder).toBeLessThan(spy2CallOrder);
expect(spy2CallOrder).toBeLessThan(spy3CallOrder);
expect(spy3CallOrder).toBeLessThan(spy4CallOrder);
expect(spy4CallOrder).toBeLessThan(spy5CallOrder);
expect(spy5CallOrder).toBeLessThan(spy6CallOrder);
});
});
+2 -2
View File
@@ -66,8 +66,8 @@ export { default as NoOpEntityMetricsAdapter } from './metrics/NoOpEntityMetrics
export { default as AlwaysAllowPrivacyPolicyRule } from './rules/AlwaysAllowPrivacyPolicyRule';
export { default as AlwaysDenyPrivacyPolicyRule } from './rules/AlwaysDenyPrivacyPolicyRule';
export { default as AlwaysSkipPrivacyPolicyRule } from './rules/AlwaysSkipPrivacyPolicyRule';
export { default as PrivacyPolicyRule } from './rules/PrivacyPolicyRule';
export * from './rules/PrivacyPolicyRule';
export * from './rules/PrivacyPolicyRuleEnums';
export * from './rules/PrivacyPolicyRuleTypes';
export * from './utils/testing/PrivacyPolicyRuleTestUtils';
export * from './utils/testing/StubCacheAdapter';
export { default as describeFieldTestCase } from './utils/testing/describeFieldTestCase';
@@ -1,4 +1,5 @@
import PrivacyPolicyRule, { RuleEvaluationResult } from './PrivacyPolicyRule';
import { RuleComplexity, RuleEvaluationResult } from './PrivacyPolicyRuleEnums';
import { AllowOrSkipPrivacyPolicyRule } from './PrivacyPolicyRuleTypes';
import { EntityPrivacyPolicyEvaluationContext } from '../EntityPrivacyPolicy';
import { EntityQueryContext } from '../EntityQueryContext';
import ReadonlyEntity from '../ReadonlyEntity';
@@ -13,13 +14,15 @@ export default class AlwaysAllowPrivacyPolicyRule<
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields = keyof TFields
> extends PrivacyPolicyRule<TFields, TID, TViewerContext, TEntity, TSelectedFields> {
> extends AllowOrSkipPrivacyPolicyRule<TFields, TID, TViewerContext, TEntity, TSelectedFields> {
override complexity = RuleComplexity.LOW;
async evaluateAsync(
_viewerContext: TViewerContext,
_queryContext: EntityQueryContext,
_evaluationContext: EntityPrivacyPolicyEvaluationContext,
_entity: TEntity
): Promise<RuleEvaluationResult> {
): Promise<RuleEvaluationResult.ALLOW | RuleEvaluationResult.SKIP> {
return RuleEvaluationResult.ALLOW;
}
}
@@ -1,4 +1,5 @@
import PrivacyPolicyRule, { RuleEvaluationResult } from './PrivacyPolicyRule';
import { RuleComplexity, RuleEvaluationResult } from './PrivacyPolicyRuleEnums';
import { DenyOrSkipPrivacyPolicyRule } from './PrivacyPolicyRuleTypes';
import { EntityPrivacyPolicyEvaluationContext } from '../EntityPrivacyPolicy';
import { EntityQueryContext } from '../EntityQueryContext';
import ReadonlyEntity from '../ReadonlyEntity';
@@ -13,13 +14,15 @@ export default class AlwaysDenyPrivacyPolicyRule<
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields = keyof TFields
> extends PrivacyPolicyRule<TFields, TID, TViewerContext, TEntity, TSelectedFields> {
> extends DenyOrSkipPrivacyPolicyRule<TFields, TID, TViewerContext, TEntity, TSelectedFields> {
override complexity = RuleComplexity.LOW;
async evaluateAsync(
_viewerContext: TViewerContext,
_queryContext: EntityQueryContext,
_evaluationContext: EntityPrivacyPolicyEvaluationContext,
_entity: TEntity
): Promise<RuleEvaluationResult> {
): Promise<RuleEvaluationResult.DENY | RuleEvaluationResult.SKIP> {
return RuleEvaluationResult.DENY;
}
}
@@ -1,4 +1,5 @@
import PrivacyPolicyRule, { RuleEvaluationResult } from './PrivacyPolicyRule';
import { RuleComplexity, RuleEvaluationResult } from './PrivacyPolicyRuleEnums';
import { SkipPrivacyPolicyRule } from './PrivacyPolicyRuleTypes';
import { EntityPrivacyPolicyEvaluationContext } from '../EntityPrivacyPolicy';
import { EntityQueryContext } from '../EntityQueryContext';
import ReadonlyEntity from '../ReadonlyEntity';
@@ -13,13 +14,15 @@ export default class AlwaysSkipPrivacyPolicyRule<
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields = keyof TFields
> extends PrivacyPolicyRule<TFields, TID, TViewerContext, TEntity, TSelectedFields> {
> extends SkipPrivacyPolicyRule<TFields, TID, TViewerContext, TEntity, TSelectedFields> {
override complexity = RuleComplexity.LOW;
async evaluateAsync(
_viewerContext: TViewerContext,
_queryContext: EntityQueryContext,
_evaluationContext: EntityPrivacyPolicyEvaluationContext,
_entity: TEntity
): Promise<RuleEvaluationResult> {
): Promise<RuleEvaluationResult.SKIP> {
return RuleEvaluationResult.SKIP;
}
}
@@ -0,0 +1,61 @@
/**
* Enum return type of privacy policy rule evaluation.
*/
export enum RuleEvaluationResult {
/**
* Deny viewer access to the entity.
*/
DENY = -1,
/**
* Defer entity viewer access to subsequent rule in the privacy policy.
*/
SKIP = 0,
/**
* Allow viewer access to the entity.
*/
ALLOW = 1,
}
/**
* Estimated complexity of a privacy policy rule. Definition of what rules fall into what complexity category
* is application-specific, but the AlwaysAllow/AlwaysDeny/AlwaysSkip rules provided are all defined as LOW
* complexity.
*/
export enum RuleComplexity {
/**
* Rule complexity is low. Definition dependent on application.
*/
LOW = 0,
/**
* Rule complexity is medium. Definition dependent on application.
*/
MEDIUM = 1,
/**
* Rule complexity is high. Definition dependent on application.
*/
HIGH = 2,
}
/**
* Used for disambiguation between classes of privacy policy rules at runtime.
* Multiple rules of the same type in a row can be reordered safely to improve policy evaluation time
* based on complexities.
*/
export enum RuleEvaluationResultType {
/**
* Rule is a DenyOrSkipPrivacyPolicyRule. Can be safely reordered with other sequential DenyOrSkipPrivacyPolicyRule or SkipPrivacyPolicyRule in a policy.
*/
DENY_OR_SKIP,
/**
* Rule is a SkipPrivacyPolicyRule. Can be safely reordered with any other sequential privacy policy rules in a policy.
*/
SKIP,
/**
* Rule is an AllowOrSkipPrivacyPolicyRule. Can be safely reordered with other sequential AllowOrSkipPrivacyPolicyRule or SkipPrivacyPolicyRule in a policy.
*/
ALLOW_OR_SKIP,
}
@@ -0,0 +1,68 @@
import { RuleEvaluationResult, RuleEvaluationResultType } from './PrivacyPolicyRuleEnums';
import { PrivacyPolicyRuleInternal } from './internal/PrivacyPolicyRule';
import { EntityPrivacyPolicyEvaluationContext } from '../EntityPrivacyPolicy';
import { EntityQueryContext } from '../EntityQueryContext';
import ReadonlyEntity from '../ReadonlyEntity';
import ViewerContext from '../ViewerContext';
export type PrivacyPolicyRule<
TFields extends object,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields = keyof TFields
> =
| AllowOrSkipPrivacyPolicyRule<TFields, TID, TViewerContext, TEntity, TSelectedFields>
| DenyOrSkipPrivacyPolicyRule<TFields, TID, TViewerContext, TEntity, TSelectedFields>
| SkipPrivacyPolicyRule<TFields, TID, TViewerContext, TEntity, TSelectedFields>;
export abstract class AllowOrSkipPrivacyPolicyRule<
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 PrivacyPolicyRuleInternal<TFields, TID, TViewerContext, TEntity, TSelectedFields> {
abstract override evaluateAsync(
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext,
entity: TEntity
): Promise<RuleEvaluationResult.ALLOW | RuleEvaluationResult.SKIP>;
override resultType = RuleEvaluationResultType.ALLOW_OR_SKIP;
}
export abstract class DenyOrSkipPrivacyPolicyRule<
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 PrivacyPolicyRuleInternal<TFields, TID, TViewerContext, TEntity, TSelectedFields> {
abstract override evaluateAsync(
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext,
entity: TEntity
): Promise<RuleEvaluationResult.DENY | RuleEvaluationResult.SKIP>;
override resultType = RuleEvaluationResultType.DENY_OR_SKIP;
}
export abstract class SkipPrivacyPolicyRule<
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 PrivacyPolicyRuleInternal<TFields, TID, TViewerContext, TEntity, TSelectedFields> {
abstract override evaluateAsync(
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext,
entity: TEntity
): Promise<RuleEvaluationResult.SKIP>;
override resultType = RuleEvaluationResultType.SKIP;
}
@@ -1,24 +1,12 @@
import { EntityPrivacyPolicyEvaluationContext } from '../EntityPrivacyPolicy';
import { EntityQueryContext } from '../EntityQueryContext';
import ReadonlyEntity from '../ReadonlyEntity';
import ViewerContext from '../ViewerContext';
export enum RuleEvaluationResult {
/**
* Deny viewer access to the entity.
*/
DENY = -1,
/**
* Defer entity viewer access to subsequent rule in the privacy policy.
*/
SKIP = 0,
/**
* Allow viewer access to the entity.
*/
ALLOW = 1,
}
import { EntityPrivacyPolicyEvaluationContext } from '../../EntityPrivacyPolicy';
import { EntityQueryContext } from '../../EntityQueryContext';
import ReadonlyEntity from '../../ReadonlyEntity';
import ViewerContext from '../../ViewerContext';
import {
RuleComplexity,
RuleEvaluationResult,
RuleEvaluationResultType,
} from '../PrivacyPolicyRuleEnums';
/**
* A single unit of which declarative privacy policies are composed, allowing for simple
@@ -36,17 +24,25 @@ export enum RuleEvaluationResult {
* - Blocking. For example, a user blocks another user from seeing their posts, and the rule
* would be named something like `DenyIfViewerHasBeenBlockedPrivacyPolicyRule`.
*/
export default abstract class PrivacyPolicyRule<
export abstract class PrivacyPolicyRuleInternal<
TFields extends object,
TID extends NonNullable<TFields[TSelectedFields]>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TID, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields = keyof TFields
> {
/**
* Estimated complexity of a privacy policy rule. Used for automatic (safe) rule reordering.
* Typed as a number so that applications can define their own complexity scale.
*/
abstract readonly complexity: RuleComplexity;
abstract evaluateAsync(
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
evaluationContext: EntityPrivacyPolicyEvaluationContext,
entity: TEntity
): Promise<RuleEvaluationResult>;
abstract readonly resultType: RuleEvaluationResultType;
}
@@ -0,0 +1,70 @@
import invariant from 'invariant';
import { RuleEvaluationResultType } from '../PrivacyPolicyRuleEnums';
/**
* Privacy policy list of PrivacyPolicyRule can be reordered safely according to
* the following constraints on RuleEvaluationResultType:
* 1. Two adjacent ALLOW_OR_SKIP rules can be swapped.
* 2. Two adjacent DENY_OR_SKIP rules can be swapped.
* 3. SKIP rules can be swapped with any other adjacent rule.
*
* @param rulesetOriginalOrder - ruleset with original ordering defined in code
* @returns reordered ruleset depending on constraints above
*/
export function reorderRulesByRuleComplexityGroups<
TRule extends { resultType: RuleEvaluationResultType; complexity: number }
>(rulesetOriginalOrder: readonly TRule[]): readonly TRule[] {
// first, group the rules according to constraints
const groups = groupRulesByResultType(rulesetOriginalOrder);
// sort the groups by declared complexity
const internallySortedGroups = groups.map((group) => sortGroupByComplexity(group));
// flatten the groups
return internallySortedGroups.flat();
}
export function sortGroupByComplexity<TRule extends { complexity: number }>(
ruleGroup: readonly TRule[]
): readonly TRule[] {
return [...ruleGroup].sort((a, b) => a.complexity - b.complexity);
}
export function groupRulesByResultType<TRule extends { resultType: RuleEvaluationResultType }>(
ruleset: readonly TRule[]
): readonly (readonly TRule[])[] {
const groups: TRule[][] = [[]];
let currentGroupIdx = 0;
let currentGroupType:
| RuleEvaluationResultType.ALLOW_OR_SKIP
| RuleEvaluationResultType.DENY_OR_SKIP
| null = null;
for (const rule of ruleset) {
// if group type of this rule is not compatible with the current group type, start a new group
if (
currentGroupType !== null &&
rule.resultType !== currentGroupType &&
rule.resultType !== RuleEvaluationResultType.SKIP
) {
currentGroupIdx += 1;
groups.push([]);
currentGroupType = null;
}
// add the current rule to the current group
const currentGroup = groups[currentGroupIdx];
invariant(currentGroup, `invalid groups index ${currentGroupIdx}`);
currentGroup.push(rule);
// if there isn't a known group type yet for the current group, set it to the group type of the rule.
// note that if there isn't a known group type yet and the current rule is SKIP, we'll
// continue to the next rule to determine group type
if (currentGroupType === null && rule.resultType !== RuleEvaluationResultType.SKIP) {
currentGroupType = rule.resultType;
}
}
return groups;
}
@@ -0,0 +1,152 @@
import { RuleComplexity, RuleEvaluationResultType } from '../../PrivacyPolicyRuleEnums';
import {
groupRulesByResultType,
reorderRulesByRuleComplexityGroups,
sortGroupByComplexity,
} from '../PrivacyPolicyRuleOrdering';
type TestRuleJustResultType = { id: number; resultType: RuleEvaluationResultType };
describe(groupRulesByResultType, () => {
const rule1 = { id: 1, resultType: RuleEvaluationResultType.ALLOW_OR_SKIP };
const rule2 = { id: 2, resultType: RuleEvaluationResultType.ALLOW_OR_SKIP };
const rule3 = { id: 3, resultType: RuleEvaluationResultType.SKIP };
const rule4 = { id: 4, resultType: RuleEvaluationResultType.SKIP };
const rule5 = { id: 5, resultType: RuleEvaluationResultType.DENY_OR_SKIP };
const rule6 = { id: 6, resultType: RuleEvaluationResultType.DENY_OR_SKIP };
test.each([
// two adjacent of the same type should be grouped
{ ruleset: [rule1, rule2], expectedResultGroups: [[rule1, rule2]] },
{ ruleset: [rule3, rule4], expectedResultGroups: [[rule3, rule4]] },
{ ruleset: [rule5, rule6], expectedResultGroups: [[rule5, rule6]] },
// two adjacent of ALLOW_OR_SKIP or DENY_OR_SKIP should not be grouped
{ ruleset: [rule1, rule5], expectedResultGroups: [[rule1], [rule5]] },
{ ruleset: [rule5, rule1], expectedResultGroups: [[rule5], [rule1]] },
// adjacent (ALLOW_OR_SKIP and SKIP) or (DENY_OR_SKIP and SKIP) should be grouped
{ ruleset: [rule1, rule2, rule3], expectedResultGroups: [[rule1, rule2, rule3]] },
{ ruleset: [rule1, rule2, rule3, rule4], expectedResultGroups: [[rule1, rule2, rule3, rule4]] },
{ ruleset: [rule5, rule6, rule3], expectedResultGroups: [[rule5, rule6, rule3]] },
{ ruleset: [rule5, rule6, rule3, rule4], expectedResultGroups: [[rule5, rule6, rule3, rule4]] },
// skip cases group with first group
{
ruleset: [rule1, rule2, rule3, rule4, rule5, rule6],
expectedResultGroups: [
[rule1, rule2, rule3, rule4],
[rule5, rule6],
],
},
// skip in first N slots should take the first group type
{
ruleset: [rule3, rule4, rule1, rule2, rule5, rule6],
expectedResultGroups: [
[rule3, rule4, rule1, rule2],
[rule5, rule6],
],
},
// lots of groups
{
ruleset: [rule1, rule5, rule3, rule2, rule6, rule4],
expectedResultGroups: [[rule1], [rule5, rule3], [rule2], [rule6, rule4]],
},
// skip in first N slots
{
ruleset: [rule3, rule4, rule5, rule1],
expectedResultGroups: [[rule3, rule4, rule5], [rule1]],
},
])(
'case %#',
({
ruleset,
expectedResultGroups,
}: {
ruleset: TestRuleJustResultType[];
expectedResultGroups: TestRuleJustResultType[][];
}) => {
const resultGroups = groupRulesByResultType(ruleset);
expect(resultGroups).toStrictEqual(expectedResultGroups);
}
);
});
type TestRuleJustComplexity = { id: number; complexity: number };
describe(sortGroupByComplexity, () => {
const rule1 = { id: 1, complexity: RuleComplexity.LOW };
const rule2 = { id: 2, complexity: RuleComplexity.LOW };
const rule3 = { id: 3, complexity: RuleComplexity.MEDIUM };
const rule5 = { id: 3, complexity: RuleComplexity.HIGH };
test.each([
{ ruleGroup: [rule1, rule2], expectedRuleGroup: [rule1, rule2] },
{ ruleGroup: [rule1, rule3], expectedRuleGroup: [rule1, rule3] },
{ ruleGroup: [rule1, rule3, rule5], expectedRuleGroup: [rule1, rule3, rule5] },
{ ruleGroup: [rule5, rule3, rule1], expectedRuleGroup: [rule1, rule3, rule5] },
])(
'case %#',
({
ruleGroup,
expectedRuleGroup,
}: {
ruleGroup: TestRuleJustComplexity[];
expectedRuleGroup: TestRuleJustComplexity[];
}) => {
const resultGroup = sortGroupByComplexity(ruleGroup);
expect(resultGroup).toStrictEqual(expectedRuleGroup);
}
);
});
type TestRule = { id: number; resultType: RuleEvaluationResultType; complexity: number };
describe(reorderRulesByRuleComplexityGroups, () => {
const rule1 = {
id: 1,
resultType: RuleEvaluationResultType.ALLOW_OR_SKIP,
complexity: RuleComplexity.HIGH,
};
const rule2 = {
id: 2,
resultType: RuleEvaluationResultType.ALLOW_OR_SKIP,
complexity: RuleComplexity.LOW,
};
const rule3 = {
id: 3,
resultType: RuleEvaluationResultType.SKIP,
complexity: RuleComplexity.LOW,
};
const rule4 = {
id: 4,
resultType: RuleEvaluationResultType.SKIP,
complexity: RuleComplexity.HIGH,
};
const rule5 = {
id: 5,
resultType: RuleEvaluationResultType.DENY_OR_SKIP,
complexity: RuleComplexity.MEDIUM,
};
const rule6 = {
id: 6,
resultType: RuleEvaluationResultType.DENY_OR_SKIP,
complexity: RuleComplexity.LOW,
};
test.each([
{
ruleset: [rule1, rule2, rule3, rule4, rule5, rule6],
expectedRuleset: [rule2, rule3, rule1, rule4, rule6, rule5],
},
])(
'case %#',
({ ruleset, expectedRuleset }: { ruleset: TestRule[]; expectedRuleset: TestRule[] }) => {
const resultRuleset = reorderRulesByRuleComplexityGroups(ruleset);
expect(resultRuleset).toStrictEqual(expectedRuleset);
}
);
});
@@ -2,7 +2,8 @@ import { EntityPrivacyPolicyEvaluationContext } from '../../EntityPrivacyPolicy'
import { EntityQueryContext } from '../../EntityQueryContext';
import ReadonlyEntity from '../../ReadonlyEntity';
import ViewerContext from '../../ViewerContext';
import PrivacyPolicyRule, { RuleEvaluationResult } from '../../rules/PrivacyPolicyRule';
import { RuleEvaluationResult } from '../../rules/PrivacyPolicyRuleEnums';
import { PrivacyPolicyRule } from '../../rules/PrivacyPolicyRuleTypes';
export interface Case<
TFields extends object,