From d000f081da274a1928589c7ddae2a858d98b0efe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Tue, 26 Mar 2024 07:59:45 -0400 Subject: [PATCH] [PM-6556] reintroduce policy reduction for multi-org accounts (#8409) --- .../generator-strategy.abstraction.ts | 9 ++- .../default-generator.service.spec.ts | 36 ++++++------ .../generator/default-generator.service.ts | 14 ++--- .../passphrase-generator-policy.spec.ts | 51 +++++++++++++++++ .../passphrase/passphrase-generator-policy.ts | 26 +++++++++ .../passphrase-generator-strategy.spec.ts | 34 ++++++------ .../passphrase-generator-strategy.ts | 31 ++++------- .../password-generator-policy.spec.ts | 55 +++++++++++++++++++ .../password/password-generator-policy.ts | 27 +++++++++ .../password-generator-strategy.spec.ts | 34 ++++++------ .../password/password-generator-strategy.ts | 33 ++++------- .../reduce-collection.operator.spec.ts | 33 +++++++++++ .../generator/reduce-collection.operator.ts | 20 +++++++ .../catchall-generator-strategy.spec.ts | 46 ++++++---------- .../username/catchall-generator-strategy.ts | 18 ++---- .../eff-username-generator-strategy.spec.ts | 46 ++++++---------- .../eff-username-generator-strategy.ts | 18 ++---- .../forwarder-generator-strategy.spec.ts | 25 +++++++-- .../username/forwarder-generator-strategy.ts | 9 +-- .../subaddress-generator-strategy.spec.ts | 46 ++++++---------- .../username/subaddress-generator-strategy.ts | 18 ++---- 21 files changed, 388 insertions(+), 241 deletions(-) create mode 100644 libs/common/src/tools/generator/passphrase/passphrase-generator-policy.spec.ts create mode 100644 libs/common/src/tools/generator/password/password-generator-policy.spec.ts create mode 100644 libs/common/src/tools/generator/reduce-collection.operator.spec.ts create mode 100644 libs/common/src/tools/generator/reduce-collection.operator.ts diff --git a/libs/common/src/tools/generator/abstractions/generator-strategy.abstraction.ts b/libs/common/src/tools/generator/abstractions/generator-strategy.abstraction.ts index f11c1d7300..eda02f7cdc 100644 --- a/libs/common/src/tools/generator/abstractions/generator-strategy.abstraction.ts +++ b/libs/common/src/tools/generator/abstractions/generator-strategy.abstraction.ts @@ -1,3 +1,5 @@ +import { Observable } from "rxjs"; + import { PolicyType } from "../../../admin-console/enums"; // FIXME: use index.ts imports once policy abstractions and models // implement ADR-0002 @@ -21,13 +23,16 @@ export abstract class GeneratorStrategy { /** Length of time in milliseconds to cache the evaluator */ cache_ms: number; - /** Creates an evaluator from a generator policy. + /** Operator function that converts a policy collection observable to a single + * policy evaluator observable. * @param policy The policy being evaluated. * @returns the policy evaluator. If `policy` is is `null` or `undefined`, * then the evaluator defaults to the application's limits. * @throws when the policy's type does not match the generator's policy type. */ - evaluator: (policy: AdminPolicy) => PolicyEvaluator; + toEvaluator: () => ( + source: Observable, + ) => Observable>; /** Generates credentials from the given options. * @param options The options used to generate the credentials. diff --git a/libs/common/src/tools/generator/default-generator.service.spec.ts b/libs/common/src/tools/generator/default-generator.service.spec.ts index 84b8ff4530..53a46c4963 100644 --- a/libs/common/src/tools/generator/default-generator.service.spec.ts +++ b/libs/common/src/tools/generator/default-generator.service.spec.ts @@ -4,7 +4,7 @@ */ import { mock } from "jest-mock-extended"; -import { BehaviorSubject, firstValueFrom } from "rxjs"; +import { BehaviorSubject, firstValueFrom, map, pipe } from "rxjs"; import { FakeSingleUserState, awaitAsync } from "../../../spec"; import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction"; @@ -20,12 +20,12 @@ import { PasswordGenerationOptions } from "./password"; import { DefaultGeneratorService } from "."; -function mockPolicyService(config?: { state?: BehaviorSubject }) { +function mockPolicyService(config?: { state?: BehaviorSubject }) { const service = mock(); // FIXME: swap out the mock return value when `getAll$` becomes available - const stateValue = config?.state ?? new BehaviorSubject(null); - service.get$.mockReturnValue(stateValue); + const stateValue = config?.state ?? new BehaviorSubject([null]); + service.getAll$.mockReturnValue(stateValue); // const stateValue = config?.state ?? new BehaviorSubject(null); // service.getAll$.mockReturnValue(stateValue); @@ -46,7 +46,9 @@ function mockGeneratorStrategy(config?: { // the value from `config`. durableState: jest.fn(() => durableState), policy: config?.policy ?? PolicyType.DisableSend, - evaluator: jest.fn(() => config?.evaluator ?? mock>()), + toEvaluator: jest.fn(() => + pipe(map(() => config?.evaluator ?? mock>())), + ), }); return strategy; @@ -94,9 +96,7 @@ describe("Password generator service", () => { await firstValueFrom(service.evaluator$(SomeUser)); - // FIXME: swap out the expect when `getAll$` becomes available - expect(policy.get$).toHaveBeenCalledWith(PolicyType.PasswordGenerator); - //expect(policy.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, SomeUser); + expect(policy.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, SomeUser); }); it("should map the policy using the generation strategy", async () => { @@ -112,21 +112,22 @@ describe("Password generator service", () => { it("should update the evaluator when the password generator policy changes", async () => { // set up dependencies - const state = new BehaviorSubject(null); + const state = new BehaviorSubject([null]); const policy = mockPolicyService({ state }); const strategy = mockGeneratorStrategy(); const service = new DefaultGeneratorService(strategy, policy); - // model responses for the observable update + // model responses for the observable update. The map is called multiple times, + // and the array shift ensures reference equality is maintained. const firstEvaluator = mock>(); - strategy.evaluator.mockReturnValueOnce(firstEvaluator); const secondEvaluator = mock>(); - strategy.evaluator.mockReturnValueOnce(secondEvaluator); + const evaluators = [firstEvaluator, secondEvaluator]; + strategy.toEvaluator.mockReturnValueOnce(pipe(map(() => evaluators.shift()))); // act const evaluator$ = service.evaluator$(SomeUser); const firstResult = await firstValueFrom(evaluator$); - state.next(null); + state.next([null]); const secondResult = await firstValueFrom(evaluator$); // assert @@ -142,9 +143,7 @@ describe("Password generator service", () => { await firstValueFrom(service.evaluator$(SomeUser)); await firstValueFrom(service.evaluator$(SomeUser)); - // FIXME: swap out the expect when `getAll$` becomes available - expect(policy.get$).toHaveBeenCalledTimes(1); - //expect(policy.getAll$).toHaveBeenCalledTimes(1); + expect(policy.getAll$).toHaveBeenCalledTimes(1); }); it("should cache the password generator policy for each user", async () => { @@ -155,9 +154,8 @@ describe("Password generator service", () => { await firstValueFrom(service.evaluator$(SomeUser)); await firstValueFrom(service.evaluator$(AnotherUser)); - // FIXME: enable this test when `getAll$` becomes available - // expect(policy.getAll$).toHaveBeenNthCalledWith(1, PolicyType.PasswordGenerator, SomeUser); - // expect(policy.getAll$).toHaveBeenNthCalledWith(2, PolicyType.PasswordGenerator, AnotherUser); + expect(policy.getAll$).toHaveBeenNthCalledWith(1, PolicyType.PasswordGenerator, SomeUser); + expect(policy.getAll$).toHaveBeenNthCalledWith(2, PolicyType.PasswordGenerator, AnotherUser); }); }); diff --git a/libs/common/src/tools/generator/default-generator.service.ts b/libs/common/src/tools/generator/default-generator.service.ts index 9c884ccefd..34aacee695 100644 --- a/libs/common/src/tools/generator/default-generator.service.ts +++ b/libs/common/src/tools/generator/default-generator.service.ts @@ -1,4 +1,4 @@ -import { firstValueFrom, map, share, timer, ReplaySubject, Observable } from "rxjs"; +import { firstValueFrom, share, timer, ReplaySubject, Observable } from "rxjs"; // FIXME: use index.ts imports once policy abstractions and models // implement ADR-0002 @@ -44,14 +44,12 @@ export class DefaultGeneratorService implements GeneratorServic } private createEvaluator(userId: UserId) { - // FIXME: when it becomes possible to get a user-specific policy observable - // (`getAll$`) update this code to call it instead of `get$`. - const policies$ = this.policy.get$(this.strategy.policy); + const evaluator$ = this.policy.getAll$(this.strategy.policy, userId).pipe( + // create the evaluator from the policies + this.strategy.toEvaluator(), - // cache evaluator in a replay subject to amortize creation cost - // and reduce GC pressure. - const evaluator$ = policies$.pipe( - map((policy) => this.strategy.evaluator(policy)), + // cache evaluator in a replay subject to amortize creation cost + // and reduce GC pressure. share({ connector: () => new ReplaySubject(1), resetOnRefCountZero: () => timer(this.strategy.cache_ms), diff --git a/libs/common/src/tools/generator/passphrase/passphrase-generator-policy.spec.ts b/libs/common/src/tools/generator/passphrase/passphrase-generator-policy.spec.ts new file mode 100644 index 0000000000..991b2ae302 --- /dev/null +++ b/libs/common/src/tools/generator/passphrase/passphrase-generator-policy.spec.ts @@ -0,0 +1,51 @@ +import { PolicyType } from "../../../admin-console/enums"; +// FIXME: use index.ts imports once policy abstractions and models +// implement ADR-0002 +import { Policy } from "../../../admin-console/models/domain/policy"; +import { PolicyId } from "../../../types/guid"; + +import { DisabledPassphraseGeneratorPolicy, leastPrivilege } from "./passphrase-generator-policy"; + +function createPolicy( + data: any, + type: PolicyType = PolicyType.PasswordGenerator, + enabled: boolean = true, +) { + return new Policy({ + id: "id" as PolicyId, + organizationId: "organizationId", + data, + enabled, + type, + }); +} + +describe("leastPrivilege", () => { + it("should return the accumulator when the policy type does not apply", () => { + const policy = createPolicy({}, PolicyType.RequireSso); + + const result = leastPrivilege(DisabledPassphraseGeneratorPolicy, policy); + + expect(result).toEqual(DisabledPassphraseGeneratorPolicy); + }); + + it("should return the accumulator when the policy is not enabled", () => { + const policy = createPolicy({}, PolicyType.PasswordGenerator, false); + + const result = leastPrivilege(DisabledPassphraseGeneratorPolicy, policy); + + expect(result).toEqual(DisabledPassphraseGeneratorPolicy); + }); + + it.each([ + ["minNumberWords", 10], + ["capitalize", true], + ["includeNumber", true], + ])("should take the %p from the policy", (input, value) => { + const policy = createPolicy({ [input]: value }); + + const result = leastPrivilege(DisabledPassphraseGeneratorPolicy, policy); + + expect(result).toEqual({ ...DisabledPassphraseGeneratorPolicy, [input]: value }); + }); +}); diff --git a/libs/common/src/tools/generator/passphrase/passphrase-generator-policy.ts b/libs/common/src/tools/generator/passphrase/passphrase-generator-policy.ts index ca54184d16..db616f16c0 100644 --- a/libs/common/src/tools/generator/passphrase/passphrase-generator-policy.ts +++ b/libs/common/src/tools/generator/passphrase/passphrase-generator-policy.ts @@ -1,3 +1,8 @@ +import { PolicyType } from "../../../admin-console/enums"; +// FIXME: use index.ts imports once policy abstractions and models +// implement ADR-0002 +import { Policy } from "../../../admin-console/models/domain/policy"; + /** Policy options enforced during passphrase generation. */ export type PassphraseGeneratorPolicy = { minNumberWords: number; @@ -11,3 +16,24 @@ export const DisabledPassphraseGeneratorPolicy: PassphraseGeneratorPolicy = Obje capitalize: false, includeNumber: false, }); + +/** Reduces a policy into an accumulator by accepting the most restrictive + * values from each policy. + * @param acc the accumulator + * @param policy the policy to reduce + * @returns the most restrictive values between the policy and accumulator. + */ +export function leastPrivilege( + acc: PassphraseGeneratorPolicy, + policy: Policy, +): PassphraseGeneratorPolicy { + if (policy.type !== PolicyType.PasswordGenerator) { + return acc; + } + + return { + minNumberWords: Math.max(acc.minNumberWords, policy.data.minNumberWords ?? acc.minNumberWords), + capitalize: policy.data.capitalize || acc.capitalize, + includeNumber: policy.data.includeNumber || acc.includeNumber, + }; +} diff --git a/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.spec.ts b/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.spec.ts index 031ea05f01..b7f09bd717 100644 --- a/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.spec.ts +++ b/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.spec.ts @@ -4,6 +4,7 @@ */ import { mock } from "jest-mock-extended"; +import { of, firstValueFrom } from "rxjs"; import { PolicyType } from "../../../admin-console/enums"; // FIXME: use index.ts imports once policy abstractions and models @@ -21,17 +22,8 @@ import { PassphraseGeneratorOptionsEvaluator, PassphraseGeneratorStrategy } from const SomeUser = "some user" as UserId; describe("Password generation strategy", () => { - describe("evaluator()", () => { - it("should throw if the policy type is incorrect", () => { - const strategy = new PassphraseGeneratorStrategy(null, null); - const policy = mock({ - type: PolicyType.DisableSend, - }); - - expect(() => strategy.evaluator(policy)).toThrow(new RegExp("Mismatched policy type\\. .+")); - }); - - it("should map to the policy evaluator", () => { + describe("toEvaluator()", () => { + it("should map to the policy evaluator", async () => { const strategy = new PassphraseGeneratorStrategy(null, null); const policy = mock({ type: PolicyType.PasswordGenerator, @@ -42,7 +34,8 @@ describe("Password generation strategy", () => { }, }); - const evaluator = strategy.evaluator(policy); + const evaluator$ = of([policy]).pipe(strategy.toEvaluator()); + const evaluator = await firstValueFrom(evaluator$); expect(evaluator).toBeInstanceOf(PassphraseGeneratorOptionsEvaluator); expect(evaluator.policy).toMatchObject({ @@ -52,13 +45,18 @@ describe("Password generation strategy", () => { }); }); - it("should map `null` to a default policy evaluator", () => { - const strategy = new PassphraseGeneratorStrategy(null, null); - const evaluator = strategy.evaluator(null); + it.each([[[]], [null], [undefined]])( + "should map `%p` to a disabled password policy evaluator", + async (policies) => { + const strategy = new PassphraseGeneratorStrategy(null, null); - expect(evaluator).toBeInstanceOf(PassphraseGeneratorOptionsEvaluator); - expect(evaluator.policy).toMatchObject(DisabledPassphraseGeneratorPolicy); - }); + const evaluator$ = of(policies).pipe(strategy.toEvaluator()); + const evaluator = await firstValueFrom(evaluator$); + + expect(evaluator).toBeInstanceOf(PassphraseGeneratorOptionsEvaluator); + expect(evaluator.policy).toMatchObject(DisabledPassphraseGeneratorPolicy); + }, + ); }); describe("durableState", () => { diff --git a/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.ts b/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.ts index d39f54b576..f193b2b326 100644 --- a/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.ts +++ b/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.ts @@ -1,18 +1,19 @@ +import { map, pipe } from "rxjs"; + import { GeneratorStrategy } from ".."; import { PolicyType } from "../../../admin-console/enums"; -// FIXME: use index.ts imports once policy abstractions and models -// implement ADR-0002 -import { Policy } from "../../../admin-console/models/domain/policy"; import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { PASSPHRASE_SETTINGS } from "../key-definitions"; import { PasswordGenerationServiceAbstraction } from "../password/password-generation.service.abstraction"; +import { reduceCollection } from "../reduce-collection.operator"; import { PassphraseGenerationOptions } from "./passphrase-generation-options"; import { PassphraseGeneratorOptionsEvaluator } from "./passphrase-generator-options-evaluator"; import { DisabledPassphraseGeneratorPolicy, PassphraseGeneratorPolicy, + leastPrivilege, } from "./passphrase-generator-policy"; const ONE_MINUTE = 60 * 1000; @@ -23,6 +24,7 @@ export class PassphraseGeneratorStrategy { /** instantiates the password generator strategy. * @param legacy generates the passphrase + * @param stateProvider provides durable state */ constructor( private legacy: PasswordGenerationServiceAbstraction, @@ -39,26 +41,17 @@ export class PassphraseGeneratorStrategy return PolicyType.PasswordGenerator; } + /** {@link GeneratorStrategy.cache_ms} */ get cache_ms() { return ONE_MINUTE; } - /** {@link GeneratorStrategy.evaluator} */ - evaluator(policy: Policy): PassphraseGeneratorOptionsEvaluator { - if (!policy) { - return new PassphraseGeneratorOptionsEvaluator(DisabledPassphraseGeneratorPolicy); - } - - if (policy.type !== this.policy) { - const details = `Expected: ${this.policy}. Received: ${policy.type}`; - throw Error("Mismatched policy type. " + details); - } - - return new PassphraseGeneratorOptionsEvaluator({ - minNumberWords: policy.data.minNumberWords, - capitalize: policy.data.capitalize, - includeNumber: policy.data.includeNumber, - }); + /** {@link GeneratorStrategy.toEvaluator} */ + toEvaluator() { + return pipe( + reduceCollection(leastPrivilege, DisabledPassphraseGeneratorPolicy), + map((policy) => new PassphraseGeneratorOptionsEvaluator(policy)), + ); } /** {@link GeneratorStrategy.generate} */ diff --git a/libs/common/src/tools/generator/password/password-generator-policy.spec.ts b/libs/common/src/tools/generator/password/password-generator-policy.spec.ts new file mode 100644 index 0000000000..206d88741b --- /dev/null +++ b/libs/common/src/tools/generator/password/password-generator-policy.spec.ts @@ -0,0 +1,55 @@ +import { PolicyType } from "../../../admin-console/enums"; +// FIXME: use index.ts imports once policy abstractions and models +// implement ADR-0002 +import { Policy } from "../../../admin-console/models/domain/policy"; +import { PolicyId } from "../../../types/guid"; + +import { DisabledPasswordGeneratorPolicy, leastPrivilege } from "./password-generator-policy"; + +function createPolicy( + data: any, + type: PolicyType = PolicyType.PasswordGenerator, + enabled: boolean = true, +) { + return new Policy({ + id: "id" as PolicyId, + organizationId: "organizationId", + data, + enabled, + type, + }); +} + +describe("leastPrivilege", () => { + it("should return the accumulator when the policy type does not apply", () => { + const policy = createPolicy({}, PolicyType.RequireSso); + + const result = leastPrivilege(DisabledPasswordGeneratorPolicy, policy); + + expect(result).toEqual(DisabledPasswordGeneratorPolicy); + }); + + it("should return the accumulator when the policy is not enabled", () => { + const policy = createPolicy({}, PolicyType.PasswordGenerator, false); + + const result = leastPrivilege(DisabledPasswordGeneratorPolicy, policy); + + expect(result).toEqual(DisabledPasswordGeneratorPolicy); + }); + + it.each([ + ["minLength", 10, "minLength"], + ["useUpper", true, "useUppercase"], + ["useLower", true, "useLowercase"], + ["useNumbers", true, "useNumbers"], + ["minNumbers", 10, "numberCount"], + ["useSpecial", true, "useSpecial"], + ["minSpecial", 10, "specialCount"], + ])("should take the %p from the policy", (input, value, expected) => { + const policy = createPolicy({ [input]: value }); + + const result = leastPrivilege(DisabledPasswordGeneratorPolicy, policy); + + expect(result).toEqual({ ...DisabledPasswordGeneratorPolicy, [expected]: value }); + }); +}); diff --git a/libs/common/src/tools/generator/password/password-generator-policy.ts b/libs/common/src/tools/generator/password/password-generator-policy.ts index c28631e9de..7de6b49788 100644 --- a/libs/common/src/tools/generator/password/password-generator-policy.ts +++ b/libs/common/src/tools/generator/password/password-generator-policy.ts @@ -1,3 +1,8 @@ +import { PolicyType } from "../../../admin-console/enums"; +// FIXME: use index.ts imports once policy abstractions and models +// implement ADR-0002 +import { Policy } from "../../../admin-console/models/domain/policy"; + /** Policy options enforced during password generation. */ export type PasswordGeneratorPolicy = { /** The minimum length of generated passwords. @@ -48,3 +53,25 @@ export const DisabledPasswordGeneratorPolicy: PasswordGeneratorPolicy = Object.f useSpecial: false, specialCount: 0, }); + +/** Reduces a policy into an accumulator by accepting the most restrictive + * values from each policy. + * @param acc the accumulator + * @param policy the policy to reduce + * @returns the most restrictive values between the policy and accumulator. + */ +export function leastPrivilege(acc: PasswordGeneratorPolicy, policy: Policy) { + if (policy.type !== PolicyType.PasswordGenerator || !policy.enabled) { + return acc; + } + + return { + minLength: Math.max(acc.minLength, policy.data.minLength ?? acc.minLength), + useUppercase: policy.data.useUpper || acc.useUppercase, + useLowercase: policy.data.useLower || acc.useLowercase, + useNumbers: policy.data.useNumbers || acc.useNumbers, + numberCount: Math.max(acc.numberCount, policy.data.minNumbers ?? acc.numberCount), + useSpecial: policy.data.useSpecial || acc.useSpecial, + specialCount: Math.max(acc.specialCount, policy.data.minSpecial ?? acc.specialCount), + }; +} diff --git a/libs/common/src/tools/generator/password/password-generator-strategy.spec.ts b/libs/common/src/tools/generator/password/password-generator-strategy.spec.ts index 6c213f8c54..9bfa5b5f35 100644 --- a/libs/common/src/tools/generator/password/password-generator-strategy.spec.ts +++ b/libs/common/src/tools/generator/password/password-generator-strategy.spec.ts @@ -4,6 +4,7 @@ */ import { mock } from "jest-mock-extended"; +import { of, firstValueFrom } from "rxjs"; import { PolicyType } from "../../../admin-console/enums"; // FIXME: use index.ts imports once policy abstractions and models @@ -24,17 +25,8 @@ import { const SomeUser = "some user" as UserId; describe("Password generation strategy", () => { - describe("evaluator()", () => { - it("should throw if the policy type is incorrect", () => { - const strategy = new PasswordGeneratorStrategy(null, null); - const policy = mock({ - type: PolicyType.DisableSend, - }); - - expect(() => strategy.evaluator(policy)).toThrow(new RegExp("Mismatched policy type\\. .+")); - }); - - it("should map to the policy evaluator", () => { + describe("toEvaluator()", () => { + it("should map to a password policy evaluator", async () => { const strategy = new PasswordGeneratorStrategy(null, null); const policy = mock({ type: PolicyType.PasswordGenerator, @@ -49,7 +41,8 @@ describe("Password generation strategy", () => { }, }); - const evaluator = strategy.evaluator(policy); + const evaluator$ = of([policy]).pipe(strategy.toEvaluator()); + const evaluator = await firstValueFrom(evaluator$); expect(evaluator).toBeInstanceOf(PasswordGeneratorOptionsEvaluator); expect(evaluator.policy).toMatchObject({ @@ -63,13 +56,18 @@ describe("Password generation strategy", () => { }); }); - it("should map `null` to a default policy evaluator", () => { - const strategy = new PasswordGeneratorStrategy(null, null); - const evaluator = strategy.evaluator(null); + it.each([[[]], [null], [undefined]])( + "should map `%p` to a disabled password policy evaluator", + async (policies) => { + const strategy = new PasswordGeneratorStrategy(null, null); - expect(evaluator).toBeInstanceOf(PasswordGeneratorOptionsEvaluator); - expect(evaluator.policy).toMatchObject(DisabledPasswordGeneratorPolicy); - }); + const evaluator$ = of(policies).pipe(strategy.toEvaluator()); + const evaluator = await firstValueFrom(evaluator$); + + expect(evaluator).toBeInstanceOf(PasswordGeneratorOptionsEvaluator); + expect(evaluator.policy).toMatchObject(DisabledPasswordGeneratorPolicy); + }, + ); }); describe("durableState", () => { diff --git a/libs/common/src/tools/generator/password/password-generator-strategy.ts b/libs/common/src/tools/generator/password/password-generator-strategy.ts index 223470c586..f8d618128b 100644 --- a/libs/common/src/tools/generator/password/password-generator-strategy.ts +++ b/libs/common/src/tools/generator/password/password-generator-strategy.ts @@ -1,11 +1,11 @@ +import { map, pipe } from "rxjs"; + import { GeneratorStrategy } from ".."; import { PolicyType } from "../../../admin-console/enums"; -// FIXME: use index.ts imports once policy abstractions and models -// implement ADR-0002 -import { Policy } from "../../../admin-console/models/domain/policy"; import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { PASSWORD_SETTINGS } from "../key-definitions"; +import { reduceCollection } from "../reduce-collection.operator"; import { PasswordGenerationOptions } from "./password-generation-options"; import { PasswordGenerationServiceAbstraction } from "./password-generation.service.abstraction"; @@ -13,6 +13,7 @@ import { PasswordGeneratorOptionsEvaluator } from "./password-generator-options- import { DisabledPasswordGeneratorPolicy, PasswordGeneratorPolicy, + leastPrivilege, } from "./password-generator-policy"; const ONE_MINUTE = 60 * 1000; @@ -43,26 +44,12 @@ export class PasswordGeneratorStrategy return ONE_MINUTE; } - /** {@link GeneratorStrategy.evaluator} */ - evaluator(policy: Policy): PasswordGeneratorOptionsEvaluator { - if (!policy) { - return new PasswordGeneratorOptionsEvaluator(DisabledPasswordGeneratorPolicy); - } - - if (policy.type !== this.policy) { - const details = `Expected: ${this.policy}. Received: ${policy.type}`; - throw Error("Mismatched policy type. " + details); - } - - return new PasswordGeneratorOptionsEvaluator({ - minLength: policy.data.minLength, - useUppercase: policy.data.useUpper, - useLowercase: policy.data.useLower, - useNumbers: policy.data.useNumbers, - numberCount: policy.data.minNumbers, - useSpecial: policy.data.useSpecial, - specialCount: policy.data.minSpecial, - }); + /** {@link GeneratorStrategy.toEvaluator} */ + toEvaluator() { + return pipe( + reduceCollection(leastPrivilege, DisabledPasswordGeneratorPolicy), + map((policy) => new PasswordGeneratorOptionsEvaluator(policy)), + ); } /** {@link GeneratorStrategy.generate} */ diff --git a/libs/common/src/tools/generator/reduce-collection.operator.spec.ts b/libs/common/src/tools/generator/reduce-collection.operator.spec.ts new file mode 100644 index 0000000000..49648dfdf0 --- /dev/null +++ b/libs/common/src/tools/generator/reduce-collection.operator.spec.ts @@ -0,0 +1,33 @@ +/** + * include structuredClone in test environment. + * @jest-environment ../../../../shared/test.environment.ts + */ + +import { of, firstValueFrom } from "rxjs"; + +import { reduceCollection } from "./reduce-collection.operator"; + +describe("reduceCollection", () => { + it.each([[null], [undefined], [[]]])( + "should return the default value when the collection is %p", + async (value: number[]) => { + const reduce = (acc: number, value: number) => acc + value; + const source$ = of(value); + + const result$ = source$.pipe(reduceCollection(reduce, 100)); + const result = await firstValueFrom(result$); + + expect(result).toEqual(100); + }, + ); + + it("should reduce the collection to a single value", async () => { + const reduce = (acc: number, value: number) => acc + value; + const source$ = of([1, 2, 3]); + + const result$ = source$.pipe(reduceCollection(reduce, 0)); + const result = await firstValueFrom(result$); + + expect(result).toEqual(6); + }); +}); diff --git a/libs/common/src/tools/generator/reduce-collection.operator.ts b/libs/common/src/tools/generator/reduce-collection.operator.ts new file mode 100644 index 0000000000..224595eeba --- /dev/null +++ b/libs/common/src/tools/generator/reduce-collection.operator.ts @@ -0,0 +1,20 @@ +import { map, OperatorFunction } from "rxjs"; + +/** + * An observable operator that reduces an emitted collection to a single object, + * returning a default if all items are ignored. + * @param reduce The reduce function to apply to the filtered collection. The + * first argument is the accumulator, and the second is the current item. The + * return value is the new accumulator. + * @param defaultValue The default value to return if the collection is empty. The + * default value is also the initial value of the accumulator. + */ +export function reduceCollection( + reduce: (acc: Accumulator, value: Item) => Accumulator, + defaultValue: Accumulator, +): OperatorFunction { + return map((values: Item[]) => { + const reduced = (values ?? []).reduce(reduce, structuredClone(defaultValue)); + return reduced; + }); +} diff --git a/libs/common/src/tools/generator/username/catchall-generator-strategy.spec.ts b/libs/common/src/tools/generator/username/catchall-generator-strategy.spec.ts index dafb55feba..339e4b2720 100644 --- a/libs/common/src/tools/generator/username/catchall-generator-strategy.spec.ts +++ b/libs/common/src/tools/generator/username/catchall-generator-strategy.spec.ts @@ -1,4 +1,5 @@ import { mock } from "jest-mock-extended"; +import { of, firstValueFrom } from "rxjs"; import { PolicyType } from "../../../admin-console/enums"; // FIXME: use index.ts imports once policy abstractions and models @@ -12,39 +13,26 @@ import { CATCHALL_SETTINGS } from "../key-definitions"; import { CatchallGeneratorStrategy, UsernameGenerationServiceAbstraction } from "."; const SomeUser = "some user" as UserId; +const SomePolicy = mock({ + type: PolicyType.PasswordGenerator, + data: { + minLength: 10, + }, +}); describe("Email subaddress list generation strategy", () => { - describe("evaluator()", () => { - it("should throw if the policy type is incorrect", () => { - const strategy = new CatchallGeneratorStrategy(null, null); - const policy = mock({ - type: PolicyType.DisableSend, - }); + describe("toEvaluator()", () => { + it.each([[[]], [null], [undefined], [[SomePolicy]], [[SomePolicy, SomePolicy]]])( + "should map any input (= %p) to the default policy evaluator", + async (policies) => { + const strategy = new CatchallGeneratorStrategy(null, null); - expect(() => strategy.evaluator(policy)).toThrow(new RegExp("Mismatched policy type\\. .+")); - }); + const evaluator$ = of(policies).pipe(strategy.toEvaluator()); + const evaluator = await firstValueFrom(evaluator$); - it("should map to the policy evaluator", () => { - const strategy = new CatchallGeneratorStrategy(null, null); - const policy = mock({ - type: PolicyType.PasswordGenerator, - data: { - minLength: 10, - }, - }); - - const evaluator = strategy.evaluator(policy); - - expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator); - expect(evaluator.policy).toMatchObject({}); - }); - - it("should map `null` to a default policy evaluator", () => { - const strategy = new CatchallGeneratorStrategy(null, null); - const evaluator = strategy.evaluator(null); - - expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator); - }); + expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator); + }, + ); }); describe("durableState", () => { diff --git a/libs/common/src/tools/generator/username/catchall-generator-strategy.ts b/libs/common/src/tools/generator/username/catchall-generator-strategy.ts index aadca78b3b..6b36ebd50b 100644 --- a/libs/common/src/tools/generator/username/catchall-generator-strategy.ts +++ b/libs/common/src/tools/generator/username/catchall-generator-strategy.ts @@ -1,5 +1,6 @@ +import { map, pipe } from "rxjs"; + import { PolicyType } from "../../../admin-console/enums"; -import { Policy } from "../../../admin-console/models/domain/policy"; import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { GeneratorStrategy } from "../abstractions"; @@ -41,18 +42,9 @@ export class CatchallGeneratorStrategy return ONE_MINUTE; } - /** {@link GeneratorStrategy.evaluator} */ - evaluator(policy: Policy) { - if (!policy) { - return new DefaultPolicyEvaluator(); - } - - if (policy.type !== this.policy) { - const details = `Expected: ${this.policy}. Received: ${policy.type}`; - throw Error("Mismatched policy type. " + details); - } - - return new DefaultPolicyEvaluator(); + /** {@link GeneratorStrategy.toEvaluator} */ + toEvaluator() { + return pipe(map((_) => new DefaultPolicyEvaluator())); } /** {@link GeneratorStrategy.generate} */ diff --git a/libs/common/src/tools/generator/username/eff-username-generator-strategy.spec.ts b/libs/common/src/tools/generator/username/eff-username-generator-strategy.spec.ts index 0fb5bf573c..821b4bb7dc 100644 --- a/libs/common/src/tools/generator/username/eff-username-generator-strategy.spec.ts +++ b/libs/common/src/tools/generator/username/eff-username-generator-strategy.spec.ts @@ -1,4 +1,5 @@ import { mock } from "jest-mock-extended"; +import { of, firstValueFrom } from "rxjs"; import { PolicyType } from "../../../admin-console/enums"; // FIXME: use index.ts imports once policy abstractions and models @@ -12,39 +13,26 @@ import { EFF_USERNAME_SETTINGS } from "../key-definitions"; import { EffUsernameGeneratorStrategy, UsernameGenerationServiceAbstraction } from "."; const SomeUser = "some user" as UserId; +const SomePolicy = mock({ + type: PolicyType.PasswordGenerator, + data: { + minLength: 10, + }, +}); describe("EFF long word list generation strategy", () => { - describe("evaluator()", () => { - it("should throw if the policy type is incorrect", () => { - const strategy = new EffUsernameGeneratorStrategy(null, null); - const policy = mock({ - type: PolicyType.DisableSend, - }); + describe("toEvaluator()", () => { + it.each([[[]], [null], [undefined], [[SomePolicy]], [[SomePolicy, SomePolicy]]])( + "should map any input (= %p) to the default policy evaluator", + async (policies) => { + const strategy = new EffUsernameGeneratorStrategy(null, null); - expect(() => strategy.evaluator(policy)).toThrow(new RegExp("Mismatched policy type\\. .+")); - }); + const evaluator$ = of(policies).pipe(strategy.toEvaluator()); + const evaluator = await firstValueFrom(evaluator$); - it("should map to the policy evaluator", () => { - const strategy = new EffUsernameGeneratorStrategy(null, null); - const policy = mock({ - type: PolicyType.PasswordGenerator, - data: { - minLength: 10, - }, - }); - - const evaluator = strategy.evaluator(policy); - - expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator); - expect(evaluator.policy).toMatchObject({}); - }); - - it("should map `null` to a default policy evaluator", () => { - const strategy = new EffUsernameGeneratorStrategy(null, null); - const evaluator = strategy.evaluator(null); - - expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator); - }); + expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator); + }, + ); }); describe("durableState", () => { diff --git a/libs/common/src/tools/generator/username/eff-username-generator-strategy.ts b/libs/common/src/tools/generator/username/eff-username-generator-strategy.ts index e0179895ae..133b4e7777 100644 --- a/libs/common/src/tools/generator/username/eff-username-generator-strategy.ts +++ b/libs/common/src/tools/generator/username/eff-username-generator-strategy.ts @@ -1,5 +1,6 @@ +import { map, pipe } from "rxjs"; + import { PolicyType } from "../../../admin-console/enums"; -import { Policy } from "../../../admin-console/models/domain/policy"; import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { GeneratorStrategy } from "../abstractions"; @@ -41,18 +42,9 @@ export class EffUsernameGeneratorStrategy return ONE_MINUTE; } - /** {@link GeneratorStrategy.evaluator} */ - evaluator(policy: Policy) { - if (!policy) { - return new DefaultPolicyEvaluator(); - } - - if (policy.type !== this.policy) { - const details = `Expected: ${this.policy}. Received: ${policy.type}`; - throw Error("Mismatched policy type. " + details); - } - - return new DefaultPolicyEvaluator(); + /** {@link GeneratorStrategy.toEvaluator} */ + toEvaluator() { + return pipe(map((_) => new DefaultPolicyEvaluator())); } /** {@link GeneratorStrategy.generate} */ diff --git a/libs/common/src/tools/generator/username/forwarder-generator-strategy.spec.ts b/libs/common/src/tools/generator/username/forwarder-generator-strategy.spec.ts index 96a7bca2b1..30dd620484 100644 --- a/libs/common/src/tools/generator/username/forwarder-generator-strategy.spec.ts +++ b/libs/common/src/tools/generator/username/forwarder-generator-strategy.spec.ts @@ -1,6 +1,11 @@ import { mock } from "jest-mock-extended"; +import { of, firstValueFrom } from "rxjs"; import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec"; +import { PolicyType } from "../../../admin-console/enums"; +// FIXME: use index.ts imports once policy abstractions and models +// implement ADR-0002 +import { Policy } from "../../../admin-console/models/domain/policy"; import { CryptoService } from "../../../platform/abstractions/crypto.service"; import { EncryptService } from "../../../platform/abstractions/encrypt.service"; import { StateProvider } from "../../../platform/state"; @@ -29,6 +34,12 @@ class TestForwarder extends ForwarderGeneratorStrategy { const SomeUser = "some user" as UserId; const AnotherUser = "another user" as UserId; +const SomePolicy = mock({ + type: PolicyType.PasswordGenerator, + data: { + minLength: 10, + }, +}); describe("ForwarderGeneratorStrategy", () => { const encryptService = mock(); @@ -63,11 +74,17 @@ describe("ForwarderGeneratorStrategy", () => { }); }); - it("evaluator returns the default policy evaluator", () => { - const strategy = new TestForwarder(null, null, null); + describe("toEvaluator()", () => { + it.each([[[]], [null], [undefined], [[SomePolicy]], [[SomePolicy, SomePolicy]]])( + "should map any input (= %p) to the default policy evaluator", + async (policies) => { + const strategy = new TestForwarder(encryptService, keyService, stateProvider); - const result = strategy.evaluator(null); + const evaluator$ = of(policies).pipe(strategy.toEvaluator()); + const evaluator = await firstValueFrom(evaluator$); - expect(result).toBeInstanceOf(DefaultPolicyEvaluator); + expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator); + }, + ); }); }); diff --git a/libs/common/src/tools/generator/username/forwarder-generator-strategy.ts b/libs/common/src/tools/generator/username/forwarder-generator-strategy.ts index b0717695e0..8b78f22634 100644 --- a/libs/common/src/tools/generator/username/forwarder-generator-strategy.ts +++ b/libs/common/src/tools/generator/username/forwarder-generator-strategy.ts @@ -1,5 +1,6 @@ +import { map, pipe } from "rxjs"; + import { PolicyType } from "../../../admin-console/enums"; -import { Policy } from "../../../admin-console/models/domain/policy"; import { CryptoService } from "../../../platform/abstractions/crypto.service"; import { EncryptService } from "../../../platform/abstractions/encrypt.service"; import { KeyDefinition, SingleUserState, StateProvider } from "../../../platform/state"; @@ -81,8 +82,8 @@ export abstract class ForwarderGeneratorStrategy< /** Determine where forwarder configuration is stored */ protected abstract readonly key: KeyDefinition; - /** {@link GeneratorStrategy.evaluator} */ - evaluator = (_policy: Policy) => { - return new DefaultPolicyEvaluator(); + /** {@link GeneratorStrategy.toEvaluator} */ + toEvaluator = () => { + return pipe(map((_) => new DefaultPolicyEvaluator())); }; } diff --git a/libs/common/src/tools/generator/username/subaddress-generator-strategy.spec.ts b/libs/common/src/tools/generator/username/subaddress-generator-strategy.spec.ts index 105edd6b4d..59a2b56172 100644 --- a/libs/common/src/tools/generator/username/subaddress-generator-strategy.spec.ts +++ b/libs/common/src/tools/generator/username/subaddress-generator-strategy.spec.ts @@ -1,4 +1,5 @@ import { mock } from "jest-mock-extended"; +import { of, firstValueFrom } from "rxjs"; import { PolicyType } from "../../../admin-console/enums"; // FIXME: use index.ts imports once policy abstractions and models @@ -12,39 +13,26 @@ import { SUBADDRESS_SETTINGS } from "../key-definitions"; import { SubaddressGeneratorStrategy, UsernameGenerationServiceAbstraction } from "."; const SomeUser = "some user" as UserId; +const SomePolicy = mock({ + type: PolicyType.PasswordGenerator, + data: { + minLength: 10, + }, +}); describe("Email subaddress list generation strategy", () => { - describe("evaluator()", () => { - it("should throw if the policy type is incorrect", () => { - const strategy = new SubaddressGeneratorStrategy(null, null); - const policy = mock({ - type: PolicyType.DisableSend, - }); + describe("toEvaluator()", () => { + it.each([[[]], [null], [undefined], [[SomePolicy]], [[SomePolicy, SomePolicy]]])( + "should map any input (= %p) to the default policy evaluator", + async (policies) => { + const strategy = new SubaddressGeneratorStrategy(null, null); - expect(() => strategy.evaluator(policy)).toThrow(new RegExp("Mismatched policy type\\. .+")); - }); + const evaluator$ = of(policies).pipe(strategy.toEvaluator()); + const evaluator = await firstValueFrom(evaluator$); - it("should map to the policy evaluator", () => { - const strategy = new SubaddressGeneratorStrategy(null, null); - const policy = mock({ - type: PolicyType.PasswordGenerator, - data: { - minLength: 10, - }, - }); - - const evaluator = strategy.evaluator(policy); - - expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator); - expect(evaluator.policy).toMatchObject({}); - }); - - it("should map `null` to a default policy evaluator", () => { - const strategy = new SubaddressGeneratorStrategy(null, null); - const evaluator = strategy.evaluator(null); - - expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator); - }); + expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator); + }, + ); }); describe("durableState", () => { diff --git a/libs/common/src/tools/generator/username/subaddress-generator-strategy.ts b/libs/common/src/tools/generator/username/subaddress-generator-strategy.ts index 1aba473476..1ae0cb9142 100644 --- a/libs/common/src/tools/generator/username/subaddress-generator-strategy.ts +++ b/libs/common/src/tools/generator/username/subaddress-generator-strategy.ts @@ -1,5 +1,6 @@ +import { map, pipe } from "rxjs"; + import { PolicyType } from "../../../admin-console/enums"; -import { Policy } from "../../../admin-console/models/domain/policy"; import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { GeneratorStrategy } from "../abstractions"; @@ -41,18 +42,9 @@ export class SubaddressGeneratorStrategy return ONE_MINUTE; } - /** {@link GeneratorStrategy.evaluator} */ - evaluator(policy: Policy) { - if (!policy) { - return new DefaultPolicyEvaluator(); - } - - if (policy.type !== this.policy) { - const details = `Expected: ${this.policy}. Received: ${policy.type}`; - throw Error("Mismatched policy type. " + details); - } - - return new DefaultPolicyEvaluator(); + /** {@link GeneratorStrategy.toEvaluator} */ + toEvaluator() { + return pipe(map((_) => new DefaultPolicyEvaluator())); } /** {@link GeneratorStrategy.generate} */