[PM-6523] generator service tuning (#8155)

* rename policy$ to evaluator$
* replace `ActiveUserState` with `SingleUserState`
* implement `SingleUserState<T>` on `SecretState`
This commit is contained in:
✨ Audrey ✨ 2024-03-04 13:43:38 -05:00 committed by GitHub
parent bf6fd39f15
commit d87a8f9271
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 394 additions and 211 deletions

View File

@ -2,4 +2,5 @@ export * from "./utils";
export * from "./intercept-console";
export * from "./matchers";
export * from "./fake-state-provider";
export * from "./fake-state";
export * from "./fake-account-service";

View File

@ -4,7 +4,7 @@ export { DerivedState } from "./derived-state";
export { GlobalState } from "./global-state";
export { StateProvider } from "./state.provider";
export { GlobalStateProvider } from "./global-state.provider";
export { ActiveUserState, SingleUserState } from "./user-state";
export { ActiveUserState, SingleUserState, CombinedState } from "./user-state";
export { ActiveUserStateProvider, SingleUserStateProvider } from "./user-state.provider";
export { KeyDefinition } from "./key-definition";
export { StateUpdateOptions } from "./state-update-options";

View File

@ -2,14 +2,18 @@ import { PolicyType } from "../../../admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy as AdminPolicy } from "../../../admin-console/models/domain/policy";
import { KeyDefinition } from "../../../platform/state";
import { SingleUserState } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { PolicyEvaluator } from "./policy-evaluator.abstraction";
/** Tailors the generator service to generate a specific kind of credentials */
export abstract class GeneratorStrategy<Options, Policy> {
/** The key used when storing credentials on disk. */
disk: KeyDefinition<Options>;
/** Retrieve application state that persists across locks.
* @param userId: identifies the user state to retrieve
* @returns the strategy's durable user state
*/
durableState: (userId: UserId) => SingleUserState<Options>;
/** Identifies the policy enforced by the generator. */
policy: PolicyType;
@ -19,7 +23,8 @@ export abstract class GeneratorStrategy<Options, Policy> {
/** Creates an evaluator from a generator policy.
* @param policy The policy being evaluated.
* @returns the policy evaluator.
* @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<Policy, Options>;

View File

@ -1,5 +1,7 @@
import { Observable } from "rxjs";
import { UserId } from "../../../types/guid";
import { PolicyEvaluator } from "./policy-evaluator.abstraction";
/** Generates credentials used for user authentication
@ -9,19 +11,22 @@ import { PolicyEvaluator } from "./policy-evaluator.abstraction";
export abstract class GeneratorService<Options, Policy> {
/** An observable monitoring the options saved to disk.
* The observable updates when the options are saved.
* @param userId: Identifies the user making the request
*/
options$: Observable<Options>;
options$: (userId: UserId) => Observable<Options>;
/** An observable monitoring the options used to enforce policy.
* The observable updates when the policy changes.
* @param userId: Identifies the user making the request
*/
policy$: Observable<PolicyEvaluator<Policy, Options>>;
evaluator$: (userId: UserId) => Observable<PolicyEvaluator<Policy, Options>>;
/** Enforces the policy on the given options
* @param userId: Identifies the user making the request
* @param options the options to enforce the policy on
* @returns a new instance of the options with the policy enforced
*/
enforcePolicy: (options: Options) => Promise<Options>;
enforcePolicy: (userId: UserId, options: Options) => Promise<Options>;
/** Generates credentials
* @param options the options to generate credentials with
@ -30,8 +35,9 @@ export abstract class GeneratorService<Options, Policy> {
generate: (options: Options) => Promise<string>;
/** Saves the given options to disk.
* @param userId: Identifies the user making the request
* @param options the options to save
* @returns a promise that resolves when the options are saved
*/
saveOptions: (options: Options) => Promise<void>;
saveOptions: (userId: UserId, options: Options) => Promise<void>;
}

View File

@ -6,42 +6,45 @@
import { mock } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { FakeActiveUserStateProvider, mockAccountServiceWith } from "../../../spec";
import { FakeSingleUserState, awaitAsync } from "../../../spec";
import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction";
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 { Utils } from "../../platform/misc/utils";
import { ActiveUserState, ActiveUserStateProvider, KeyDefinition } from "../../platform/state";
import { SingleUserState } from "../../platform/state";
import { UserId } from "../../types/guid";
import { GeneratorStrategy, PolicyEvaluator } from "./abstractions";
import { PASSPHRASE_SETTINGS, PASSWORD_SETTINGS } from "./key-definitions";
import { PasswordGenerationOptions } from "./password";
import { DefaultGeneratorService } from ".";
function mockPolicyService(config?: { data?: any; policy?: BehaviorSubject<Policy> }) {
const state = mock<Policy>({ data: config?.data ?? {} });
const subject = config?.policy ?? new BehaviorSubject<Policy>(state);
function mockPolicyService(config?: { state?: BehaviorSubject<Policy> }) {
const service = mock<PolicyService>();
service.get$.mockReturnValue(subject.asObservable());
// FIXME: swap out the mock return value when `getAll$` becomes available
const stateValue = config?.state ?? new BehaviorSubject<Policy>(null);
service.get$.mockReturnValue(stateValue);
// const stateValue = config?.state ?? new BehaviorSubject<Policy[]>(null);
// service.getAll$.mockReturnValue(stateValue);
return service;
}
function mockGeneratorStrategy(config?: {
disk?: KeyDefinition<any>;
userState?: SingleUserState<any>;
policy?: PolicyType;
evaluator?: any;
}) {
const durableState =
config?.userState ?? new FakeSingleUserState<PasswordGenerationOptions>(SomeUser);
const strategy = mock<GeneratorStrategy<any, any>>({
// intentionally arbitrary so that tests that need to check
// whether they're used properly are guaranteed to test
// the value from `config`.
disk: config?.disk ?? {},
durableState: jest.fn(() => durableState),
policy: config?.policy ?? PolicyType.DisableSend,
evaluator: jest.fn(() => config?.evaluator ?? mock<PolicyEvaluator<any, any>>()),
});
@ -49,129 +52,123 @@ function mockGeneratorStrategy(config?: {
return strategy;
}
// FIXME: Use the fake instead, once it's updated to monitor its method calls.
function mockStateProvider(): [
ActiveUserStateProvider,
ActiveUserState<PasswordGenerationOptions>,
] {
const state = mock<ActiveUserState<PasswordGenerationOptions>>();
const provider = mock<ActiveUserStateProvider>();
provider.get.mockReturnValue(state);
return [provider, state];
}
function fakeStateProvider(key: KeyDefinition<any>, initalValue: any): FakeActiveUserStateProvider {
const userId = Utils.newGuid() as UserId;
const acctService = mockAccountServiceWith(userId);
const provider = new FakeActiveUserStateProvider(acctService);
provider.mockFor(key.key, initalValue);
return provider;
}
const SomeUser = "some user" as UserId;
const AnotherUser = "another user" as UserId;
describe("Password generator service", () => {
describe("constructor()", () => {
it("should initialize the password generator policy", () => {
const policy = mockPolicyService();
const strategy = mockGeneratorStrategy({ policy: PolicyType.PasswordGenerator });
new DefaultGeneratorService(strategy, policy, null);
expect(policy.get$).toHaveBeenCalledWith(PolicyType.PasswordGenerator);
});
});
describe("options$", () => {
it("should return the state from strategy.key", () => {
it("should retrieve durable state from the service", () => {
const policy = mockPolicyService();
const strategy = mockGeneratorStrategy({ disk: PASSPHRASE_SETTINGS });
const [state] = mockStateProvider();
const service = new DefaultGeneratorService(strategy, policy, state);
const userState = new FakeSingleUserState<PasswordGenerationOptions>(SomeUser);
const strategy = mockGeneratorStrategy({ userState });
const service = new DefaultGeneratorService(strategy, policy);
// invoke the getter. It returns the state but that's not important.
service.options$;
const result = service.options$(SomeUser);
expect(state.get).toHaveBeenCalledWith(PASSPHRASE_SETTINGS);
expect(strategy.durableState).toHaveBeenCalledWith(SomeUser);
expect(result).toBe(userState.state$);
});
});
describe("saveOptions()", () => {
it("should update the state at strategy.key", async () => {
const policy = mockPolicyService();
const [provider, state] = mockStateProvider();
const strategy = mockGeneratorStrategy({ disk: PASSWORD_SETTINGS });
const service = new DefaultGeneratorService(strategy, policy, provider);
await service.saveOptions({});
expect(provider.get).toHaveBeenCalledWith(PASSWORD_SETTINGS);
expect(state.update).toHaveBeenCalled();
});
it("should trigger an options$ update", async () => {
const policy = mockPolicyService();
const strategy = mockGeneratorStrategy();
// using the fake here because we're testing that the update and the
// property are wired together. If we were to mock that, we'd be testing
// the mock configuration instead of the wiring.
const provider = fakeStateProvider(strategy.disk, { length: 9 });
const service = new DefaultGeneratorService(strategy, policy, provider);
const userState = new FakeSingleUserState<PasswordGenerationOptions>(SomeUser, { length: 9 });
const strategy = mockGeneratorStrategy({ userState });
const service = new DefaultGeneratorService(strategy, policy);
await service.saveOptions({ length: 10 });
await service.saveOptions(SomeUser, { length: 10 });
await awaitAsync();
const options = await firstValueFrom(service.options$(SomeUser));
const options = await firstValueFrom(service.options$);
expect(strategy.durableState).toHaveBeenCalledWith(SomeUser);
expect(options).toEqual({ length: 10 });
});
});
describe("policy$", () => {
describe("evaluator$", () => {
it("should initialize the password generator policy", async () => {
const policy = mockPolicyService();
const strategy = mockGeneratorStrategy({ policy: PolicyType.PasswordGenerator });
const service = new DefaultGeneratorService(strategy, policy);
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);
});
it("should map the policy using the generation strategy", async () => {
const policyService = mockPolicyService();
const evaluator = mock<PolicyEvaluator<any, any>>();
const strategy = mockGeneratorStrategy({ evaluator });
const service = new DefaultGeneratorService(strategy, policyService);
const service = new DefaultGeneratorService(strategy, policyService, null);
const policy = await firstValueFrom(service.policy$);
const policy = await firstValueFrom(service.evaluator$(SomeUser));
expect(policy).toBe(evaluator);
});
it("should update the evaluator when the password generator policy changes", async () => {
// set up dependencies
const state = new BehaviorSubject<Policy>(null);
const policy = mockPolicyService({ state });
const strategy = mockGeneratorStrategy();
const service = new DefaultGeneratorService(strategy, policy);
// model responses for the observable update
const firstEvaluator = mock<PolicyEvaluator<any, any>>();
strategy.evaluator.mockReturnValueOnce(firstEvaluator);
const secondEvaluator = mock<PolicyEvaluator<any, any>>();
strategy.evaluator.mockReturnValueOnce(secondEvaluator);
// act
const evaluator$ = service.evaluator$(SomeUser);
const firstResult = await firstValueFrom(evaluator$);
state.next(null);
const secondResult = await firstValueFrom(evaluator$);
// assert
expect(firstResult).toBe(firstEvaluator);
expect(secondResult).toBe(secondEvaluator);
});
it("should cache the password generator policy", async () => {
const policy = mockPolicyService();
const strategy = mockGeneratorStrategy({ policy: PolicyType.PasswordGenerator });
const service = new DefaultGeneratorService(strategy, policy);
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);
});
it("should cache the password generator policy for each user", async () => {
const policy = mockPolicyService();
const strategy = mockGeneratorStrategy({ policy: PolicyType.PasswordGenerator });
const service = new DefaultGeneratorService(strategy, policy);
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);
});
});
describe("enforcePolicy()", () => {
describe("should load the policy", () => {
it("from the cache by default", async () => {
const policy = mockPolicyService();
const strategy = mockGeneratorStrategy();
const service = new DefaultGeneratorService(strategy, policy, null);
await service.enforcePolicy({});
await service.enforcePolicy({});
expect(strategy.evaluator).toHaveBeenCalledTimes(1);
});
it("from the policy service when the policy changes", async () => {
const policy = new BehaviorSubject<Policy>(mock<Policy>({ data: {} }));
const policyService = mockPolicyService({ policy });
const strategy = mockGeneratorStrategy();
const service = new DefaultGeneratorService(strategy, policyService, null);
await service.enforcePolicy({});
policy.next(mock<Policy>({ data: { some: "change" } }));
await service.enforcePolicy({});
expect(strategy.evaluator).toHaveBeenCalledTimes(2);
});
});
it("should evaluate the policy using the generation strategy", async () => {
const policy = mockPolicyService();
const evaluator = mock<PolicyEvaluator<any, any>>();
const strategy = mockGeneratorStrategy({ evaluator });
const service = new DefaultGeneratorService(strategy, policy, null);
const service = new DefaultGeneratorService(strategy, policy);
await service.enforcePolicy({});
await service.enforcePolicy(SomeUser, {});
expect(evaluator.applyPolicy).toHaveBeenCalled();
expect(evaluator.sanitize).toHaveBeenCalled();
@ -182,7 +179,7 @@ describe("Password generator service", () => {
it("should invoke the generation strategy", async () => {
const strategy = mockGeneratorStrategy();
const policy = mockPolicyService();
const service = new DefaultGeneratorService(strategy, policy, null);
const service = new DefaultGeneratorService(strategy, policy);
await service.generate({});

View File

@ -3,7 +3,7 @@ import { firstValueFrom, map, share, timer, ReplaySubject, Observable } from "rx
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction";
import { ActiveUserStateProvider } from "../../platform/state";
import { UserId } from "../../types/guid";
import { GeneratorStrategy, GeneratorService, PolicyEvaluator } from "./abstractions";
@ -13,45 +13,57 @@ export class DefaultGeneratorService<Options, Policy> implements GeneratorServic
* @param strategy tailors the service to a specific generator type
* (e.g. password, passphrase)
* @param policy provides the policy to enforce
* @param state saves and loads password generation options to the location
* specified by the strategy
*/
constructor(
private strategy: GeneratorStrategy<Options, Policy>,
private policy: PolicyService,
private state: ActiveUserStateProvider,
) {
this._policy$ = this.policy.get$(this.strategy.policy).pipe(
) {}
private _evaluators$ = new Map<UserId, Observable<PolicyEvaluator<Policy, Options>>>();
/** {@link GeneratorService.options$()} */
options$(userId: UserId) {
return this.strategy.durableState(userId).state$;
}
/** {@link GeneratorService.saveOptions} */
async saveOptions(userId: UserId, options: Options): Promise<void> {
await this.strategy.durableState(userId).update(() => options);
}
/** {@link GeneratorService.evaluator$()} */
evaluator$(userId: UserId) {
let evaluator$ = this._evaluators$.get(userId);
if (!evaluator$) {
evaluator$ = this.createEvaluator(userId);
this._evaluators$.set(userId, evaluator$);
}
return evaluator$;
}
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);
// cache evaluator in a replay subject to amortize creation cost
// and reduce GC pressure.
const evaluator$ = policies$.pipe(
map((policy) => this.strategy.evaluator(policy)),
share({
// cache evaluator in a replay subject to amortize creation cost
// and reduce GC pressure.
connector: () => new ReplaySubject(1),
resetOnRefCountZero: () => timer(this.strategy.cache_ms),
}),
);
return evaluator$;
}
private _policy$: Observable<PolicyEvaluator<Policy, Options>>;
/** {@link GeneratorService.options$} */
get options$() {
return this.state.get(this.strategy.disk).state$;
}
/** {@link GeneratorService.saveOptions} */
async saveOptions(options: Options): Promise<void> {
await this.state.get(this.strategy.disk).update(() => options);
}
/** {@link GeneratorService.policy$} */
get policy$() {
return this._policy$;
}
/** {@link GeneratorService.enforcePolicy} */
async enforcePolicy(options: Options): Promise<Options> {
const policy = await firstValueFrom(this._policy$);
/** {@link GeneratorService.enforcePolicy()} */
async enforcePolicy(userId: UserId, options: Options): Promise<Options> {
const policy = await firstValueFrom(this.evaluator$(userId));
const evaluated = policy.applyPolicy(options);
const sanitized = policy.sanitize(evaluated);
return sanitized;

View File

@ -9,15 +9,21 @@ 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 { DisabledPassphraseGeneratorPolicy } from "./passphrase-generator-policy";
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);
const strategy = new PassphraseGeneratorStrategy(null, null);
const policy = mock<Policy>({
type: PolicyType.DisableSend,
});
@ -26,7 +32,7 @@ describe("Password generation strategy", () => {
});
it("should map to the policy evaluator", () => {
const strategy = new PassphraseGeneratorStrategy(null);
const strategy = new PassphraseGeneratorStrategy(null, null);
const policy = mock<Policy>({
type: PolicyType.PasswordGenerator,
data: {
@ -45,21 +51,32 @@ describe("Password generation strategy", () => {
includeNumber: true,
});
});
it("should map `null` to a default policy evaluator", () => {
const strategy = new PassphraseGeneratorStrategy(null, null);
const evaluator = strategy.evaluator(null);
expect(evaluator).toBeInstanceOf(PassphraseGeneratorOptionsEvaluator);
expect(evaluator.policy).toMatchObject(DisabledPassphraseGeneratorPolicy);
});
});
describe("disk", () => {
describe("durableState", () => {
it("should use password settings key", () => {
const provider = mock<StateProvider>();
const legacy = mock<PasswordGenerationServiceAbstraction>();
const strategy = new PassphraseGeneratorStrategy(legacy);
const strategy = new PassphraseGeneratorStrategy(legacy, provider);
expect(strategy.disk).toBe(PASSPHRASE_SETTINGS);
strategy.durableState(SomeUser);
expect(provider.getUser).toHaveBeenCalledWith(SomeUser, PASSPHRASE_SETTINGS);
});
});
describe("cache_ms", () => {
it("should be a positive non-zero number", () => {
const legacy = mock<PasswordGenerationServiceAbstraction>();
const strategy = new PassphraseGeneratorStrategy(legacy);
const strategy = new PassphraseGeneratorStrategy(legacy, null);
expect(strategy.cache_ms).toBeGreaterThan(0);
});
@ -68,7 +85,7 @@ describe("Password generation strategy", () => {
describe("policy", () => {
it("should use password generator policy", () => {
const legacy = mock<PasswordGenerationServiceAbstraction>();
const strategy = new PassphraseGeneratorStrategy(legacy);
const strategy = new PassphraseGeneratorStrategy(legacy, null);
expect(strategy.policy).toBe(PolicyType.PasswordGenerator);
});
@ -77,7 +94,7 @@ describe("Password generation strategy", () => {
describe("generate()", () => {
it("should call the legacy service with the given options", async () => {
const legacy = mock<PasswordGenerationServiceAbstraction>();
const strategy = new PassphraseGeneratorStrategy(legacy);
const strategy = new PassphraseGeneratorStrategy(legacy, null);
const options = {
type: "passphrase",
minNumberWords: 1,
@ -92,7 +109,7 @@ describe("Password generation strategy", () => {
it("should set the generation type to passphrase", async () => {
const legacy = mock<PasswordGenerationServiceAbstraction>();
const strategy = new PassphraseGeneratorStrategy(legacy);
const strategy = new PassphraseGeneratorStrategy(legacy, null);
await strategy.generate({ type: "foo" } as any);

View File

@ -3,12 +3,17 @@ 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 { PassphraseGenerationOptions } from "./passphrase-generation-options";
import { PassphraseGeneratorOptionsEvaluator } from "./passphrase-generator-options-evaluator";
import { PassphraseGeneratorPolicy } from "./passphrase-generator-policy";
import {
DisabledPassphraseGeneratorPolicy,
PassphraseGeneratorPolicy,
} from "./passphrase-generator-policy";
const ONE_MINUTE = 60 * 1000;
@ -19,11 +24,14 @@ export class PassphraseGeneratorStrategy
/** instantiates the password generator strategy.
* @param legacy generates the passphrase
*/
constructor(private legacy: PasswordGenerationServiceAbstraction) {}
constructor(
private legacy: PasswordGenerationServiceAbstraction,
private stateProvider: StateProvider,
) {}
/** {@link GeneratorStrategy.disk} */
get disk() {
return PASSPHRASE_SETTINGS;
/** {@link GeneratorStrategy.durableState} */
durableState(id: UserId) {
return this.stateProvider.getUser(id, PASSPHRASE_SETTINGS);
}
/** {@link GeneratorStrategy.policy} */
@ -37,6 +45,10 @@ export class PassphraseGeneratorStrategy
/** {@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);

View File

@ -9,18 +9,24 @@ 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 { DisabledPasswordGeneratorPolicy } from "./password-generator-policy";
import {
PasswordGenerationServiceAbstraction,
PasswordGeneratorOptionsEvaluator,
PasswordGeneratorStrategy,
} 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 PasswordGeneratorStrategy(null);
const strategy = new PasswordGeneratorStrategy(null, null);
const policy = mock<Policy>({
type: PolicyType.DisableSend,
});
@ -29,7 +35,7 @@ describe("Password generation strategy", () => {
});
it("should map to the policy evaluator", () => {
const strategy = new PasswordGeneratorStrategy(null);
const strategy = new PasswordGeneratorStrategy(null, null);
const policy = mock<Policy>({
type: PolicyType.PasswordGenerator,
data: {
@ -56,21 +62,32 @@ describe("Password generation strategy", () => {
specialCount: 1,
});
});
it("should map `null` to a default policy evaluator", () => {
const strategy = new PasswordGeneratorStrategy(null, null);
const evaluator = strategy.evaluator(null);
expect(evaluator).toBeInstanceOf(PasswordGeneratorOptionsEvaluator);
expect(evaluator.policy).toMatchObject(DisabledPasswordGeneratorPolicy);
});
});
describe("disk", () => {
describe("durableState", () => {
it("should use password settings key", () => {
const provider = mock<StateProvider>();
const legacy = mock<PasswordGenerationServiceAbstraction>();
const strategy = new PasswordGeneratorStrategy(legacy);
const strategy = new PasswordGeneratorStrategy(legacy, provider);
expect(strategy.disk).toBe(PASSWORD_SETTINGS);
strategy.durableState(SomeUser);
expect(provider.getUser).toHaveBeenCalledWith(SomeUser, PASSWORD_SETTINGS);
});
});
describe("cache_ms", () => {
it("should be a positive non-zero number", () => {
const legacy = mock<PasswordGenerationServiceAbstraction>();
const strategy = new PasswordGeneratorStrategy(legacy);
const strategy = new PasswordGeneratorStrategy(legacy, null);
expect(strategy.cache_ms).toBeGreaterThan(0);
});
@ -79,7 +96,7 @@ describe("Password generation strategy", () => {
describe("policy", () => {
it("should use password generator policy", () => {
const legacy = mock<PasswordGenerationServiceAbstraction>();
const strategy = new PasswordGeneratorStrategy(legacy);
const strategy = new PasswordGeneratorStrategy(legacy, null);
expect(strategy.policy).toBe(PolicyType.PasswordGenerator);
});
@ -88,7 +105,7 @@ describe("Password generation strategy", () => {
describe("generate()", () => {
it("should call the legacy service with the given options", async () => {
const legacy = mock<PasswordGenerationServiceAbstraction>();
const strategy = new PasswordGeneratorStrategy(legacy);
const strategy = new PasswordGeneratorStrategy(legacy, null);
const options = {
type: "password",
minLength: 1,
@ -107,7 +124,7 @@ describe("Password generation strategy", () => {
it("should set the generation type to password", async () => {
const legacy = mock<PasswordGenerationServiceAbstraction>();
const strategy = new PasswordGeneratorStrategy(legacy);
const strategy = new PasswordGeneratorStrategy(legacy, null);
await strategy.generate({ type: "foo" } as any);

View File

@ -3,12 +3,17 @@ 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 { PasswordGenerationOptions } from "./password-generation-options";
import { PasswordGenerationServiceAbstraction } from "./password-generation.service.abstraction";
import { PasswordGeneratorOptionsEvaluator } from "./password-generator-options-evaluator";
import { PasswordGeneratorPolicy } from "./password-generator-policy";
import {
DisabledPasswordGeneratorPolicy,
PasswordGeneratorPolicy,
} from "./password-generator-policy";
const ONE_MINUTE = 60 * 1000;
@ -19,11 +24,14 @@ export class PasswordGeneratorStrategy
/** instantiates the password generator strategy.
* @param legacy generates the password
*/
constructor(private legacy: PasswordGenerationServiceAbstraction) {}
constructor(
private legacy: PasswordGenerationServiceAbstraction,
private stateProvider: StateProvider,
) {}
/** {@link GeneratorStrategy.disk} */
get disk() {
return PASSWORD_SETTINGS;
/** {@link GeneratorStrategy.durableState} */
durableState(id: UserId) {
return this.stateProvider.getUser(id, PASSWORD_SETTINGS);
}
/** {@link GeneratorStrategy.policy} */
@ -37,6 +45,10 @@ export class PasswordGeneratorStrategy
/** {@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);

View File

@ -83,7 +83,16 @@ describe("UserEncryptor", () => {
});
describe("instance", () => {
it("gets a set value", async () => {
it("userId outputs the user input during construction", async () => {
const provider = await fakeStateProvider();
const encryptor = mockEncryptor();
const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor);
expect(state.userId).toEqual(SomeUser);
});
it("state$ gets a set value", async () => {
const provider = await fakeStateProvider();
const encryptor = mockEncryptor();
const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor);
@ -96,6 +105,20 @@ describe("UserEncryptor", () => {
expect(result).toEqual(value);
});
it("combinedState$ gets a set value with the userId", async () => {
const provider = await fakeStateProvider();
const encryptor = mockEncryptor();
const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor);
const value = { foo: true, bar: false };
await state.update(() => value);
await awaitAsync();
const [userId, result] = await firstValueFrom(state.combinedState$);
expect(result).toEqual(value);
expect(userId).toEqual(SomeUser);
});
it("round-trips json-serializable values", async () => {
const provider = await fakeStateProvider();
const encryptor = mockEncryptor();

View File

@ -1,4 +1,4 @@
import { Observable, concatMap, of, zip } from "rxjs";
import { Observable, concatMap, of, zip, map } from "rxjs";
import { Jsonify } from "type-fest";
import { EncString } from "../../../platform/models/domain/enc-string";
@ -9,6 +9,7 @@ import {
SingleUserState,
StateProvider,
StateUpdateOptions,
CombinedState,
} from "../../../platform/state";
import { UserId } from "../../../types/guid";
@ -37,7 +38,9 @@ type ClassifiedFormat<Disclosed> = {
*
* DO NOT USE THIS for synchronized data.
*/
export class SecretState<Plaintext extends object, Disclosed> {
export class SecretState<Plaintext extends object, Disclosed>
implements SingleUserState<Plaintext>
{
// The constructor is private to avoid creating a circular dependency when
// wiring the derived and secret states together.
private constructor(
@ -46,8 +49,23 @@ export class SecretState<Plaintext extends object, Disclosed> {
private readonly plaintext: DerivedState<Plaintext>,
) {
this.state$ = plaintext.state$;
this.combinedState$ = plaintext.state$.pipe(map((state) => [this.encrypted.userId, state]));
}
/** {@link SingleUserState.userId} */
get userId() {
return this.encrypted.userId;
}
/** Observes changes to the decrypted secret state. The observer
* updates after the secret has been recorded to state storage.
* @returns `undefined` when the account is locked.
*/
readonly state$: Observable<Plaintext>;
/** {@link SingleUserState.combinedState$} */
readonly combinedState$: Observable<CombinedState<Plaintext>>;
/** Creates a secret state bound to an account encryptor. The account must be unlocked
* when this method is called.
* @param userId: the user to which the secret state is bound.
@ -106,12 +124,6 @@ export class SecretState<Plaintext extends object, Disclosed> {
return secretState;
}
/** Observes changes to the decrypted secret state. The observer
* updates after the secret has been recorded to state storage.
* @returns `undefined` when the account is locked.
*/
readonly state$: Observable<Plaintext>;
/** Updates the secret stored by this state.
* @param configureState a callback that returns an updated decrypted
* secret state. The callback receives the state's present value as its

View File

@ -4,15 +4,19 @@ 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 { DefaultPolicyEvaluator } from "../default-policy-evaluator";
import { CATCHALL_SETTINGS } from "../key-definitions";
import { CatchallGeneratorStrategy, UsernameGenerationServiceAbstraction } from ".";
const SomeUser = "some user" as UserId;
describe("Email subaddress list generation strategy", () => {
describe("evaluator()", () => {
it("should throw if the policy type is incorrect", () => {
const strategy = new CatchallGeneratorStrategy(null);
const strategy = new CatchallGeneratorStrategy(null, null);
const policy = mock<Policy>({
type: PolicyType.DisableSend,
});
@ -21,7 +25,7 @@ describe("Email subaddress list generation strategy", () => {
});
it("should map to the policy evaluator", () => {
const strategy = new CatchallGeneratorStrategy(null);
const strategy = new CatchallGeneratorStrategy(null, null);
const policy = mock<Policy>({
type: PolicyType.PasswordGenerator,
data: {
@ -34,21 +38,31 @@ describe("Email subaddress list generation strategy", () => {
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);
});
});
describe("disk", () => {
describe("durableState", () => {
it("should use password settings key", () => {
const provider = mock<StateProvider>();
const legacy = mock<UsernameGenerationServiceAbstraction>();
const strategy = new CatchallGeneratorStrategy(legacy);
const strategy = new CatchallGeneratorStrategy(legacy, provider);
expect(strategy.disk).toBe(CATCHALL_SETTINGS);
strategy.durableState(SomeUser);
expect(provider.getUser).toHaveBeenCalledWith(SomeUser, CATCHALL_SETTINGS);
});
});
describe("cache_ms", () => {
it("should be a positive non-zero number", () => {
const legacy = mock<UsernameGenerationServiceAbstraction>();
const strategy = new CatchallGeneratorStrategy(legacy);
const strategy = new CatchallGeneratorStrategy(legacy, null);
expect(strategy.cache_ms).toBeGreaterThan(0);
});
@ -57,7 +71,7 @@ describe("Email subaddress list generation strategy", () => {
describe("policy", () => {
it("should use password generator policy", () => {
const legacy = mock<UsernameGenerationServiceAbstraction>();
const strategy = new CatchallGeneratorStrategy(legacy);
const strategy = new CatchallGeneratorStrategy(legacy, null);
expect(strategy.policy).toBe(PolicyType.PasswordGenerator);
});
@ -66,7 +80,7 @@ describe("Email subaddress list generation strategy", () => {
describe("generate()", () => {
it("should call the legacy service with the given options", async () => {
const legacy = mock<UsernameGenerationServiceAbstraction>();
const strategy = new CatchallGeneratorStrategy(legacy);
const strategy = new CatchallGeneratorStrategy(legacy, null);
const options = {
type: "website-name" as const,
domain: "example.com",

View File

@ -1,5 +1,7 @@
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";
import { DefaultPolicyEvaluator } from "../default-policy-evaluator";
import { CATCHALL_SETTINGS } from "../key-definitions";
@ -17,11 +19,14 @@ export class CatchallGeneratorStrategy
/** Instantiates the generation strategy
* @param usernameService generates a catchall address for a domain
*/
constructor(private usernameService: UsernameGenerationServiceAbstraction) {}
constructor(
private usernameService: UsernameGenerationServiceAbstraction,
private stateProvider: StateProvider,
) {}
/** {@link GeneratorStrategy.disk} */
get disk() {
return CATCHALL_SETTINGS;
/** {@link GeneratorStrategy.durableState} */
durableState(id: UserId) {
return this.stateProvider.getUser(id, CATCHALL_SETTINGS);
}
/** {@link GeneratorStrategy.policy} */
@ -38,6 +43,10 @@ export class CatchallGeneratorStrategy
/** {@link GeneratorStrategy.evaluator} */
evaluator(policy: Policy) {
if (!policy) {
return new DefaultPolicyEvaluator<CatchallGenerationOptions>();
}
if (policy.type !== this.policy) {
const details = `Expected: ${this.policy}. Received: ${policy.type}`;
throw Error("Mismatched policy type. " + details);

View File

@ -4,15 +4,19 @@ 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 { DefaultPolicyEvaluator } from "../default-policy-evaluator";
import { EFF_USERNAME_SETTINGS } from "../key-definitions";
import { EffUsernameGeneratorStrategy, UsernameGenerationServiceAbstraction } from ".";
const SomeUser = "some user" as UserId;
describe("EFF long word list generation strategy", () => {
describe("evaluator()", () => {
it("should throw if the policy type is incorrect", () => {
const strategy = new EffUsernameGeneratorStrategy(null);
const strategy = new EffUsernameGeneratorStrategy(null, null);
const policy = mock<Policy>({
type: PolicyType.DisableSend,
});
@ -21,7 +25,7 @@ describe("EFF long word list generation strategy", () => {
});
it("should map to the policy evaluator", () => {
const strategy = new EffUsernameGeneratorStrategy(null);
const strategy = new EffUsernameGeneratorStrategy(null, null);
const policy = mock<Policy>({
type: PolicyType.PasswordGenerator,
data: {
@ -34,21 +38,31 @@ describe("EFF long word list generation strategy", () => {
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);
});
});
describe("disk", () => {
describe("durableState", () => {
it("should use password settings key", () => {
const provider = mock<StateProvider>();
const legacy = mock<UsernameGenerationServiceAbstraction>();
const strategy = new EffUsernameGeneratorStrategy(legacy);
const strategy = new EffUsernameGeneratorStrategy(legacy, provider);
expect(strategy.disk).toBe(EFF_USERNAME_SETTINGS);
strategy.durableState(SomeUser);
expect(provider.getUser).toHaveBeenCalledWith(SomeUser, EFF_USERNAME_SETTINGS);
});
});
describe("cache_ms", () => {
it("should be a positive non-zero number", () => {
const legacy = mock<UsernameGenerationServiceAbstraction>();
const strategy = new EffUsernameGeneratorStrategy(legacy);
const strategy = new EffUsernameGeneratorStrategy(legacy, null);
expect(strategy.cache_ms).toBeGreaterThan(0);
});
@ -57,7 +71,7 @@ describe("EFF long word list generation strategy", () => {
describe("policy", () => {
it("should use password generator policy", () => {
const legacy = mock<UsernameGenerationServiceAbstraction>();
const strategy = new EffUsernameGeneratorStrategy(legacy);
const strategy = new EffUsernameGeneratorStrategy(legacy, null);
expect(strategy.policy).toBe(PolicyType.PasswordGenerator);
});
@ -66,7 +80,7 @@ describe("EFF long word list generation strategy", () => {
describe("generate()", () => {
it("should call the legacy service with the given options", async () => {
const legacy = mock<UsernameGenerationServiceAbstraction>();
const strategy = new EffUsernameGeneratorStrategy(legacy);
const strategy = new EffUsernameGeneratorStrategy(legacy, null);
const options = {
wordCapitalize: false,
wordIncludeNumber: false,

View File

@ -1,5 +1,7 @@
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";
import { DefaultPolicyEvaluator } from "../default-policy-evaluator";
import { EFF_USERNAME_SETTINGS } from "../key-definitions";
@ -17,11 +19,14 @@ export class EffUsernameGeneratorStrategy
/** Instantiates the generation strategy
* @param usernameService generates a username from EFF word list
*/
constructor(private usernameService: UsernameGenerationServiceAbstraction) {}
constructor(
private usernameService: UsernameGenerationServiceAbstraction,
private stateProvider: StateProvider,
) {}
/** {@link GeneratorStrategy.disk} */
get disk() {
return EFF_USERNAME_SETTINGS;
/** {@link GeneratorStrategy.durableState} */
durableState(id: UserId) {
return this.stateProvider.getUser(id, EFF_USERNAME_SETTINGS);
}
/** {@link GeneratorStrategy.policy} */
@ -38,6 +43,10 @@ export class EffUsernameGeneratorStrategy
/** {@link GeneratorStrategy.evaluator} */
evaluator(policy: Policy) {
if (!policy) {
return new DefaultPolicyEvaluator<EffUsernameGenerationOptions>();
}
if (policy.type !== this.policy) {
const details = `Expected: ${this.policy}. Received: ${policy.type}`;
throw Error("Mismatched policy type. " + details);

View File

@ -4,15 +4,19 @@ 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 { DefaultPolicyEvaluator } from "../default-policy-evaluator";
import { SUBADDRESS_SETTINGS } from "../key-definitions";
import { SubaddressGeneratorStrategy, UsernameGenerationServiceAbstraction } from ".";
const SomeUser = "some user" as UserId;
describe("Email subaddress list generation strategy", () => {
describe("evaluator()", () => {
it("should throw if the policy type is incorrect", () => {
const strategy = new SubaddressGeneratorStrategy(null);
const strategy = new SubaddressGeneratorStrategy(null, null);
const policy = mock<Policy>({
type: PolicyType.DisableSend,
});
@ -21,7 +25,7 @@ describe("Email subaddress list generation strategy", () => {
});
it("should map to the policy evaluator", () => {
const strategy = new SubaddressGeneratorStrategy(null);
const strategy = new SubaddressGeneratorStrategy(null, null);
const policy = mock<Policy>({
type: PolicyType.PasswordGenerator,
data: {
@ -34,21 +38,31 @@ describe("Email subaddress list generation strategy", () => {
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);
});
});
describe("disk", () => {
describe("durableState", () => {
it("should use password settings key", () => {
const provider = mock<StateProvider>();
const legacy = mock<UsernameGenerationServiceAbstraction>();
const strategy = new SubaddressGeneratorStrategy(legacy);
const strategy = new SubaddressGeneratorStrategy(legacy, provider);
expect(strategy.disk).toBe(SUBADDRESS_SETTINGS);
strategy.durableState(SomeUser);
expect(provider.getUser).toHaveBeenCalledWith(SomeUser, SUBADDRESS_SETTINGS);
});
});
describe("cache_ms", () => {
it("should be a positive non-zero number", () => {
const legacy = mock<UsernameGenerationServiceAbstraction>();
const strategy = new SubaddressGeneratorStrategy(legacy);
const strategy = new SubaddressGeneratorStrategy(legacy, null);
expect(strategy.cache_ms).toBeGreaterThan(0);
});
@ -57,7 +71,7 @@ describe("Email subaddress list generation strategy", () => {
describe("policy", () => {
it("should use password generator policy", () => {
const legacy = mock<UsernameGenerationServiceAbstraction>();
const strategy = new SubaddressGeneratorStrategy(legacy);
const strategy = new SubaddressGeneratorStrategy(legacy, null);
expect(strategy.policy).toBe(PolicyType.PasswordGenerator);
});
@ -66,7 +80,7 @@ describe("Email subaddress list generation strategy", () => {
describe("generate()", () => {
it("should call the legacy service with the given options", async () => {
const legacy = mock<UsernameGenerationServiceAbstraction>();
const strategy = new SubaddressGeneratorStrategy(legacy);
const strategy = new SubaddressGeneratorStrategy(legacy, null);
const options = {
type: "website-name" as const,
email: "someone@example.com",

View File

@ -1,5 +1,7 @@
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";
import { DefaultPolicyEvaluator } from "../default-policy-evaluator";
import { SUBADDRESS_SETTINGS } from "../key-definitions";
@ -17,11 +19,14 @@ export class SubaddressGeneratorStrategy
/** Instantiates the generation strategy
* @param usernameService generates an email subaddress from an email address
*/
constructor(private usernameService: UsernameGenerationServiceAbstraction) {}
constructor(
private usernameService: UsernameGenerationServiceAbstraction,
private stateProvider: StateProvider,
) {}
/** {@link GeneratorStrategy.disk} */
get disk() {
return SUBADDRESS_SETTINGS;
/** {@link GeneratorStrategy.durableState} */
durableState(id: UserId) {
return this.stateProvider.getUser(id, SUBADDRESS_SETTINGS);
}
/** {@link GeneratorStrategy.policy} */
@ -38,6 +43,10 @@ export class SubaddressGeneratorStrategy
/** {@link GeneratorStrategy.evaluator} */
evaluator(policy: Policy) {
if (!policy) {
return new DefaultPolicyEvaluator<SubaddressGenerationOptions>();
}
if (policy.type !== this.policy) {
const details = `Expected: ${this.policy}. Received: ${policy.type}`;
throw Error("Mismatched policy type. " + details);