From 8c4b8d71ea50d63529f08f19d96a0756a1a280d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Wed, 28 Aug 2024 09:26:17 -0400 Subject: [PATCH] [PM-5609] passphrase settings component & services (#10535) --- apps/browser/src/popup/app-routing.module.ts | 7 +- .../credential-generator.component.html | 1 + .../credential-generator.component.ts | 11 + jest.config.js | 1 + .../src/tools/generator/generator-swap.ts | 32 ++ .../tools/state/user-state-subject.spec.ts | 30 +- .../src/tools/state/user-state-subject.ts | 17 +- libs/common/src/tools/types.ts | 48 +++ .../generator/components/src/dependencies.ts | 48 +++ libs/tools/generator/components/src/index.ts | 1 + .../src/passphrase-settings.component.html | 38 ++ .../src/passphrase-settings.component.ts | 139 +++++++ libs/tools/generator/components/src/util.ts | 68 ++++ .../disabled-passphrase-generator-policy.ts | 8 - .../disabled-password-generator-policy.ts | 12 - .../generator/core/src/data/generators.ts | 31 ++ libs/tools/generator/core/src/data/index.ts | 3 +- .../tools/generator/core/src/data/policies.ts | 34 +- libs/tools/generator/core/src/index.ts | 7 +- ...phrase-generator-options-evaluator.spec.ts | 46 +-- .../passphrase-generator-options-evaluator.ts | 6 +- .../passphrase-least-privilege.spec.ts | 14 +- ...ssword-generator-options-evaluator.spec.ts | 104 ++--- .../policies/password-least-privilege.spec.ts | 14 +- libs/tools/generator/core/src/rx.ts | 13 + .../credential-generator.service.spec.ts | 377 ++++++++++++++++++ .../services/credential-generator.service.ts | 128 ++++++ .../generator/core/src/services/index.ts | 1 + .../passphrase-generator-strategy.spec.ts | 4 +- .../password-generator-strategy.spec.ts | 4 +- .../credential-generator-configuration.ts | 19 + libs/tools/generator/core/src/types/index.ts | 1 + .../core/src/types/policy-configuration.ts | 17 +- ...legacy-password-generation.service.spec.ts | 7 +- 34 files changed, 1147 insertions(+), 144 deletions(-) create mode 100644 apps/browser/src/tools/popup/generator/credential-generator.component.html create mode 100644 apps/browser/src/tools/popup/generator/credential-generator.component.ts create mode 100644 libs/angular/src/tools/generator/generator-swap.ts create mode 100644 libs/common/src/tools/types.ts create mode 100644 libs/tools/generator/components/src/dependencies.ts create mode 100644 libs/tools/generator/components/src/passphrase-settings.component.html create mode 100644 libs/tools/generator/components/src/passphrase-settings.component.ts create mode 100644 libs/tools/generator/components/src/util.ts delete mode 100644 libs/tools/generator/core/src/data/disabled-passphrase-generator-policy.ts delete mode 100644 libs/tools/generator/core/src/data/disabled-password-generator-policy.ts create mode 100644 libs/tools/generator/core/src/data/generators.ts create mode 100644 libs/tools/generator/core/src/services/credential-generator.service.spec.ts create mode 100644 libs/tools/generator/core/src/services/credential-generator.service.ts create mode 100644 libs/tools/generator/core/src/types/credential-generator-configuration.ts diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 79a2df30e6..455909336b 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -9,6 +9,7 @@ import { unauthGuardFn, } from "@bitwarden/angular/auth/guards"; import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard"; +import { generatorSwap } from "@bitwarden/angular/tools/generator/generator-swap"; import { extensionRefreshSwap } from "@bitwarden/angular/utils/extension-refresh-swap"; import { AnonLayoutWrapperComponent, @@ -51,6 +52,7 @@ import { PremiumV2Component } from "../billing/popup/settings/premium-v2.compone import { PremiumComponent } from "../billing/popup/settings/premium.component"; import BrowserPopupUtils from "../platform/popup/browser-popup-utils"; import { popupRouterCacheGuard } from "../platform/popup/view-cache/popup-router-cache.service"; +import { CredentialGeneratorComponent } from "../tools/popup/generator/credential-generator.component"; import { GeneratorComponent } from "../tools/popup/generator/generator.component"; import { PasswordGeneratorHistoryComponent } from "../tools/popup/generator/password-generator-history.component"; import { SendAddEditComponent } from "../tools/popup/send/send-add-edit.component"; @@ -472,12 +474,11 @@ const routes: Routes = [ canDeactivate: [clearVaultStateGuard], data: { state: "tabs_vault" }, }), - { + ...generatorSwap(GeneratorComponent, CredentialGeneratorComponent, { path: "generator", - component: GeneratorComponent, canActivate: [authGuard], data: { state: "tabs_generator" }, - }, + }), ...extensionRefreshSwap(SettingsComponent, SettingsV2Component, { path: "settings", canActivate: [authGuard], diff --git a/apps/browser/src/tools/popup/generator/credential-generator.component.html b/apps/browser/src/tools/popup/generator/credential-generator.component.html new file mode 100644 index 0000000000..1bb626e3e8 --- /dev/null +++ b/apps/browser/src/tools/popup/generator/credential-generator.component.html @@ -0,0 +1 @@ + diff --git a/apps/browser/src/tools/popup/generator/credential-generator.component.ts b/apps/browser/src/tools/popup/generator/credential-generator.component.ts new file mode 100644 index 0000000000..91a17ab2d3 --- /dev/null +++ b/apps/browser/src/tools/popup/generator/credential-generator.component.ts @@ -0,0 +1,11 @@ +import { Component } from "@angular/core"; + +import { PassphraseSettingsComponent } from "@bitwarden/generator-components"; + +@Component({ + standalone: true, + selector: "credential-generator", + templateUrl: "credential-generator.component.html", + imports: [PassphraseSettingsComponent], +}) +export class CredentialGeneratorComponent {} diff --git a/jest.config.js b/jest.config.js index 6526237261..829adf1bf7 100644 --- a/jest.config.js +++ b/jest.config.js @@ -32,6 +32,7 @@ module.exports = { "/libs/components/jest.config.js", "/libs/tools/export/vault-export/vault-export-core/jest.config.js", "/libs/tools/generator/core/jest.config.js", + "/libs/tools/generator/components/jest.config.js", "/libs/tools/generator/extensions/history/jest.config.js", "/libs/tools/generator/extensions/legacy/jest.config.js", "/libs/tools/generator/extensions/navigation/jest.config.js", diff --git a/libs/angular/src/tools/generator/generator-swap.ts b/libs/angular/src/tools/generator/generator-swap.ts new file mode 100644 index 0000000000..16fafc6711 --- /dev/null +++ b/libs/angular/src/tools/generator/generator-swap.ts @@ -0,0 +1,32 @@ +import { Type, inject } from "@angular/core"; +import { Route, Routes } from "@angular/router"; + +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; + +import { componentRouteSwap } from "../../utils/component-route-swap"; + +/** + * Helper function to swap between two components based on the GeneratorToolsModernization feature flag. + * @param defaultComponent - The current non-refreshed component to render. + * @param refreshedComponent - The new refreshed component to render. + * @param options - The shared route options to apply to the default component, and to the alt component if altOptions is not provided. + * @param altOptions - The alt route options to apply to the alt component. + */ +export function generatorSwap( + defaultComponent: Type, + refreshedComponent: Type, + options: Route, + altOptions?: Route, +): Routes { + return componentRouteSwap( + defaultComponent, + refreshedComponent, + async () => { + const configService = inject(ConfigService); + return configService.getFeatureFlag(FeatureFlag.GeneratorToolsModernization); + }, + options, + altOptions, + ); +} diff --git a/libs/common/src/tools/state/user-state-subject.spec.ts b/libs/common/src/tools/state/user-state-subject.spec.ts index 9d019abb0b..a441505b35 100644 --- a/libs/common/src/tools/state/user-state-subject.spec.ts +++ b/libs/common/src/tools/state/user-state-subject.spec.ts @@ -257,8 +257,8 @@ describe("UserStateSubject", () => { let actual: TestType = null; subject.subscribe({ - error: (value) => { - actual = value; + error: (value: unknown) => { + actual = value as any; }, }); subject.error(expected); @@ -275,8 +275,8 @@ describe("UserStateSubject", () => { let actual: TestType = null; subject.subscribe({ - error: (value) => { - actual = value; + error: (value: unknown) => { + actual = value as any; }, }); subject.error("expectedError"); @@ -415,8 +415,8 @@ describe("UserStateSubject", () => { let error = false; subject.subscribe({ - error: (e) => { - error = e; + error: (e: unknown) => { + error = e as any; }, }); singleUserId$.next(errorUserId); @@ -434,8 +434,8 @@ describe("UserStateSubject", () => { let actual = false; subject.subscribe({ - error: (e) => { - actual = e; + error: (e: unknown) => { + actual = e as any; }, }); singleUserId$.error(expected); @@ -454,8 +454,8 @@ describe("UserStateSubject", () => { let actual = false; subject.subscribe({ - error: (e) => { - actual = e; + error: (e: unknown) => { + actual = e as any; }, }); when$.error(expected); @@ -464,4 +464,14 @@ describe("UserStateSubject", () => { expect(actual).toEqual(expected); }); }); + + describe("userId", () => { + it("returns the userId to which the subject is bound", () => { + const state = new FakeSingleUserState(SomeUser, { foo: "init" }); + const singleUserId$ = new Subject(); + const subject = new UserStateSubject(state, { singleUserId$ }); + + expect(subject.userId).toEqual(SomeUser); + }); + }); }); diff --git a/libs/common/src/tools/state/user-state-subject.ts b/libs/common/src/tools/state/user-state-subject.ts index 290103664b..659eb94947 100644 --- a/libs/common/src/tools/state/user-state-subject.ts +++ b/libs/common/src/tools/state/user-state-subject.ts @@ -15,6 +15,8 @@ import { ignoreElements, endWith, startWith, + Observable, + Subscription, } from "rxjs"; import { Simplify } from "type-fest"; @@ -59,7 +61,10 @@ export type UserStateSubjectDependencies = Simplify< * @template State the state stored by the subject * @template Dependencies use-specific dependencies provided by the user. */ -export class UserStateSubject implements SubjectLike { +export class UserStateSubject + extends Observable + implements SubjectLike +{ /** * Instantiates the user state subject * @param state the backing store of the subject @@ -76,6 +81,8 @@ export class UserStateSubject implements SubjectLike private state: SingleUserState, private dependencies: UserStateSubjectDependencies, ) { + super(); + // normalize dependencies const when$ = (this.dependencies.when$ ?? new BehaviorSubject(true)).pipe( distinctUntilChanged(), @@ -114,6 +121,12 @@ export class UserStateSubject implements SubjectLike }); } + /** The userId to which the subject is bound. + */ + get userId() { + return this.state.userId; + } + next(value: State) { this.input?.next(value); } @@ -130,7 +143,7 @@ export class UserStateSubject implements SubjectLike * @param observer listening for events * @returns the subscription */ - subscribe(observer: Partial> | ((value: State) => void)): Unsubscribable { + subscribe(observer?: Partial> | ((value: State) => void) | null): Subscription { return this.output.subscribe(observer); } diff --git a/libs/common/src/tools/types.ts b/libs/common/src/tools/types.ts new file mode 100644 index 0000000000..0c2f2832ea --- /dev/null +++ b/libs/common/src/tools/types.ts @@ -0,0 +1,48 @@ +import { Simplify } from "type-fest"; + +/** Constraints that are shared by all primitive field types */ +type PrimitiveConstraint = { + /** presence indicates the field is required */ + required?: true; +}; + +/** Constraints that are shared by string fields */ +type StringConstraints = { + /** minimum string length. When absent, min length is 0. */ + minLength?: number; + + /** maximum string length. When absent, max length is unbounded. */ + maxLength?: number; +}; + +/** Constraints that are shared by number fields */ +type NumberConstraints = { + /** minimum number value. When absent, min value is unbounded. */ + min?: number; + + /** maximum number value. When absent, min value is unbounded. */ + max?: number; + + /** presence indicates the field only accepts integer values */ + integer?: true; + + /** requires the number be a multiple of the step value */ + step?: number; +}; + +/** Utility type that transforms keys of T into their supported + * validators. + */ +export type Constraints = { + [Key in keyof T]: Simplify< + PrimitiveConstraint & + (T[Key] extends string + ? StringConstraints + : T[Key] extends number + ? NumberConstraints + : never) + >; +}; + +/** utility type for methods that evaluate constraints generically. */ +export type AnyConstraint = PrimitiveConstraint & StringConstraints & NumberConstraints; diff --git a/libs/tools/generator/components/src/dependencies.ts b/libs/tools/generator/components/src/dependencies.ts new file mode 100644 index 0000000000..927c3811c8 --- /dev/null +++ b/libs/tools/generator/components/src/dependencies.ts @@ -0,0 +1,48 @@ +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; +import { ReactiveFormsModule } from "@angular/forms"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; +import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { StateProvider } from "@bitwarden/common/platform/state"; +import { + CardComponent, + CheckboxModule, + ColorPasswordModule, + FormFieldModule, + InputModule, + SectionComponent, + SectionHeaderComponent, +} from "@bitwarden/components"; +import { CredentialGeneratorService } from "@bitwarden/generator-core"; + +/** Shared module containing generator component dependencies */ +@NgModule({ + imports: [SectionComponent, SectionHeaderComponent, CardComponent], + exports: [ + JslibModule, + JslibServicesModule, + FormFieldModule, + CommonModule, + ReactiveFormsModule, + ColorPasswordModule, + InputModule, + CheckboxModule, + SectionComponent, + SectionHeaderComponent, + CardComponent, + ], + providers: [ + safeProvider({ + provide: CredentialGeneratorService, + useClass: CredentialGeneratorService, + deps: [StateProvider, PolicyService], + }), + ], + declarations: [], +}) +export class DependenciesModule { + constructor() {} +} diff --git a/libs/tools/generator/components/src/index.ts b/libs/tools/generator/components/src/index.ts index e69de29bb2..ae631f7137 100644 --- a/libs/tools/generator/components/src/index.ts +++ b/libs/tools/generator/components/src/index.ts @@ -0,0 +1 @@ +export { PassphraseSettingsComponent } from "./passphrase-settings.component"; diff --git a/libs/tools/generator/components/src/passphrase-settings.component.html b/libs/tools/generator/components/src/passphrase-settings.component.html new file mode 100644 index 0000000000..c19c03943b --- /dev/null +++ b/libs/tools/generator/components/src/passphrase-settings.component.html @@ -0,0 +1,38 @@ + + +
{{ "options" | i18n }}
+
+
+
+ + + {{ "numWords" | i18n }} + + + +
+
+ + + {{ "wordSeparator" | i18n }} + + + + + {{ "capitalize" | i18n }} + + + + {{ "includeNumber" | i18n }} + + +
+
+
diff --git a/libs/tools/generator/components/src/passphrase-settings.component.ts b/libs/tools/generator/components/src/passphrase-settings.component.ts new file mode 100644 index 0000000000..f55cc7ba57 --- /dev/null +++ b/libs/tools/generator/components/src/passphrase-settings.component.ts @@ -0,0 +1,139 @@ +import { OnInit, Input, Output, EventEmitter, Component, OnDestroy } from "@angular/core"; +import { FormBuilder } from "@angular/forms"; +import { BehaviorSubject, skip, takeUntil, Subject } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { + Generators, + CredentialGeneratorService, + PassphraseGenerationOptions, +} from "@bitwarden/generator-core"; + +import { DependenciesModule } from "./dependencies"; +import { completeOnAccountSwitch, toValidators } from "./util"; + +const Controls = Object.freeze({ + numWords: "numWords", + includeNumber: "includeNumber", + capitalize: "capitalize", + wordSeparator: "wordSeparator", +}); + +/** Options group for passphrases */ +@Component({ + standalone: true, + selector: "bit-passphrase-settings", + templateUrl: "passphrase-settings.component.html", + imports: [DependenciesModule], +}) +export class PassphraseSettingsComponent implements OnInit, OnDestroy { + /** Instantiates the component + * @param accountService queries user availability + * @param generatorService settings and policy logic + * @param formBuilder reactive form controls + */ + constructor( + private formBuilder: FormBuilder, + private generatorService: CredentialGeneratorService, + private accountService: AccountService, + ) {} + + /** Binds the passphrase component to a specific user's settings. + * When this input is not provided, the form binds to the active + * user + */ + @Input() + userId: UserId | null; + + /** When `true`, an options header is displayed by the component. Otherwise, the header is hidden. */ + @Input() + showHeader: boolean = true; + + /** Emits settings updates and completes if the settings become unavailable. + * @remarks this does not emit the initial settings. If you would like + * to receive live settings updates including the initial update, + * use `CredentialGeneratorService.settings$(...)` instead. + */ + @Output() + readonly onUpdated = new EventEmitter(); + + protected settings = this.formBuilder.group({ + [Controls.numWords]: [Generators.Passphrase.settings.initial.numWords], + [Controls.wordSeparator]: [Generators.Passphrase.settings.initial.wordSeparator], + [Controls.capitalize]: [Generators.Passphrase.settings.initial.capitalize], + [Controls.includeNumber]: [Generators.Passphrase.settings.initial.includeNumber], + }); + + async ngOnInit() { + const singleUserId$ = this.singleUserId$(); + const settings = await this.generatorService.settings(Generators.Passphrase, { singleUserId$ }); + + // skips reactive event emissions to break a subscription cycle + settings.pipe(takeUntil(this.destroyed$)).subscribe((s) => { + this.settings.patchValue(s, { emitEvent: false }); + }); + + // the first emission is the current value; subsequent emissions are updates + settings.pipe(skip(1), takeUntil(this.destroyed$)).subscribe(this.onUpdated); + + // dynamic policy enforcement + this.generatorService + .policy$(Generators.Passphrase, { userId$: singleUserId$ }) + .pipe(takeUntil(this.destroyed$)) + .subscribe((policy) => { + this.settings + .get(Controls.numWords) + .setValidators(toValidators(Controls.numWords, Generators.Passphrase, policy)); + + this.settings + .get(Controls.wordSeparator) + .setValidators(toValidators(Controls.wordSeparator, Generators.Passphrase, policy)); + + // forward word boundaries to the template (can't do it through the rx form) + // FIXME: move the boundary logic fully into the policy evaluator + this.minNumWords = + policy.numWords?.min ?? Generators.Passphrase.settings.constraints.numWords.min; + this.maxNumWords = + policy.numWords?.max ?? Generators.Passphrase.settings.constraints.numWords.max; + + this.toggleEnabled(Controls.capitalize, !policy.policy.capitalize); + this.toggleEnabled(Controls.includeNumber, !policy.policy.includeNumber); + }); + + // now that outputs are set up, connect inputs + this.settings.valueChanges.pipe(takeUntil(this.destroyed$)).subscribe(settings); + } + + /** attribute binding for numWords[min] */ + protected minNumWords: number; + + /** attribute binding for numWords[max] */ + protected maxNumWords: number; + + private toggleEnabled(setting: keyof typeof Controls, enabled: boolean) { + if (enabled) { + this.settings.get(setting).enable(); + } else { + this.settings.get(setting).disable(); + } + } + + private singleUserId$() { + // FIXME: this branch should probably scan for the user and make sure + // the account is unlocked + if (this.userId) { + return new BehaviorSubject(this.userId as UserId).asObservable(); + } + + return this.accountService.activeAccount$.pipe( + completeOnAccountSwitch(), + takeUntil(this.destroyed$), + ); + } + + private readonly destroyed$ = new Subject(); + ngOnDestroy(): void { + this.destroyed$.complete(); + } +} diff --git a/libs/tools/generator/components/src/util.ts b/libs/tools/generator/components/src/util.ts new file mode 100644 index 0000000000..07d6277c0c --- /dev/null +++ b/libs/tools/generator/components/src/util.ts @@ -0,0 +1,68 @@ +import { ValidatorFn, Validators } from "@angular/forms"; +import { map, pairwise, pipe, skipWhile, startWith, takeWhile } from "rxjs"; + +import { AnyConstraint, Constraints } from "@bitwarden/common/tools/types"; +import { UserId } from "@bitwarden/common/types/guid"; +import { CredentialGeneratorConfiguration } from "@bitwarden/generator-core"; + +export function completeOnAccountSwitch() { + return pipe( + map(({ id }: { id: UserId | null }) => id), + skipWhile((id) => !id), + startWith(null as UserId), + pairwise(), + takeWhile(([prev, next]) => (prev ?? next) === next), + map(([_, id]) => id), + ); +} + +export function toValidators( + target: keyof Settings, + configuration: CredentialGeneratorConfiguration, + policy?: Constraints, +) { + const validators: Array = []; + + // widen the types to avoid typecheck issues + const config: AnyConstraint = configuration.settings.constraints[target]; + const runtime: AnyConstraint = policy[target]; + + const required = getConstraint("required", config, runtime) ?? false; + if (required) { + validators.push(Validators.required); + } + + const maxLength = getConstraint("maxLength", config, runtime); + if (maxLength !== undefined) { + validators.push(Validators.maxLength(maxLength)); + } + + const minLength = getConstraint("minLength", config, runtime); + if (minLength !== undefined) { + validators.push(Validators.minLength(config.minLength)); + } + + const min = getConstraint("min", config, runtime); + if (min !== undefined) { + validators.push(Validators.min(min)); + } + + const max = getConstraint("max", config, runtime); + if (max === undefined) { + validators.push(Validators.max(max)); + } + + return validators; +} + +function getConstraint( + key: Key, + config: AnyConstraint, + policy?: AnyConstraint, +) { + if (policy && key in policy) { + return policy[key] ?? config[key]; + } else if (key in config) { + return config[key]; + } +} diff --git a/libs/tools/generator/core/src/data/disabled-passphrase-generator-policy.ts b/libs/tools/generator/core/src/data/disabled-passphrase-generator-policy.ts deleted file mode 100644 index 2eb77a2cd4..0000000000 --- a/libs/tools/generator/core/src/data/disabled-passphrase-generator-policy.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { PassphraseGeneratorPolicy } from "../types"; - -/** The default options for password generation policy. */ -export const DisabledPassphraseGeneratorPolicy: PassphraseGeneratorPolicy = Object.freeze({ - minNumberWords: 0, - capitalize: false, - includeNumber: false, -}); diff --git a/libs/tools/generator/core/src/data/disabled-password-generator-policy.ts b/libs/tools/generator/core/src/data/disabled-password-generator-policy.ts deleted file mode 100644 index 4fc921975c..0000000000 --- a/libs/tools/generator/core/src/data/disabled-password-generator-policy.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { PasswordGeneratorPolicy } from "../types"; - -/** The default options for password generation policy. */ -export const DisabledPasswordGeneratorPolicy: PasswordGeneratorPolicy = Object.freeze({ - minLength: 0, - useUppercase: false, - useLowercase: false, - useNumbers: false, - numberCount: 0, - useSpecial: false, - specialCount: 0, -}); diff --git a/libs/tools/generator/core/src/data/generators.ts b/libs/tools/generator/core/src/data/generators.ts new file mode 100644 index 0000000000..dadba41f42 --- /dev/null +++ b/libs/tools/generator/core/src/data/generators.ts @@ -0,0 +1,31 @@ +import { PASSPHRASE_SETTINGS } from "../strategies/storage"; +import { PassphraseGenerationOptions, PassphraseGeneratorPolicy } from "../types"; +import { CredentialGeneratorConfiguration } from "../types/credential-generator-configuration"; + +import { DefaultPassphraseBoundaries } from "./default-passphrase-boundaries"; +import { DefaultPassphraseGenerationOptions } from "./default-passphrase-generation-options"; +import { Policies } from "./policies"; + +const PASSPHRASE = Object.freeze({ + settings: { + initial: DefaultPassphraseGenerationOptions, + constraints: { + numWords: { + min: DefaultPassphraseBoundaries.numWords.min, + max: DefaultPassphraseBoundaries.numWords.max, + }, + wordSeparator: { maxLength: 1 }, + }, + account: PASSPHRASE_SETTINGS, + }, + policy: Policies.Passphrase, +} satisfies CredentialGeneratorConfiguration< + PassphraseGenerationOptions, + PassphraseGeneratorPolicy +>); + +/** Generator configurations */ +export const Generators = Object.freeze({ + /** Passphrase generator configuration */ + Passphrase: PASSPHRASE, +}); diff --git a/libs/tools/generator/core/src/data/index.ts b/libs/tools/generator/core/src/data/index.ts index b4aa4fe3b9..eaaac757ff 100644 --- a/libs/tools/generator/core/src/data/index.ts +++ b/libs/tools/generator/core/src/data/index.ts @@ -1,3 +1,4 @@ +export * from "./generators"; export * from "./default-addy-io-options"; export * from "./default-catchall-options"; export * from "./default-duck-duck-go-options"; @@ -11,8 +12,6 @@ export * from "./default-passphrase-generation-options"; export * from "./default-password-generation-options"; export * from "./default-subaddress-generator-options"; export * from "./default-simple-login-options"; -export * from "./disabled-passphrase-generator-policy"; -export * from "./disabled-password-generator-policy"; export * from "./forwarders"; export * from "./integrations"; export * from "./policies"; diff --git a/libs/tools/generator/core/src/data/policies.ts b/libs/tools/generator/core/src/data/policies.ts index e7271e5616..7df1f60360 100644 --- a/libs/tools/generator/core/src/data/policies.ts +++ b/libs/tools/generator/core/src/data/policies.ts @@ -1,23 +1,45 @@ -import { DisabledPassphraseGeneratorPolicy, DisabledPasswordGeneratorPolicy } from "../data"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; + import { passphraseLeastPrivilege, passwordLeastPrivilege, PassphraseGeneratorOptionsEvaluator, PasswordGeneratorOptionsEvaluator, } from "../policies"; -import { PassphraseGeneratorPolicy, PasswordGeneratorPolicy, PolicyConfiguration } from "../types"; +import { + PassphraseGenerationOptions, + PassphraseGeneratorPolicy, + PasswordGenerationOptions, + PasswordGeneratorPolicy, + PolicyConfiguration, +} from "../types"; const PASSPHRASE = Object.freeze({ - disabledValue: DisabledPassphraseGeneratorPolicy, + type: PolicyType.PasswordGenerator, + disabledValue: Object.freeze({ + minNumberWords: 0, + capitalize: false, + includeNumber: false, + }), combine: passphraseLeastPrivilege, createEvaluator: (policy) => new PassphraseGeneratorOptionsEvaluator(policy), -} as PolicyConfiguration); + createEvaluatorV2: (policy) => new PassphraseGeneratorOptionsEvaluator(policy), +} as PolicyConfiguration); const PASSWORD = Object.freeze({ - disabledValue: DisabledPasswordGeneratorPolicy, + type: PolicyType.PasswordGenerator, + disabledValue: Object.freeze({ + minLength: 0, + useUppercase: false, + useLowercase: false, + useNumbers: false, + numberCount: 0, + useSpecial: false, + specialCount: 0, + }), combine: passwordLeastPrivilege, createEvaluator: (policy) => new PasswordGeneratorOptionsEvaluator(policy), -} as PolicyConfiguration); +} as PolicyConfiguration); /** Policy configurations */ export const Policies = Object.freeze({ diff --git a/libs/tools/generator/core/src/index.ts b/libs/tools/generator/core/src/index.ts index 330bb64f59..494d034b67 100644 --- a/libs/tools/generator/core/src/index.ts +++ b/libs/tools/generator/core/src/index.ts @@ -1,10 +1,15 @@ +// The root module interface has API stability guarantees export * from "./abstractions"; export * from "./data"; export { createRandomizer } from "./factories"; +export * from "./types"; +export { CredentialGeneratorService } from "./services"; + +// These internal interfacess are exposed for use by other generator modules +// They are unstable and may change arbitrarily export * as engine from "./engine"; export * as integration from "./integration"; export * as policies from "./policies"; export * as rx from "./rx"; export * as services from "./services"; export * as strategies from "./strategies"; -export * from "./types"; diff --git a/libs/tools/generator/core/src/policies/passphrase-generator-options-evaluator.spec.ts b/libs/tools/generator/core/src/policies/passphrase-generator-options-evaluator.spec.ts index 3a57df70df..811c4aa822 100644 --- a/libs/tools/generator/core/src/policies/passphrase-generator-options-evaluator.spec.ts +++ b/libs/tools/generator/core/src/policies/passphrase-generator-options-evaluator.spec.ts @@ -1,4 +1,4 @@ -import { DisabledPassphraseGeneratorPolicy, DefaultPassphraseBoundaries } from "../data"; +import { Policies, DefaultPassphraseBoundaries } from "../data"; import { PassphraseGenerationOptions } from "../types"; import { PassphraseGeneratorOptionsEvaluator } from "./passphrase-generator-options-evaluator"; @@ -6,7 +6,7 @@ import { PassphraseGeneratorOptionsEvaluator } from "./passphrase-generator-opti describe("Password generator options builder", () => { describe("constructor()", () => { it("should set the policy object to a copy of the input policy", () => { - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + const policy = Object.assign({}, Policies.Passphrase.disabledValue); policy.minNumberWords = 10; // arbitrary change for deep equality check const builder = new PassphraseGeneratorOptionsEvaluator(policy); @@ -16,7 +16,7 @@ describe("Password generator options builder", () => { }); it("should set default boundaries when a default policy is used", () => { - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + const policy = Object.assign({}, Policies.Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); expect(builder.numWords).toEqual(DefaultPassphraseBoundaries.numWords); @@ -25,7 +25,7 @@ describe("Password generator options builder", () => { it.each([1, 2])( "should use the default word boundaries when they are greater than `policy.minNumberWords` (= %i)", (minNumberWords) => { - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + const policy = Object.assign({}, Policies.Passphrase.disabledValue); policy.minNumberWords = minNumberWords; const builder = new PassphraseGeneratorOptionsEvaluator(policy); @@ -37,7 +37,7 @@ describe("Password generator options builder", () => { it.each([8, 12, 18])( "should use `policy.minNumberWords` (= %i) when it is greater than the default minimum words", (minNumberWords) => { - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + const policy = Object.assign({}, Policies.Passphrase.disabledValue); policy.minNumberWords = minNumberWords; const builder = new PassphraseGeneratorOptionsEvaluator(policy); @@ -50,7 +50,7 @@ describe("Password generator options builder", () => { it.each([150, 300, 9000])( "should use `policy.minNumberWords` (= %i) when it is greater than the default boundaries", (minNumberWords) => { - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + const policy = Object.assign({}, Policies.Passphrase.disabledValue); policy.minNumberWords = minNumberWords; const builder = new PassphraseGeneratorOptionsEvaluator(policy); @@ -63,14 +63,14 @@ describe("Password generator options builder", () => { describe("policyInEffect", () => { it("should return false when the policy has no effect", () => { - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + const policy = Object.assign({}, Policies.Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); expect(builder.policyInEffect).toEqual(false); }); it("should return true when the policy has a numWords greater than the default boundary", () => { - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + const policy = Object.assign({}, Policies.Passphrase.disabledValue); policy.minNumberWords = DefaultPassphraseBoundaries.numWords.min + 1; const builder = new PassphraseGeneratorOptionsEvaluator(policy); @@ -78,7 +78,7 @@ describe("Password generator options builder", () => { }); it("should return true when the policy has capitalize enabled", () => { - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + const policy = Object.assign({}, Policies.Passphrase.disabledValue); policy.capitalize = true; const builder = new PassphraseGeneratorOptionsEvaluator(policy); @@ -86,7 +86,7 @@ describe("Password generator options builder", () => { }); it("should return true when the policy has includeNumber enabled", () => { - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + const policy = Object.assign({}, Policies.Passphrase.disabledValue); policy.includeNumber = true; const builder = new PassphraseGeneratorOptionsEvaluator(policy); @@ -98,7 +98,7 @@ describe("Password generator options builder", () => { // All tests should freeze the options to ensure they are not modified it("should set `capitalize` to `false` when the policy does not override it", () => { - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + const policy = Object.assign({}, Policies.Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({}); @@ -108,7 +108,7 @@ describe("Password generator options builder", () => { }); it("should set `capitalize` to `true` when the policy overrides it", () => { - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + const policy = Object.assign({}, Policies.Passphrase.disabledValue); policy.capitalize = true; const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({ capitalize: false }); @@ -119,7 +119,7 @@ describe("Password generator options builder", () => { }); it("should set `includeNumber` to false when the policy does not override it", () => { - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + const policy = Object.assign({}, Policies.Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({}); @@ -129,7 +129,7 @@ describe("Password generator options builder", () => { }); it("should set `includeNumber` to true when the policy overrides it", () => { - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + const policy = Object.assign({}, Policies.Passphrase.disabledValue); policy.includeNumber = true; const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({ includeNumber: false }); @@ -140,7 +140,7 @@ describe("Password generator options builder", () => { }); it("should set `numWords` to the minimum value when it isn't supplied", () => { - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + const policy = Object.assign({}, Policies.Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({}); @@ -154,7 +154,7 @@ describe("Password generator options builder", () => { (numWords) => { expect(numWords).toBeLessThan(DefaultPassphraseBoundaries.numWords.min); - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + const policy = Object.assign({}, Policies.Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({ numWords }); @@ -170,7 +170,7 @@ describe("Password generator options builder", () => { expect(numWords).toBeGreaterThanOrEqual(DefaultPassphraseBoundaries.numWords.min); expect(numWords).toBeLessThanOrEqual(DefaultPassphraseBoundaries.numWords.max); - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + const policy = Object.assign({}, Policies.Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({ numWords }); @@ -185,7 +185,7 @@ describe("Password generator options builder", () => { (numWords) => { expect(numWords).toBeGreaterThan(DefaultPassphraseBoundaries.numWords.max); - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + const policy = Object.assign({}, Policies.Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({ numWords }); @@ -196,7 +196,7 @@ describe("Password generator options builder", () => { ); it("should preserve unknown properties", () => { - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + const policy = Object.assign({}, Policies.Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({ unknown: "property", @@ -214,7 +214,7 @@ describe("Password generator options builder", () => { // All tests should freeze the options to ensure they are not modified it("should return the input options without altering them", () => { - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + const policy = Object.assign({}, Policies.Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({ wordSeparator: "%" }); @@ -224,7 +224,7 @@ describe("Password generator options builder", () => { }); it("should set `wordSeparator` to '-' when it isn't supplied and there is no policy override", () => { - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + const policy = Object.assign({}, Policies.Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({}); @@ -234,7 +234,7 @@ describe("Password generator options builder", () => { }); it("should leave `wordSeparator` as the empty string '' when it is the empty string", () => { - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + const policy = Object.assign({}, Policies.Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({ wordSeparator: "" }); @@ -244,7 +244,7 @@ describe("Password generator options builder", () => { }); it("should preserve unknown properties", () => { - const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + const policy = Object.assign({}, Policies.Passphrase.disabledValue); const builder = new PassphraseGeneratorOptionsEvaluator(policy); const options = Object.freeze({ unknown: "property", diff --git a/libs/tools/generator/core/src/policies/passphrase-generator-options-evaluator.ts b/libs/tools/generator/core/src/policies/passphrase-generator-options-evaluator.ts index 35f4e07dfd..5a510ed3a8 100644 --- a/libs/tools/generator/core/src/policies/passphrase-generator-options-evaluator.ts +++ b/libs/tools/generator/core/src/policies/passphrase-generator-options-evaluator.ts @@ -1,3 +1,5 @@ +import { Constraints } from "@bitwarden/common/tools/types"; + import { PolicyEvaluator } from "../abstractions"; import { DefaultPassphraseGenerationOptions, DefaultPassphraseBoundaries } from "../data"; import { Boundary, PassphraseGenerationOptions, PassphraseGeneratorPolicy } from "../types"; @@ -5,7 +7,9 @@ import { Boundary, PassphraseGenerationOptions, PassphraseGeneratorPolicy } from /** Enforces policy for passphrase generation options. */ export class PassphraseGeneratorOptionsEvaluator - implements PolicyEvaluator + implements + PolicyEvaluator, + Constraints { // This design is not ideal, but it is a step towards a more robust passphrase // generator. Ideally, `sanitize` would be implemented on an options class, diff --git a/libs/tools/generator/core/src/policies/passphrase-least-privilege.spec.ts b/libs/tools/generator/core/src/policies/passphrase-least-privilege.spec.ts index 4a330f032f..ecac385598 100644 --- a/libs/tools/generator/core/src/policies/passphrase-least-privilege.spec.ts +++ b/libs/tools/generator/core/src/policies/passphrase-least-privilege.spec.ts @@ -4,7 +4,7 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { PolicyId } from "@bitwarden/common/types/guid"; -import { DisabledPassphraseGeneratorPolicy } from "../data"; +import { Policies } from "../data"; import { passphraseLeastPrivilege } from "./passphrase-least-privilege"; @@ -26,17 +26,17 @@ describe("passphraseLeastPrivilege", () => { it("should return the accumulator when the policy type does not apply", () => { const policy = createPolicy({}, PolicyType.RequireSso); - const result = passphraseLeastPrivilege(DisabledPassphraseGeneratorPolicy, policy); + const result = passphraseLeastPrivilege(Policies.Passphrase.disabledValue, policy); - expect(result).toEqual(DisabledPassphraseGeneratorPolicy); + expect(result).toEqual(Policies.Passphrase.disabledValue); }); it("should return the accumulator when the policy is not enabled", () => { const policy = createPolicy({}, PolicyType.PasswordGenerator, false); - const result = passphraseLeastPrivilege(DisabledPassphraseGeneratorPolicy, policy); + const result = passphraseLeastPrivilege(Policies.Passphrase.disabledValue, policy); - expect(result).toEqual(DisabledPassphraseGeneratorPolicy); + expect(result).toEqual(Policies.Passphrase.disabledValue); }); it.each([ @@ -46,8 +46,8 @@ describe("passphraseLeastPrivilege", () => { ])("should take the %p from the policy", (input, value) => { const policy = createPolicy({ [input]: value }); - const result = passphraseLeastPrivilege(DisabledPassphraseGeneratorPolicy, policy); + const result = passphraseLeastPrivilege(Policies.Passphrase.disabledValue, policy); - expect(result).toEqual({ ...DisabledPassphraseGeneratorPolicy, [input]: value }); + expect(result).toEqual({ ...Policies.Passphrase.disabledValue, [input]: value }); }); }); diff --git a/libs/tools/generator/core/src/policies/password-generator-options-evaluator.spec.ts b/libs/tools/generator/core/src/policies/password-generator-options-evaluator.spec.ts index a703388f95..c6cc96dd82 100644 --- a/libs/tools/generator/core/src/policies/password-generator-options-evaluator.spec.ts +++ b/libs/tools/generator/core/src/policies/password-generator-options-evaluator.spec.ts @@ -1,4 +1,4 @@ -import { DefaultPasswordBoundaries, DisabledPasswordGeneratorPolicy } from "../data"; +import { DefaultPasswordBoundaries, Policies } from "../data"; import { PasswordGenerationOptions } from "../types"; import { PasswordGeneratorOptionsEvaluator } from "./password-generator-options-evaluator"; @@ -8,7 +8,7 @@ describe("Password generator options builder", () => { describe("constructor()", () => { it("should set the policy object to a copy of the input policy", () => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); policy.minLength = 10; // arbitrary change for deep equality check const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -18,7 +18,7 @@ describe("Password generator options builder", () => { }); it("should set default boundaries when a default policy is used", () => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -32,7 +32,7 @@ describe("Password generator options builder", () => { (minLength) => { expect(minLength).toBeLessThan(DefaultPasswordBoundaries.length.min); - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); policy.minLength = minLength; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -47,7 +47,7 @@ describe("Password generator options builder", () => { expect(expectedLength).toBeGreaterThan(DefaultPasswordBoundaries.length.min); expect(expectedLength).toBeLessThanOrEqual(DefaultPasswordBoundaries.length.max); - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); policy.minLength = expectedLength; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -62,7 +62,7 @@ describe("Password generator options builder", () => { (expectedLength) => { expect(expectedLength).toBeGreaterThan(DefaultPasswordBoundaries.length.max); - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); policy.minLength = expectedLength; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -78,7 +78,7 @@ describe("Password generator options builder", () => { expect(expectedMinDigits).toBeGreaterThan(DefaultPasswordBoundaries.minDigits.min); expect(expectedMinDigits).toBeLessThanOrEqual(DefaultPasswordBoundaries.minDigits.max); - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); policy.numberCount = expectedMinDigits; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -93,7 +93,7 @@ describe("Password generator options builder", () => { (expectedMinDigits) => { expect(expectedMinDigits).toBeGreaterThan(DefaultPasswordBoundaries.minDigits.max); - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); policy.numberCount = expectedMinDigits; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -113,7 +113,7 @@ describe("Password generator options builder", () => { DefaultPasswordBoundaries.minSpecialCharacters.max, ); - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); policy.specialCount = expectedSpecialCharacters; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -132,7 +132,7 @@ describe("Password generator options builder", () => { DefaultPasswordBoundaries.minSpecialCharacters.max, ); - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); policy.specialCount = expectedSpecialCharacters; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -151,7 +151,7 @@ describe("Password generator options builder", () => { (expectedLength, numberCount, specialCount) => { expect(expectedLength).toBeGreaterThanOrEqual(DefaultPasswordBoundaries.length.min); - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); policy.numberCount = numberCount; policy.specialCount = specialCount; @@ -164,14 +164,14 @@ describe("Password generator options builder", () => { describe("policyInEffect", () => { it("should return false when the policy has no effect", () => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); expect(builder.policyInEffect).toEqual(false); }); it("should return true when the policy has a minlength greater than the default boundary", () => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); policy.minLength = DefaultPasswordBoundaries.length.min + 1; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -179,7 +179,7 @@ describe("Password generator options builder", () => { }); it("should return true when the policy has a number count greater than the default boundary", () => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); policy.numberCount = DefaultPasswordBoundaries.minDigits.min + 1; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -187,7 +187,7 @@ describe("Password generator options builder", () => { }); it("should return true when the policy has a special character count greater than the default boundary", () => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); policy.specialCount = DefaultPasswordBoundaries.minSpecialCharacters.min + 1; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -195,7 +195,7 @@ describe("Password generator options builder", () => { }); it("should return true when the policy has uppercase enabled", () => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); policy.useUppercase = true; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -203,7 +203,7 @@ describe("Password generator options builder", () => { }); it("should return true when the policy has lowercase enabled", () => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); policy.useLowercase = true; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -211,7 +211,7 @@ describe("Password generator options builder", () => { }); it("should return true when the policy has numbers enabled", () => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); policy.useNumbers = true; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -219,7 +219,7 @@ describe("Password generator options builder", () => { }); it("should return true when the policy has special characters enabled", () => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); policy.useSpecial = true; const builder = new PasswordGeneratorOptionsEvaluator(policy); @@ -237,7 +237,7 @@ describe("Password generator options builder", () => { ])( "should set `options.uppercase` to '%s' when `policy.useUppercase` is false and `options.uppercase` is '%s'", (expectedUppercase, uppercase) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); policy.useUppercase = false; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, uppercase }); @@ -251,7 +251,7 @@ describe("Password generator options builder", () => { it.each([false, true, undefined])( "should set `options.uppercase` (= %s) to true when `policy.useUppercase` is true", (uppercase) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); policy.useUppercase = true; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, uppercase }); @@ -269,7 +269,7 @@ describe("Password generator options builder", () => { ])( "should set `options.lowercase` to '%s' when `policy.useLowercase` is false and `options.lowercase` is '%s'", (expectedLowercase, lowercase) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); policy.useLowercase = false; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, lowercase }); @@ -283,7 +283,7 @@ describe("Password generator options builder", () => { it.each([false, true, undefined])( "should set `options.lowercase` (= %s) to true when `policy.useLowercase` is true", (lowercase) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); policy.useLowercase = true; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, lowercase }); @@ -301,7 +301,7 @@ describe("Password generator options builder", () => { ])( "should set `options.number` to '%s' when `policy.useNumbers` is false and `options.number` is '%s'", (expectedNumber, number) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); policy.useNumbers = false; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, number }); @@ -315,7 +315,7 @@ describe("Password generator options builder", () => { it.each([false, true, undefined])( "should set `options.number` (= %s) to true when `policy.useNumbers` is true", (number) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); policy.useNumbers = true; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, number }); @@ -333,7 +333,7 @@ describe("Password generator options builder", () => { ])( "should set `options.special` to '%s' when `policy.useSpecial` is false and `options.special` is '%s'", (expectedSpecial, special) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); policy.useSpecial = false; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, special }); @@ -347,7 +347,7 @@ describe("Password generator options builder", () => { it.each([false, true, undefined])( "should set `options.special` (= %s) to true when `policy.useSpecial` is true", (special) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); policy.useSpecial = true; const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, special }); @@ -361,7 +361,7 @@ describe("Password generator options builder", () => { it.each([1, 2, 3, 4])( "should set `options.length` (= %i) to the minimum it is less than the minimum length", (length) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); expect(length).toBeLessThan(builder.length.min); @@ -376,7 +376,7 @@ describe("Password generator options builder", () => { it.each([5, 10, 50, 100, 128])( "should not change `options.length` (= %i) when it is within the boundaries", (length) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); expect(length).toBeGreaterThanOrEqual(builder.length.min); expect(length).toBeLessThanOrEqual(builder.length.max); @@ -392,7 +392,7 @@ describe("Password generator options builder", () => { it.each([129, 500, 9000])( "should set `options.length` (= %i) to the maximum length when it is exceeded", (length) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); expect(length).toBeGreaterThan(builder.length.max); @@ -414,7 +414,7 @@ describe("Password generator options builder", () => { ])( "should set `options.number === %s` when `options.minNumber` (= %i) is set to a value greater than 0", (expectedNumber, minNumber) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, minNumber }); @@ -425,7 +425,7 @@ describe("Password generator options builder", () => { ); it("should set `options.minNumber` to the minimum value when `options.number` is true", () => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, number: true }); @@ -435,7 +435,7 @@ describe("Password generator options builder", () => { }); it("should set `options.minNumber` to 0 when `options.number` is false", () => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, number: false }); @@ -447,7 +447,7 @@ describe("Password generator options builder", () => { it.each([1, 2, 3, 4])( "should set `options.minNumber` (= %i) to the minimum it is less than the minimum number", (minNumber) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); policy.numberCount = 5; // arbitrary value greater than minNumber expect(minNumber).toBeLessThan(policy.numberCount); @@ -463,7 +463,7 @@ describe("Password generator options builder", () => { it.each([1, 3, 5, 7, 9])( "should not change `options.minNumber` (= %i) when it is within the boundaries", (minNumber) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); expect(minNumber).toBeGreaterThanOrEqual(builder.minDigits.min); expect(minNumber).toBeLessThanOrEqual(builder.minDigits.max); @@ -479,7 +479,7 @@ describe("Password generator options builder", () => { it.each([10, 20, 400])( "should set `options.minNumber` (= %i) to the maximum digit boundary when it is exceeded", (minNumber) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); expect(minNumber).toBeGreaterThan(builder.minDigits.max); @@ -501,7 +501,7 @@ describe("Password generator options builder", () => { ])( "should set `options.special === %s` when `options.minSpecial` (= %i) is set to a value greater than 0", (expectedSpecial, minSpecial) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, minSpecial }); @@ -512,7 +512,7 @@ describe("Password generator options builder", () => { ); it("should set `options.minSpecial` to the minimum value when `options.special` is true", () => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, special: true }); @@ -522,7 +522,7 @@ describe("Password generator options builder", () => { }); it("should set `options.minSpecial` to 0 when `options.special` is false", () => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ ...defaultOptions, special: false }); @@ -534,7 +534,7 @@ describe("Password generator options builder", () => { it.each([1, 2, 3, 4])( "should set `options.minSpecial` (= %i) to the minimum it is less than the minimum special characters", (minSpecial) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); policy.specialCount = 5; // arbitrary value greater than minSpecial expect(minSpecial).toBeLessThan(policy.specialCount); @@ -550,7 +550,7 @@ describe("Password generator options builder", () => { it.each([1, 3, 5, 7, 9])( "should not change `options.minSpecial` (= %i) when it is within the boundaries", (minSpecial) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); expect(minSpecial).toBeGreaterThanOrEqual(builder.minSpecialCharacters.min); expect(minSpecial).toBeLessThanOrEqual(builder.minSpecialCharacters.max); @@ -566,7 +566,7 @@ describe("Password generator options builder", () => { it.each([10, 20, 400])( "should set `options.minSpecial` (= %i) to the maximum special character boundary when it is exceeded", (minSpecial) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); expect(minSpecial).toBeGreaterThan(builder.minSpecialCharacters.max); @@ -579,7 +579,7 @@ describe("Password generator options builder", () => { ); it("should preserve unknown properties", () => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ unknown: "property", @@ -602,7 +602,7 @@ describe("Password generator options builder", () => { ])( "should output `options.minLowercase === %i` when `options.lowercase` is %s", (expectedMinLowercase, lowercase) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ lowercase, ...defaultOptions }); @@ -618,7 +618,7 @@ describe("Password generator options builder", () => { ])( "should output `options.minUppercase === %i` when `options.uppercase` is %s", (expectedMinUppercase, uppercase) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ uppercase, ...defaultOptions }); @@ -634,7 +634,7 @@ describe("Password generator options builder", () => { ])( "should output `options.minNumber === %i` when `options.number` is %s and `options.minNumber` is not set", (expectedMinNumber, number) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ number, ...defaultOptions }); @@ -652,7 +652,7 @@ describe("Password generator options builder", () => { ])( "should output `options.number === %s` when `options.minNumber` is %i and `options.number` is not set", (expectedNumber, minNumber) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ minNumber, ...defaultOptions }); @@ -668,7 +668,7 @@ describe("Password generator options builder", () => { ])( "should output `options.minSpecial === %i` when `options.special` is %s and `options.minSpecial` is not set", (special, expectedMinSpecial) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ special, ...defaultOptions }); @@ -686,7 +686,7 @@ describe("Password generator options builder", () => { ])( "should output `options.special === %s` when `options.minSpecial` is %i and `options.special` is not set", (minSpecial, expectedSpecial) => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ minSpecial, ...defaultOptions }); @@ -707,7 +707,7 @@ describe("Password generator options builder", () => { const sumOfMinimums = minLowercase + minUppercase + minNumber + minSpecial; expect(sumOfMinimums).toBeLessThan(DefaultPasswordBoundaries.length.min); - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ minLowercase, @@ -732,7 +732,7 @@ describe("Password generator options builder", () => { (expectedMinLength, minLowercase, minUppercase, minNumber, minSpecial) => { expect(expectedMinLength).toBeGreaterThanOrEqual(DefaultPasswordBoundaries.length.min); - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ minLowercase, @@ -749,7 +749,7 @@ describe("Password generator options builder", () => { ); it("should preserve unknown properties", () => { - const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const policy = Object.assign({}, Policies.Password.disabledValue); const builder = new PasswordGeneratorOptionsEvaluator(policy); const options = Object.freeze({ unknown: "property", diff --git a/libs/tools/generator/core/src/policies/password-least-privilege.spec.ts b/libs/tools/generator/core/src/policies/password-least-privilege.spec.ts index 2ce02a97a2..5d5430b8ca 100644 --- a/libs/tools/generator/core/src/policies/password-least-privilege.spec.ts +++ b/libs/tools/generator/core/src/policies/password-least-privilege.spec.ts @@ -4,7 +4,7 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { PolicyId } from "@bitwarden/common/types/guid"; -import { DisabledPasswordGeneratorPolicy } from "../data"; +import { Policies } from "../data"; import { passwordLeastPrivilege } from "./password-least-privilege"; @@ -26,17 +26,17 @@ describe("passwordLeastPrivilege", () => { it("should return the accumulator when the policy type does not apply", () => { const policy = createPolicy({}, PolicyType.RequireSso); - const result = passwordLeastPrivilege(DisabledPasswordGeneratorPolicy, policy); + const result = passwordLeastPrivilege(Policies.Password.disabledValue, policy); - expect(result).toEqual(DisabledPasswordGeneratorPolicy); + expect(result).toEqual(Policies.Password.disabledValue); }); it("should return the accumulator when the policy is not enabled", () => { const policy = createPolicy({}, PolicyType.PasswordGenerator, false); - const result = passwordLeastPrivilege(DisabledPasswordGeneratorPolicy, policy); + const result = passwordLeastPrivilege(Policies.Password.disabledValue, policy); - expect(result).toEqual(DisabledPasswordGeneratorPolicy); + expect(result).toEqual(Policies.Password.disabledValue); }); it.each([ @@ -50,8 +50,8 @@ describe("passwordLeastPrivilege", () => { ])("should take the %p from the policy", (input, value, expected) => { const policy = createPolicy({ [input]: value }); - const result = passwordLeastPrivilege(DisabledPasswordGeneratorPolicy, policy); + const result = passwordLeastPrivilege(Policies.Password.disabledValue, policy); - expect(result).toEqual({ ...DisabledPasswordGeneratorPolicy, [expected]: value }); + expect(result).toEqual({ ...Policies.Password.disabledValue, [expected]: value }); }); }); diff --git a/libs/tools/generator/core/src/rx.ts b/libs/tools/generator/core/src/rx.ts index ab907b6455..e3c02be129 100644 --- a/libs/tools/generator/core/src/rx.ts +++ b/libs/tools/generator/core/src/rx.ts @@ -18,6 +18,19 @@ export function mapPolicyToEvaluator( ); } +/** Maps an administrative console policy to a policy evaluator using the provided configuration. + * @param configuration the configuration that constructs the evaluator. + */ +export function mapPolicyToEvaluatorV2( + configuration: PolicyConfiguration, +) { + return pipe( + reduceCollection(configuration.combine, configuration.disabledValue), + distinctIfShallowMatch(), + map(configuration.createEvaluatorV2), + ); +} + /** Constructs a method that maps a policy to the default (no-op) policy. */ export function newDefaultEvaluator() { return () => { diff --git a/libs/tools/generator/core/src/services/credential-generator.service.spec.ts b/libs/tools/generator/core/src/services/credential-generator.service.spec.ts new file mode 100644 index 0000000000..31f5134918 --- /dev/null +++ b/libs/tools/generator/core/src/services/credential-generator.service.spec.ts @@ -0,0 +1,377 @@ +import { mock } from "jest-mock-extended"; +import { BehaviorSubject, firstValueFrom } from "rxjs"; + +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; +import { Constraints } from "@bitwarden/common/tools/types"; +import { OrganizationId, PolicyId, UserId } from "@bitwarden/common/types/guid"; + +import { FakeStateProvider, FakeAccountService, awaitAsync } from "../../../../../common/spec"; +import { PolicyEvaluator } from "../abstractions"; +import { CredentialGeneratorConfiguration } from "../types"; + +import { CredentialGeneratorService } from "./credential-generator.service"; + +// arbitrary settings types +type SomeSettings = { foo: string }; +type SomePolicy = { fooPolicy: boolean }; + +// settings storage location +const SettingsKey = new UserKeyDefinition(GENERATOR_DISK, "SomeSettings", { + deserializer: (value) => value, + clearOn: [], +}); + +// fake policy +const policyService = mock(); +const somePolicy = new Policy({ + data: { fooPolicy: true }, + type: PolicyType.PasswordGenerator, + id: "" as PolicyId, + organizationId: "" as OrganizationId, + enabled: true, +}); + +// fake the configuration +const SomeConfiguration: CredentialGeneratorConfiguration = { + settings: { + initial: { foo: "initial" }, + constraints: { foo: {} }, + account: SettingsKey, + }, + policy: { + type: PolicyType.PasswordGenerator, + disabledValue: { + fooPolicy: false, + }, + combine: (acc, policy) => { + return { fooPolicy: acc.fooPolicy || policy.data.fooPolicy }; + }, + createEvaluator: () => { + throw new Error("this should never be called"); + }, + createEvaluatorV2: (policy) => { + return { + foo: {}, + policy, + policyInEffect: policy.fooPolicy, + applyPolicy: (settings) => { + return policy.fooPolicy ? { foo: `apply(${settings.foo})` } : settings; + }, + sanitize: (settings) => { + return policy.fooPolicy ? { foo: `sanitize(${settings.foo})` } : settings; + }, + } as PolicyEvaluator & Constraints; + }, + }, +}; + +// fake user information +const SomeUser = "SomeUser" as UserId; +const AnotherUser = "SomeOtherUser" as UserId; +const accountService = new FakeAccountService({ + [SomeUser]: { + name: "some user", + email: "some.user@example.com", + emailVerified: true, + }, + [AnotherUser]: { + name: "some other user", + email: "some.other.user@example.com", + emailVerified: true, + }, +}); + +// fake state +const stateProvider = new FakeStateProvider(accountService); + +describe("CredentialGeneratorService", () => { + beforeEach(async () => { + await accountService.switchAccount(SomeUser); + policyService.getAll$.mockImplementation(() => new BehaviorSubject([]).asObservable()); + jest.clearAllMocks(); + }); + + describe("settings$", () => { + it("defaults to the configuration's initial settings if settings aren't found", async () => { + await stateProvider.setUserState(SettingsKey, null, SomeUser); + const generator = new CredentialGeneratorService(stateProvider, policyService); + + const result = await firstValueFrom(generator.settings$(SomeConfiguration)); + + expect(result).toEqual(SomeConfiguration.settings.initial); + }); + + it("reads from the active user's configuration-defined storage", async () => { + const settings = { foo: "value" }; + await stateProvider.setUserState(SettingsKey, settings, SomeUser); + const generator = new CredentialGeneratorService(stateProvider, policyService); + + const result = await firstValueFrom(generator.settings$(SomeConfiguration)); + + expect(result).toEqual(settings); + }); + + it("applies policy to the loaded settings", async () => { + const settings = { foo: "value" }; + await stateProvider.setUserState(SettingsKey, settings, SomeUser); + const policy$ = new BehaviorSubject([somePolicy]); + policyService.getAll$.mockReturnValue(policy$); + const generator = new CredentialGeneratorService(stateProvider, policyService); + + const result = await firstValueFrom(generator.settings$(SomeConfiguration)); + + expect(result).toEqual({ foo: "sanitize(apply(value))" }); + }); + + it("follows changes to the active user", async () => { + const someSettings = { foo: "value" }; + const anotherSettings = { foo: "another" }; + await stateProvider.setUserState(SettingsKey, someSettings, SomeUser); + await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser); + const generator = new CredentialGeneratorService(stateProvider, policyService); + const results: any = []; + const sub = generator.settings$(SomeConfiguration).subscribe((r) => results.push(r)); + + await accountService.switchAccount(AnotherUser); + await awaitAsync(); + sub.unsubscribe(); + + const [someResult, anotherResult] = results; + expect(someResult).toEqual(someSettings); + expect(anotherResult).toEqual(anotherSettings); + }); + + it("reads an arbitrary user's settings", async () => { + await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); + const anotherSettings = { foo: "another" }; + await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser); + const generator = new CredentialGeneratorService(stateProvider, policyService); + const userId$ = new BehaviorSubject(AnotherUser).asObservable(); + + const result = await firstValueFrom(generator.settings$(SomeConfiguration, { userId$ })); + + expect(result).toEqual(anotherSettings); + }); + + it("follows changes to the arbitrary user", async () => { + const someSettings = { foo: "value" }; + await stateProvider.setUserState(SettingsKey, someSettings, SomeUser); + const anotherSettings = { foo: "another" }; + await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser); + const generator = new CredentialGeneratorService(stateProvider, policyService); + const userId = new BehaviorSubject(SomeUser); + const userId$ = userId.asObservable(); + const results: any = []; + const sub = generator + .settings$(SomeConfiguration, { userId$ }) + .subscribe((r) => results.push(r)); + + userId.next(AnotherUser); + await awaitAsync(); + sub.unsubscribe(); + + const [someResult, anotherResult] = results; + expect(someResult).toEqual(someSettings); + expect(anotherResult).toEqual(anotherSettings); + }); + + it("errors when the arbitrary user's stream errors", async () => { + await stateProvider.setUserState(SettingsKey, null, SomeUser); + const generator = new CredentialGeneratorService(stateProvider, policyService); + const userId = new BehaviorSubject(SomeUser); + const userId$ = userId.asObservable(); + let error = null; + + generator.settings$(SomeConfiguration, { userId$ }).subscribe({ + error: (e: unknown) => { + error = e; + }, + }); + userId.error({ some: "error" }); + await awaitAsync(); + + expect(error).toEqual({ some: "error" }); + }); + + it("completes when the arbitrary user's stream completes", async () => { + await stateProvider.setUserState(SettingsKey, null, SomeUser); + const generator = new CredentialGeneratorService(stateProvider, policyService); + const userId = new BehaviorSubject(SomeUser); + const userId$ = userId.asObservable(); + let completed = false; + + generator.settings$(SomeConfiguration, { userId$ }).subscribe({ + complete: () => { + completed = true; + }, + }); + userId.complete(); + await awaitAsync(); + + expect(completed).toBeTruthy(); + }); + + it("ignores repeated arbitrary user emissions", async () => { + await stateProvider.setUserState(SettingsKey, null, SomeUser); + const generator = new CredentialGeneratorService(stateProvider, policyService); + const userId = new BehaviorSubject(SomeUser); + const userId$ = userId.asObservable(); + let count = 0; + + const sub = generator.settings$(SomeConfiguration, { userId$ }).subscribe({ + next: () => { + count++; + }, + }); + await awaitAsync(); + userId.next(SomeUser); + await awaitAsync(); + userId.next(SomeUser); + await awaitAsync(); + sub.unsubscribe(); + + expect(count).toEqual(1); + }); + }); + + describe("settings", () => { + it("writes to the user's state", async () => { + const singleUserId$ = new BehaviorSubject(SomeUser).asObservable(); + const generator = new CredentialGeneratorService(stateProvider, policyService); + const subject = await generator.settings(SomeConfiguration, { singleUserId$ }); + + subject.next({ foo: "next value" }); + await awaitAsync(); + const result = await firstValueFrom(stateProvider.getUserState$(SettingsKey, SomeUser)); + + expect(result).toEqual({ foo: "next value" }); + }); + + it("waits for the user to become available", async () => { + const singleUserId = new BehaviorSubject(null); + const singleUserId$ = singleUserId.asObservable(); + const generator = new CredentialGeneratorService(stateProvider, policyService); + + let completed = false; + const promise = generator.settings(SomeConfiguration, { singleUserId$ }).then((settings) => { + completed = true; + return settings; + }); + await awaitAsync(); + expect(completed).toBeFalsy(); + singleUserId.next(SomeUser); + const result = await promise; + + expect(result.userId).toEqual(SomeUser); + }); + }); + + describe("policy$", () => { + it("creates a disabled policy evaluator when there is no policy", async () => { + const generator = new CredentialGeneratorService(stateProvider, policyService); + const userId$ = new BehaviorSubject(SomeUser).asObservable(); + + const result = await firstValueFrom(generator.policy$(SomeConfiguration, { userId$ })); + + expect(result.policy).toEqual(SomeConfiguration.policy.disabledValue); + expect(result.policyInEffect).toBeFalsy(); + }); + + it("creates an active policy evaluator when there is a policy", async () => { + const generator = new CredentialGeneratorService(stateProvider, policyService); + const userId$ = new BehaviorSubject(SomeUser).asObservable(); + const policy$ = new BehaviorSubject([somePolicy]); + policyService.getAll$.mockReturnValue(policy$); + + const result = await firstValueFrom(generator.policy$(SomeConfiguration, { userId$ })); + + expect(result.policy).toEqual({ fooPolicy: true }); + expect(result.policyInEffect).toBeTruthy(); + }); + + it("follows policy emissions", async () => { + const generator = new CredentialGeneratorService(stateProvider, policyService); + const userId = new BehaviorSubject(SomeUser); + const userId$ = userId.asObservable(); + const somePolicySubject = new BehaviorSubject([somePolicy]); + policyService.getAll$.mockReturnValueOnce(somePolicySubject.asObservable()); + const emissions: any = []; + const sub = generator + .policy$(SomeConfiguration, { userId$ }) + .subscribe((policy) => emissions.push(policy)); + + // swap the active policy for an inactive policy + somePolicySubject.next([]); + await awaitAsync(); + sub.unsubscribe(); + const [someResult, anotherResult] = emissions; + + expect(someResult.policy).toEqual({ fooPolicy: true }); + expect(someResult.policyInEffect).toBeTruthy(); + expect(anotherResult.policy).toEqual(SomeConfiguration.policy.disabledValue); + expect(anotherResult.policyInEffect).toBeFalsy(); + }); + + it("follows user emissions", async () => { + const generator = new CredentialGeneratorService(stateProvider, policyService); + const userId = new BehaviorSubject(SomeUser); + const userId$ = userId.asObservable(); + const somePolicy$ = new BehaviorSubject([somePolicy]).asObservable(); + const anotherPolicy$ = new BehaviorSubject([]).asObservable(); + policyService.getAll$.mockReturnValueOnce(somePolicy$).mockReturnValueOnce(anotherPolicy$); + const emissions: any = []; + const sub = generator + .policy$(SomeConfiguration, { userId$ }) + .subscribe((policy) => emissions.push(policy)); + + // swapping the user invokes the return for `anotherPolicy$` + userId.next(AnotherUser); + await awaitAsync(); + sub.unsubscribe(); + const [someResult, anotherResult] = emissions; + + expect(someResult.policy).toEqual({ fooPolicy: true }); + expect(someResult.policyInEffect).toBeTruthy(); + expect(anotherResult.policy).toEqual(SomeConfiguration.policy.disabledValue); + expect(anotherResult.policyInEffect).toBeFalsy(); + }); + + it("errors when the user errors", async () => { + const generator = new CredentialGeneratorService(stateProvider, policyService); + const userId = new BehaviorSubject(SomeUser); + const userId$ = userId.asObservable(); + const expectedError = { some: "error" }; + + let actualError: any = null; + generator.policy$(SomeConfiguration, { userId$ }).subscribe({ + error: (e: unknown) => { + actualError = e; + }, + }); + userId.error(expectedError); + await awaitAsync(); + + expect(actualError).toEqual(expectedError); + }); + + it("completes when the user completes", async () => { + const generator = new CredentialGeneratorService(stateProvider, policyService); + const userId = new BehaviorSubject(SomeUser); + const userId$ = userId.asObservable(); + + let completed = false; + generator.policy$(SomeConfiguration, { userId$ }).subscribe({ + complete: () => { + completed = true; + }, + }); + userId.complete(); + await awaitAsync(); + + expect(completed).toBeTruthy(); + }); + }); +}); diff --git a/libs/tools/generator/core/src/services/credential-generator.service.ts b/libs/tools/generator/core/src/services/credential-generator.service.ts new file mode 100644 index 0000000000..891d0016fe --- /dev/null +++ b/libs/tools/generator/core/src/services/credential-generator.service.ts @@ -0,0 +1,128 @@ +import { + combineLatest, + distinctUntilChanged, + endWith, + filter, + firstValueFrom, + ignoreElements, + map, + mergeMap, + Observable, + switchMap, + takeUntil, +} from "rxjs"; + +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { StateProvider } from "@bitwarden/common/platform/state"; +import { SingleUserDependency, UserDependency } from "@bitwarden/common/tools/dependencies"; +import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject"; +import { Constraints } from "@bitwarden/common/tools/types"; + +import { PolicyEvaluator } from "../abstractions"; +import { mapPolicyToEvaluatorV2 } from "../rx"; +import { CredentialGeneratorConfiguration as Configuration } from "../types/credential-generator-configuration"; + +type Policy$Dependencies = UserDependency; +type Settings$Dependencies = Partial; +// FIXME: once the modernization is complete, switch the type parameters +// in `PolicyEvaluator` and bake-in the constraints type. +type Evaluator = PolicyEvaluator & Constraints; + +export class CredentialGeneratorService { + constructor( + private stateProvider: StateProvider, + private policyService: PolicyService, + ) {} + + /** Get the settings for the provided configuration + * @param configuration determines which generator's settings are loaded + * @param dependencies.userId$ identifies the user to which the settings are bound. + * If this parameter is not provided, the observable follows the active user and + * may not complete. + * @returns an observable that emits settings + * @remarks the observable enforces policies on the settings + */ + settings$( + configuration: Configuration, + dependencies?: Settings$Dependencies, + ) { + const userId$ = dependencies?.userId$ ?? this.stateProvider.activeUserId$; + const completion$ = userId$.pipe(ignoreElements(), endWith(true)); + + const state$ = userId$.pipe( + filter((userId) => !!userId), + distinctUntilChanged(), + switchMap((userId) => { + const state$ = this.stateProvider + .getUserState$(configuration.settings.account, userId) + .pipe(takeUntil(completion$)); + + return state$; + }), + map((settings) => settings ?? structuredClone(configuration.settings.initial)), + ); + + const settings$ = combineLatest([state$, this.policy$(configuration, { userId$ })]).pipe( + map(([settings, policy]) => { + // FIXME: create `onLoadApply` that wraps these operations + const applied = policy.applyPolicy(settings); + const sanitized = policy.sanitize(applied); + return sanitized; + }), + ); + + return settings$; + } + + /** Get a subject bound to a specific user's settings + * @param configuration determines which generator's settings are loaded + * @param dependencies.singleUserId$ identifies the user to which the settings are bound + * @returns a promise that resolves with the subject once + * `dependencies.singleUserId$` becomes available. + * @remarks the subject enforces policy for the settings + */ + async settings( + configuration: Configuration, + dependencies: SingleUserDependency, + ) { + const userId = await firstValueFrom( + dependencies.singleUserId$.pipe(filter((userId) => !!userId)), + ); + const state = this.stateProvider.getUser(userId, configuration.settings.account); + + // FIXME: apply policy to the settings - this should happen *within* the subject. + // Note that policies could be evaluated when the settings are saved or when they + // are loaded. The existing subject presently could only apply settings on save + // (by wiring the policy in as a dependency and applying with "nextState"), and + // even that has a limitation since arbitrary dependencies do not trigger state + // emissions. + const subject = new UserStateSubject(state, dependencies); + + return subject; + } + + /** Get the policy for the provided configuration + * @param dependencies.userId$ determines which user's policy is loaded + * @returns an observable that emits the policy once `dependencies.userId$` + * and the policy become available. + */ + policy$( + configuration: Configuration, + dependencies: Policy$Dependencies, + ): Observable> { + const completion$ = dependencies.userId$.pipe(ignoreElements(), endWith(true)); + + const policy$ = dependencies.userId$.pipe( + mergeMap((userId) => { + // complete policy emissions otherwise `mergeMap` holds `policy$` open indefinitely + const policies$ = this.policyService + .getAll$(configuration.policy.type, userId) + .pipe(takeUntil(completion$)); + return policies$; + }), + mapPolicyToEvaluatorV2(configuration.policy), + ); + + return policy$; + } +} diff --git a/libs/tools/generator/core/src/services/index.ts b/libs/tools/generator/core/src/services/index.ts index 7568f55b68..d7184f684a 100644 --- a/libs/tools/generator/core/src/services/index.ts +++ b/libs/tools/generator/core/src/services/index.ts @@ -1 +1,2 @@ export { DefaultGeneratorService } from "./default-generator.service"; +export { CredentialGeneratorService } from "./credential-generator.service"; diff --git a/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.spec.ts b/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.spec.ts index f3a6046fac..f9b346e02b 100644 --- a/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.spec.ts +++ b/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.spec.ts @@ -8,7 +8,7 @@ import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { StateProvider } from "@bitwarden/common/platform/state"; import { UserId } from "@bitwarden/common/types/guid"; -import { DefaultPassphraseGenerationOptions, DisabledPassphraseGeneratorPolicy } from "../data"; +import { DefaultPassphraseGenerationOptions, Policies } from "../data"; import { PasswordRandomizer } from "../engine"; import { PassphraseGeneratorOptionsEvaluator } from "../policies"; @@ -50,7 +50,7 @@ describe("Password generation strategy", () => { const evaluator = await firstValueFrom(evaluator$); expect(evaluator).toBeInstanceOf(PassphraseGeneratorOptionsEvaluator); - expect(evaluator.policy).toMatchObject(DisabledPassphraseGeneratorPolicy); + expect(evaluator.policy).toMatchObject(Policies.Passphrase.disabledValue); }, ); }); diff --git a/libs/tools/generator/core/src/strategies/password-generator-strategy.spec.ts b/libs/tools/generator/core/src/strategies/password-generator-strategy.spec.ts index 536d69c9c1..928e0b0dc8 100644 --- a/libs/tools/generator/core/src/strategies/password-generator-strategy.spec.ts +++ b/libs/tools/generator/core/src/strategies/password-generator-strategy.spec.ts @@ -8,7 +8,7 @@ import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { StateProvider } from "@bitwarden/common/platform/state"; import { UserId } from "@bitwarden/common/types/guid"; -import { DefaultPasswordGenerationOptions, DisabledPasswordGeneratorPolicy } from "../data"; +import { DefaultPasswordGenerationOptions, Policies } from "../data"; import { PasswordRandomizer } from "../engine"; import { PasswordGeneratorOptionsEvaluator } from "../policies"; @@ -58,7 +58,7 @@ describe("Password generation strategy", () => { const evaluator = await firstValueFrom(evaluator$); expect(evaluator).toBeInstanceOf(PasswordGeneratorOptionsEvaluator); - expect(evaluator.policy).toMatchObject(DisabledPasswordGeneratorPolicy); + expect(evaluator.policy).toMatchObject(Policies.Password.disabledValue); }, ); }); diff --git a/libs/tools/generator/core/src/types/credential-generator-configuration.ts b/libs/tools/generator/core/src/types/credential-generator-configuration.ts new file mode 100644 index 0000000000..2a8b07b0e8 --- /dev/null +++ b/libs/tools/generator/core/src/types/credential-generator-configuration.ts @@ -0,0 +1,19 @@ +import { UserKeyDefinition } from "@bitwarden/common/platform/state"; +import { Constraints } from "@bitwarden/common/tools/types"; + +import { PolicyConfiguration } from "../types"; + +export type CredentialGeneratorConfiguration = { + settings: { + /** value used when an account's settings haven't been initialized */ + initial: Readonly>; + + constraints: Constraints; + + /** storage location for account-global settings */ + account: UserKeyDefinition; + }; + + /** defines how to construct policy for this settings instance */ + policy: PolicyConfiguration; +}; diff --git a/libs/tools/generator/core/src/types/index.ts b/libs/tools/generator/core/src/types/index.ts index 7a6d49d87c..786b15b9d1 100644 --- a/libs/tools/generator/core/src/types/index.ts +++ b/libs/tools/generator/core/src/types/index.ts @@ -1,5 +1,6 @@ export * from "./boundary"; export * from "./catchall-generator-options"; +export * from "./credential-generator-configuration"; export * from "./eff-username-generator-options"; export * from "./forwarder-options"; export * from "./generator-options"; diff --git a/libs/tools/generator/core/src/types/policy-configuration.ts b/libs/tools/generator/core/src/types/policy-configuration.ts index afecbe7d2b..6ec077bcb6 100644 --- a/libs/tools/generator/core/src/types/policy-configuration.ts +++ b/libs/tools/generator/core/src/types/policy-configuration.ts @@ -1,7 +1,13 @@ +import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Policy as AdminPolicy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { Constraints } from "@bitwarden/common/tools/types"; + +import { PolicyEvaluator } from "../abstractions"; /** Determines how to construct a password generator policy */ -export type PolicyConfiguration = { +export type PolicyConfiguration = { + type: PolicyType; + /** The value of the policy when it is not in effect. */ disabledValue: Policy; @@ -12,5 +18,12 @@ export type PolicyConfiguration = { /** Converts policy service data into an actionable policy. */ - createEvaluator: (policy: Policy) => Evaluator; + createEvaluator: (policy: Policy) => PolicyEvaluator; + + /** Converts policy service data into an actionable policy. + * @remarks this version includes constraints needed for the reactive forms; + * it was introduced so that the constraints can be incrementally introduced + * as the new UI is built. + */ + createEvaluatorV2?: (policy: Policy) => PolicyEvaluator & Constraints; }; diff --git a/libs/tools/generator/extensions/legacy/src/legacy-password-generation.service.spec.ts b/libs/tools/generator/extensions/legacy/src/legacy-password-generation.service.spec.ts index 4c1dee7a5d..21fbcb2627 100644 --- a/libs/tools/generator/extensions/legacy/src/legacy-password-generation.service.spec.ts +++ b/libs/tools/generator/extensions/legacy/src/legacy-password-generation.service.spec.ts @@ -7,8 +7,7 @@ import { GeneratorService, DefaultPassphraseGenerationOptions, DefaultPasswordGenerationOptions, - DisabledPassphraseGeneratorPolicy, - DisabledPasswordGeneratorPolicy, + Policies, PassphraseGenerationOptions, PassphraseGeneratorPolicy, PasswordGenerationOptions, @@ -39,7 +38,7 @@ const PasswordGeneratorOptionsEvaluator = policies.PasswordGeneratorOptionsEvalu function createPassphraseGenerator( options: PassphraseGenerationOptions = {}, - policy: PassphraseGeneratorPolicy = DisabledPassphraseGeneratorPolicy, + policy: PassphraseGeneratorPolicy = Policies.Passphrase.disabledValue, ) { let savedOptions = options; const generator = mock>({ @@ -64,7 +63,7 @@ function createPassphraseGenerator( function createPasswordGenerator( options: PasswordGenerationOptions = {}, - policy: PasswordGeneratorPolicy = DisabledPasswordGeneratorPolicy, + policy: PasswordGeneratorPolicy = Policies.Password.disabledValue, ) { let savedOptions = options; const generator = mock>({