diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 3c0d212bfc..8eae45e6b3 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -489,6 +489,10 @@ "message": "Avoid ambiguous characters", "description": "Label for the avoid ambiguous characters checkbox." }, + "generatorPolicyInEffect": { + "message": "Enterprise policy requirements have been applied to your generator options.", + "description": "Indicates that a policy limits the credential generator screen." + }, "searchVault": { "message": "Search vault" }, diff --git a/apps/browser/src/tools/popup/generator/credential-generator.component.html b/apps/browser/src/tools/popup/generator/credential-generator.component.html index d8c49da5b1..45a6c67f78 100644 --- a/apps/browser/src/tools/popup/generator/credential-generator.component.html +++ b/apps/browser/src/tools/popup/generator/credential-generator.component.html @@ -1 +1 @@ - + diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 46c89b933c..7f8ddb3aad 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -444,6 +444,10 @@ "ambiguous": { "message": "Avoid ambiguous characters" }, + "generatorPolicyInEffect": { + "message": "Enterprise policy requirements have been applied to your generator options.", + "description": "Indicates that a policy limits the credential generator screen." + }, "searchCollection": { "message": "Search collection" }, diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 06d42c4265..ed3bc14c25 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -1475,6 +1475,10 @@ "includeNumber": { "message": "Include number" }, + "generatorPolicyInEffect": { + "message": "Enterprise policy requirements have been applied to your generator options.", + "description": "Indicates that a policy limits the credential generator screen." + }, "passwordHistory": { "message": "Password history" }, diff --git a/libs/common/src/tools/state/identity-state-constraint.ts b/libs/common/src/tools/state/identity-state-constraint.ts new file mode 100644 index 0000000000..ff7712b909 --- /dev/null +++ b/libs/common/src/tools/state/identity-state-constraint.ts @@ -0,0 +1,24 @@ +import { Constraints, StateConstraints } from "../types"; + +// The constraints type shares the properties of the state, +// but never has any members +const EMPTY_CONSTRAINTS = new Proxy(Object.freeze({}), { + get() { + return {}; + }, +}); + +/** A constraint that does nothing. */ +export class IdentityConstraint implements StateConstraints { + /** Instantiate the identity constraint */ + constructor() {} + + readonly constraints: Readonly> = EMPTY_CONSTRAINTS; + + adjust(state: State) { + return state; + } + fix(state: State) { + return state; + } +} diff --git a/libs/common/src/tools/state/state-constraints-dependency.spec.ts b/libs/common/src/tools/state/state-constraints-dependency.spec.ts new file mode 100644 index 0000000000..6478a4318e --- /dev/null +++ b/libs/common/src/tools/state/state-constraints-dependency.spec.ts @@ -0,0 +1,27 @@ +import { StateConstraints } from "../types"; + +import { isDynamic } from "./state-constraints-dependency"; + +type TestType = { foo: string }; + +describe("isDynamic", () => { + it("returns `true` when the constraint fits the `DynamicStateConstraints` type.", () => { + const constraint: any = { + calibrate(state: TestType): StateConstraints { + return null; + }, + }; + + const result = isDynamic(constraint); + + expect(result).toBeTruthy(); + }); + + it("returns `false` when the constraint fails to fit the `DynamicStateConstraints` type.", () => { + const constraint: any = {}; + + const result = isDynamic(constraint); + + expect(result).toBeFalsy(); + }); +}); diff --git a/libs/common/src/tools/state/state-constraints-dependency.ts b/libs/common/src/tools/state/state-constraints-dependency.ts new file mode 100644 index 0000000000..66bac636bd --- /dev/null +++ b/libs/common/src/tools/state/state-constraints-dependency.ts @@ -0,0 +1,29 @@ +import { Observable } from "rxjs"; + +import { DynamicStateConstraints, StateConstraints } from "../types"; + +/** A pattern for types that depend upon a dynamic set of constraints. + * + * Consumers of this dependency should track the last-received state and + * apply it when application state is received or emitted. If `constraints$` + * emits an unrecoverable error, the consumer should continue using the + * last-emitted constraints. If `constraints$` completes, the consumer should + * continue using the last-emitted constraints. + */ +export type StateConstraintsDependency = { + /** A stream that emits constraints when subscribed and when the + * constraints change. The stream should not emit `null` or + * `undefined`. + */ + constraints$: Observable | DynamicStateConstraints>; +}; + +/** Returns `true` if the input constraint is a `DynamicStateConstraints`. + * Otherwise, returns false. + * @param constraints the constraint to evaluate. + * */ +export function isDynamic( + constraints: StateConstraints | DynamicStateConstraints, +): constraints is DynamicStateConstraints { + return constraints && "calibrate" in constraints; +} diff --git a/libs/common/src/tools/state/user-state-subject-dependencies.ts b/libs/common/src/tools/state/user-state-subject-dependencies.ts new file mode 100644 index 0000000000..7f36ab7cae --- /dev/null +++ b/libs/common/src/tools/state/user-state-subject-dependencies.ts @@ -0,0 +1,31 @@ +import { Simplify } from "type-fest"; + +import { Dependencies, SingleUserDependency, WhenDependency } from "../dependencies"; + +import { StateConstraintsDependency } from "./state-constraints-dependency"; + +/** dependencies accepted by the user state subject */ +export type UserStateSubjectDependencies = Simplify< + SingleUserDependency & + Partial & + Partial> & + Partial> & { + /** Compute the next stored value. If this is not set, values + * provided to `next` unconditionally override state. + * @param current the value stored in state + * @param next the value received by the user state subject's `next` member + * @param dependencies the latest value from `Dependencies` + * @returns the value to store in state + */ + nextValue?: (current: State, next: State, dependencies?: Dependency) => State; + /** + * Compute whether the state should update. If this is not set, values + * provided to `next` always update the state. + * @param current the value stored in state + * @param next the value received by the user state subject's `next` member + * @param dependencies the latest value from `Dependencies` + * @returns `true` if the value should be stored, otherwise `false`. + */ + shouldUpdate?: (value: State, next: State, dependencies?: Dependency) => boolean; + } +>; 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 a441505b35..73971da4ef 100644 --- a/libs/common/src/tools/state/user-state-subject.spec.ts +++ b/libs/common/src/tools/state/user-state-subject.spec.ts @@ -2,13 +2,37 @@ import { BehaviorSubject, of, Subject } from "rxjs"; import { UserId } from "@bitwarden/common/types/guid"; -import { awaitAsync, FakeSingleUserState } from "../../../spec"; +import { awaitAsync, FakeSingleUserState, ObservableTracker } from "../../../spec"; +import { StateConstraints } from "../types"; import { UserStateSubject } from "./user-state-subject"; const SomeUser = "some user" as UserId; type TestType = { foo: string }; +function fooMaxLength(maxLength: number): StateConstraints { + return Object.freeze({ + constraints: { foo: { maxLength } }, + adjust: function (state: TestType): TestType { + return { + foo: state.foo.slice(0, this.constraints.foo.maxLength), + }; + }, + fix: function (state: TestType): TestType { + return { + foo: `finalized|${state.foo.slice(0, this.constraints.foo.maxLength)}`, + }; + }, + }); +} + +const DynamicFooMaxLength = Object.freeze({ + expected: fooMaxLength(0), + calibrate(state: TestType) { + return this.expected; + }, +}); + describe("UserStateSubject", () => { describe("dependencies", () => { it("ignores repeated when$ emissions", async () => { @@ -54,6 +78,19 @@ describe("UserStateSubject", () => { expect(nextValue).toHaveBeenCalledTimes(1); }); + + it("waits for constraints$", async () => { + const state = new FakeSingleUserState(SomeUser, { foo: "init" }); + const singleUserId$ = new BehaviorSubject(SomeUser); + const constraints$ = new Subject>(); + const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const tracker = new ObservableTracker(subject); + + constraints$.next(fooMaxLength(3)); + const [initResult] = await tracker.pauseUntilReceived(1); + + expect(initResult).toEqual({ foo: "ini" }); + }); }); describe("next", () => { @@ -246,6 +283,116 @@ describe("UserStateSubject", () => { expect(nextValue).toHaveBeenCalled(); }); + + it("applies constraints$ on init", async () => { + const state = new FakeSingleUserState(SomeUser, { foo: "init" }); + const singleUserId$ = new BehaviorSubject(SomeUser); + const constraints$ = new BehaviorSubject(fooMaxLength(2)); + const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const tracker = new ObservableTracker(subject); + + const [result] = await tracker.pauseUntilReceived(1); + + expect(result).toEqual({ foo: "in" }); + }); + + it("applies dynamic constraints", async () => { + const state = new FakeSingleUserState(SomeUser, { foo: "init" }); + const singleUserId$ = new BehaviorSubject(SomeUser); + const constraints$ = new BehaviorSubject(DynamicFooMaxLength); + const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const tracker = new ObservableTracker(subject); + const expected: TestType = { foo: "next" }; + const emission = tracker.expectEmission(); + + subject.next(expected); + const actual = await emission; + + expect(actual).toEqual({ foo: "" }); + }); + + it("applies constraints$ on constraints$ emission", async () => { + const state = new FakeSingleUserState(SomeUser, { foo: "init" }); + const singleUserId$ = new BehaviorSubject(SomeUser); + const constraints$ = new BehaviorSubject(fooMaxLength(2)); + const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const tracker = new ObservableTracker(subject); + + constraints$.next(fooMaxLength(1)); + const [, result] = await tracker.pauseUntilReceived(2); + + expect(result).toEqual({ foo: "i" }); + }); + + it("applies constraints$ on next", async () => { + const state = new FakeSingleUserState(SomeUser, { foo: "init" }); + const singleUserId$ = new BehaviorSubject(SomeUser); + const constraints$ = new BehaviorSubject(fooMaxLength(2)); + const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const tracker = new ObservableTracker(subject); + + subject.next({ foo: "next" }); + const [, result] = await tracker.pauseUntilReceived(2); + + expect(result).toEqual({ foo: "ne" }); + }); + + it("applies latest constraints$ on next", async () => { + const state = new FakeSingleUserState(SomeUser, { foo: "init" }); + const singleUserId$ = new BehaviorSubject(SomeUser); + const constraints$ = new BehaviorSubject(fooMaxLength(2)); + const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const tracker = new ObservableTracker(subject); + + constraints$.next(fooMaxLength(3)); + subject.next({ foo: "next" }); + const [, , result] = await tracker.pauseUntilReceived(3); + + expect(result).toEqual({ foo: "nex" }); + }); + + it("waits for constraints$", async () => { + const state = new FakeSingleUserState(SomeUser, { foo: "init" }); + const singleUserId$ = new BehaviorSubject(SomeUser); + const constraints$ = new Subject>(); + const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const tracker = new ObservableTracker(subject); + + subject.next({ foo: "next" }); + constraints$.next(fooMaxLength(3)); + // `init` is also waiting and is processed before `next` + const [, nextResult] = await tracker.pauseUntilReceived(2); + + expect(nextResult).toEqual({ foo: "nex" }); + }); + + it("uses the last-emitted value from constraints$ when constraints$ errors", async () => { + const state = new FakeSingleUserState(SomeUser, { foo: "init" }); + const singleUserId$ = new BehaviorSubject(SomeUser); + const constraints$ = new BehaviorSubject(fooMaxLength(3)); + const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const tracker = new ObservableTracker(subject); + + constraints$.error({ some: "error" }); + subject.next({ foo: "next" }); + const [, nextResult] = await tracker.pauseUntilReceived(1); + + expect(nextResult).toEqual({ foo: "nex" }); + }); + + it("uses the last-emitted value from constraints$ when constraints$ completes", async () => { + const state = new FakeSingleUserState(SomeUser, { foo: "init" }); + const singleUserId$ = new BehaviorSubject(SomeUser); + const constraints$ = new BehaviorSubject(fooMaxLength(3)); + const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const tracker = new ObservableTracker(subject); + + constraints$.complete(); + subject.next({ foo: "next" }); + const [, nextResult] = await tracker.pauseUntilReceived(1); + + expect(nextResult).toEqual({ foo: "nex" }); + }); }); describe("error", () => { @@ -474,4 +621,150 @@ describe("UserStateSubject", () => { expect(subject.userId).toEqual(SomeUser); }); }); + + describe("withConstraints$", () => { + it("emits the next value with an empty constraint", async () => { + const state = new FakeSingleUserState(SomeUser, { foo: "init" }); + const singleUserId$ = new BehaviorSubject(SomeUser); + const subject = new UserStateSubject(state, { singleUserId$ }); + const tracker = new ObservableTracker(subject.withConstraints$); + const expected: TestType = { foo: "next" }; + const emission = tracker.expectEmission(); + + subject.next(expected); + const actual = await emission; + + expect(actual.state).toEqual(expected); + expect(actual.constraints).toEqual({}); + }); + + it("ceases emissions once the subject completes", async () => { + const initialState = { foo: "init" }; + const state = new FakeSingleUserState(SomeUser, initialState); + const singleUserId$ = new BehaviorSubject(SomeUser); + const subject = new UserStateSubject(state, { singleUserId$ }); + const tracker = new ObservableTracker(subject.withConstraints$); + + subject.complete(); + subject.next({ foo: "ignored" }); + const [result] = await tracker.pauseUntilReceived(1); + + expect(result.state).toEqual(initialState); + expect(tracker.emissions.length).toEqual(1); + }); + + it("emits constraints$ on constraints$ emission", async () => { + const state = new FakeSingleUserState(SomeUser, { foo: "init" }); + const singleUserId$ = new BehaviorSubject(SomeUser); + const constraints$ = new BehaviorSubject(fooMaxLength(2)); + const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const tracker = new ObservableTracker(subject.withConstraints$); + const expected = fooMaxLength(1); + const emission = tracker.expectEmission(); + + constraints$.next(expected); + const result = await emission; + + expect(result.state).toEqual({ foo: "i" }); + expect(result.constraints).toEqual(expected.constraints); + }); + + it("emits dynamic constraints", async () => { + const state = new FakeSingleUserState(SomeUser, { foo: "init" }); + const singleUserId$ = new BehaviorSubject(SomeUser); + const constraints$ = new BehaviorSubject(DynamicFooMaxLength); + const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const tracker = new ObservableTracker(subject.withConstraints$); + const expected: TestType = { foo: "next" }; + const emission = tracker.expectEmission(); + + subject.next(expected); + const actual = await emission; + + expect(actual.state).toEqual({ foo: "" }); + expect(actual.constraints).toEqual(DynamicFooMaxLength.expected.constraints); + }); + + it("emits constraints$ on next", async () => { + const state = new FakeSingleUserState(SomeUser, { foo: "init" }); + const singleUserId$ = new BehaviorSubject(SomeUser); + const expected = fooMaxLength(2); + const constraints$ = new BehaviorSubject(expected); + const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const tracker = new ObservableTracker(subject.withConstraints$); + const emission = tracker.expectEmission(); + + subject.next({ foo: "next" }); + const result = await emission; + + expect(result.state).toEqual({ foo: "ne" }); + expect(result.constraints).toEqual(expected.constraints); + }); + + it("emits the latest constraints$ on next", async () => { + const state = new FakeSingleUserState(SomeUser, { foo: "init" }); + const singleUserId$ = new BehaviorSubject(SomeUser); + const constraints$ = new BehaviorSubject(fooMaxLength(2)); + const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const tracker = new ObservableTracker(subject.withConstraints$); + const expected = fooMaxLength(3); + constraints$.next(expected); + + const emission = tracker.expectEmission(); + subject.next({ foo: "next" }); + const result = await emission; + + expect(result.state).toEqual({ foo: "nex" }); + expect(result.constraints).toEqual(expected.constraints); + }); + + it("waits for constraints$", async () => { + const state = new FakeSingleUserState(SomeUser, { foo: "init" }); + const singleUserId$ = new BehaviorSubject(SomeUser); + const constraints$ = new Subject>(); + const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const tracker = new ObservableTracker(subject.withConstraints$); + const expected = fooMaxLength(3); + + subject.next({ foo: "next" }); + constraints$.next(expected); + // `init` is also waiting and is processed before `next` + const [, nextResult] = await tracker.pauseUntilReceived(2); + + expect(nextResult.state).toEqual({ foo: "nex" }); + expect(nextResult.constraints).toEqual(expected.constraints); + }); + + it("emits the last-emitted value from constraints$ when constraints$ errors", async () => { + const state = new FakeSingleUserState(SomeUser, { foo: "init" }); + const singleUserId$ = new BehaviorSubject(SomeUser); + const expected = fooMaxLength(3); + const constraints$ = new BehaviorSubject(expected); + const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const tracker = new ObservableTracker(subject.withConstraints$); + + constraints$.error({ some: "error" }); + subject.next({ foo: "next" }); + const [, nextResult] = await tracker.pauseUntilReceived(1); + + expect(nextResult.state).toEqual({ foo: "nex" }); + expect(nextResult.constraints).toEqual(expected.constraints); + }); + + it("emits the last-emitted value from constraints$ when constraints$ completes", async () => { + const state = new FakeSingleUserState(SomeUser, { foo: "init" }); + const singleUserId$ = new BehaviorSubject(SomeUser); + const expected = fooMaxLength(3); + const constraints$ = new BehaviorSubject(expected); + const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const tracker = new ObservableTracker(subject.withConstraints$); + + constraints$.complete(); + subject.next({ foo: "next" }); + const [, nextResult] = await tracker.pauseUntilReceived(1); + + expect(nextResult.state).toEqual({ foo: "nex" }); + expect(nextResult.constraints).toEqual(expected.constraints); + }); + }); }); diff --git a/libs/common/src/tools/state/user-state-subject.ts b/libs/common/src/tools/state/user-state-subject.ts index 659eb94947..61a9e87c68 100644 --- a/libs/common/src/tools/state/user-state-subject.ts +++ b/libs/common/src/tools/state/user-state-subject.ts @@ -17,37 +17,20 @@ import { startWith, Observable, Subscription, + last, + concat, + combineLatestWith, + catchError, + EMPTY, } from "rxjs"; -import { Simplify } from "type-fest"; import { SingleUserState } from "@bitwarden/common/platform/state"; -import { Dependencies, SingleUserDependency, WhenDependency } from "../dependencies"; +import { WithConstraints } from "../types"; -/** dependencies accepted by the user state subject */ -export type UserStateSubjectDependencies = Simplify< - SingleUserDependency & - Partial & - Partial> & { - /** Compute the next stored value. If this is not set, values - * provided to `next` unconditionally override state. - * @param current the value stored in state - * @param next the value received by the user state subject's `next` member - * @param dependencies the latest value from `Dependencies` - * @returns the value to store in state - */ - nextValue?: (current: State, next: State, dependencies?: Dependency) => State; - /** - * Compute whether the state should update. If this is not set, values - * provided to `next` always update the state. - * @param current the value stored in state - * @param next the value received by the user state subject's `next` member - * @param dependencies the latest value from `Dependencies` - * @returns `true` if the value should be stored, otherwise `false`. - */ - shouldUpdate?: (value: State, next: State, dependencies?: Dependency) => boolean; - } ->; +import { IdentityConstraint } from "./identity-state-constraint"; +import { isDynamic } from "./state-constraints-dependency"; +import { UserStateSubjectDependencies } from "./user-state-subject-dependencies"; /** * Adapt a state provider to an rxjs subject. @@ -61,7 +44,7 @@ export type UserStateSubjectDependencies = Simplify< * @template State the state stored by the subject * @template Dependencies use-specific dependencies provided by the user. */ -export class UserStateSubject +export class UserStateSubject extends Observable implements SubjectLike { @@ -99,6 +82,35 @@ export class UserStateSubject }), distinctUntilChanged(), ); + const constraints$ = ( + this.dependencies.constraints$ ?? new BehaviorSubject(new IdentityConstraint()) + ).pipe( + // FIXME: this should probably log that an error occurred + catchError(() => EMPTY), + ); + + // normalize input in case this `UserStateSubject` is not the only + // observer of the backing store + const input$ = combineLatest([this.input, constraints$]).pipe( + map(([input, constraints]) => { + const calibration = isDynamic(constraints) ? constraints.calibrate(input) : constraints; + const state = calibration.adjust(input); + return state; + }), + ); + + // when the output subscription completes, its last-emitted value + // loops around to the input for finalization + const finalize$ = this.pipe( + last(), + combineLatestWith(constraints$), + map(([output, constraints]) => { + const calibration = isDynamic(constraints) ? constraints.calibrate(output) : constraints; + const state = calibration.fix(output); + return state; + }), + ); + const updates$ = concat(input$, finalize$); // observe completion const whenComplete$ = when$.pipe(ignoreElements(), endWith(true)); @@ -106,9 +118,24 @@ export class UserStateSubject const userIdComplete$ = this.dependencies.singleUserId$.pipe(ignoreElements(), endWith(true)); const completion$ = race(whenComplete$, inputComplete$, userIdComplete$); - // wire subscriptions - this.outputSubscription = this.state.state$.subscribe(this.output); - this.inputSubscription = combineLatest([this.input, when$, userIdAvailable$]) + // wire output before input so that output normalizes the current state + // before any `next` value is processed + this.outputSubscription = this.state.state$ + .pipe( + combineLatestWith(constraints$), + map(([rawState, constraints]) => { + const calibration = isDynamic(constraints) + ? constraints.calibrate(rawState) + : constraints; + const state = calibration.adjust(rawState); + return { + constraints: calibration.constraints, + state, + }; + }), + ) + .subscribe(this.output); + this.inputSubscription = combineLatest([updates$, when$, userIdAvailable$]) .pipe( filter(([_, when]) => when), map(([state]) => state), @@ -144,14 +171,19 @@ export class UserStateSubject * @returns the subscription */ subscribe(observer?: Partial> | ((value: State) => void) | null): Subscription { - return this.output.subscribe(observer); + return this.output.pipe(map((wc) => wc.state)).subscribe(observer); } // using subjects to ensure the right semantics are followed; // if greater efficiency becomes desirable, consider implementing // `SubjectLike` directly private input = new Subject(); - private readonly output = new ReplaySubject(1); + private readonly output = new ReplaySubject>(1); + + /** A stream containing settings and their last-applied constraints. */ + get withConstraints$() { + return this.output.asObservable(); + } private inputSubscription: Unsubscribable; private outputSubscription: Unsubscribable; diff --git a/libs/common/src/tools/types.ts b/libs/common/src/tools/types.ts index 83d69edb06..ec1903e622 100644 --- a/libs/common/src/tools/types.ts +++ b/libs/common/src/tools/types.ts @@ -2,8 +2,11 @@ import { Simplify } from "type-fest"; /** Constraints that are shared by all primitive field types */ type PrimitiveConstraint = { - /** presence indicates the field is required */ - required?: true; + /** `true` indicates the field is required; otherwise the field is optional */ + required?: boolean; + + /** `true` indicates the field is immutable; otherwise the field is mutable */ + readonly?: boolean; }; /** Constraints that are shared by string fields */ @@ -23,29 +26,108 @@ type NumberConstraints = { /** 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 */ + /** requires the number be a multiple of the step value; + * this field must be a positive number. +0 and Infinity are + * prohibited. When absent, any number is accepted. + * @remarks set this to `1` to require integer values. + */ step?: number; }; +/** Constraints that are shared by boolean fields */ +type BooleanConstraint = { + /** When present, the boolean field must have the set value. + * When absent or undefined, the boolean field's value is unconstrained. + */ + requiredValue?: boolean; +}; + +/** Utility type that transforms a type T into its supported validators. + */ +export type Constraint = PrimitiveConstraint & + (T extends string + ? StringConstraints + : T extends number + ? NumberConstraints + : T extends boolean + ? BooleanConstraint + : never); + /** 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) - >; + [Key in keyof T]?: Simplify>; }; +/** Utility type that tracks whether a set of constraints was + * produced by an active policy. + */ +export type PolicyConstraints = { + /** When true, the constraints were derived from an active policy. */ + policyInEffect?: boolean; +} & Constraints; + /** utility type for methods that evaluate constraints generically. */ -export type AnyConstraint = PrimitiveConstraint & StringConstraints & NumberConstraints; +export type AnyConstraint = PrimitiveConstraint & + StringConstraints & + NumberConstraints & + BooleanConstraint; + +/** Extends state message with constraints that apply to the message. */ +export type WithConstraints = { + /** the state */ + readonly state: State; + + /** the constraints enforced upon the type. */ + readonly constraints: Constraints; +}; + +/** Creates constraints that are applied automatically to application + * state. + * This type is mutually exclusive with `StateConstraints`. + */ +export type DynamicStateConstraints = { + /** Creates constraints with data derived from the input state + * @param state the state from which the constraints are initialized. + * @remarks this is useful for calculating constraints that + * depend upon values from the input state. You should not send these + * constraints to the UI, because that would prevent the UI from + * offering less restrictive constraints. + */ + calibrate: (state: State) => StateConstraints; +}; + +/** Constraints that are applied automatically to application state. + * This type is mutually exclusive with `DynamicStateConstraints`. + * @remarks this type automatically corrects incoming our outgoing + * data. If you would like to prevent invalid data from being + * applied, use an rxjs filter and evaluate `Constraints` + * instead. + */ +export type StateConstraints = { + /** Well-known constraints of `State` */ + readonly constraints: Readonly>; + + /** Enforces constraints that always hold for the emitted state. + * @remarks This is useful for enforcing "override" constraints, + * such as when a policy requires a value fall within a specific + * range. + * @param state the state pending emission from the subject. + * @return the value emitted by the subject + */ + adjust: (state: State) => State; + + /** Enforces constraints that holds when the subject completes. + * @remarks This is useful for enforcing "default" constraints, + * such as when a policy requires some state is true when data is + * first subscribed, but the state may vary thereafter. + * @param state the state of the subject immediately before + * completion. + * @return the value stored to state upon completion. + */ + fix: (state: State) => State; +}; /** Options that provide contextual information about the application state * when a generator is invoked. diff --git a/libs/tools/generator/components/src/passphrase-settings.component.html b/libs/tools/generator/components/src/passphrase-settings.component.html index c19c03943b..c40df97c69 100644 --- a/libs/tools/generator/components/src/passphrase-settings.component.html +++ b/libs/tools/generator/components/src/passphrase-settings.component.html @@ -5,7 +5,7 @@
- + {{ "numWords" | i18n }} {{ "capitalize" | i18n }} - + {{ "includeNumber" | i18n }} +

{{ "generatorPolicyInEffect" | i18n }}

diff --git a/libs/tools/generator/components/src/passphrase-settings.component.ts b/libs/tools/generator/components/src/passphrase-settings.component.ts index f55cc7ba57..acbd96f10d 100644 --- a/libs/tools/generator/components/src/passphrase-settings.component.ts +++ b/libs/tools/generator/components/src/passphrase-settings.component.ts @@ -23,7 +23,7 @@ const Controls = Object.freeze({ /** Options group for passphrases */ @Component({ standalone: true, - selector: "bit-passphrase-settings", + selector: "tools-passphrase-settings", templateUrl: "passphrase-settings.component.html", imports: [DependenciesModule], }) @@ -81,24 +81,22 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy { this.generatorService .policy$(Generators.Passphrase, { userId$: singleUserId$ }) .pipe(takeUntil(this.destroyed$)) - .subscribe((policy) => { + .subscribe(({ constraints }) => { this.settings .get(Controls.numWords) - .setValidators(toValidators(Controls.numWords, Generators.Passphrase, policy)); + .setValidators(toValidators(Controls.numWords, Generators.Passphrase, constraints)); this.settings .get(Controls.wordSeparator) - .setValidators(toValidators(Controls.wordSeparator, Generators.Passphrase, policy)); + .setValidators(toValidators(Controls.wordSeparator, Generators.Passphrase, constraints)); // 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.minNumWords = constraints.numWords.min; + this.maxNumWords = constraints.numWords.max; + this.policyInEffect = constraints.policyInEffect; - this.toggleEnabled(Controls.capitalize, !policy.policy.capitalize); - this.toggleEnabled(Controls.includeNumber, !policy.policy.includeNumber); + this.toggleEnabled(Controls.capitalize, !constraints.capitalize?.readonly); + this.toggleEnabled(Controls.includeNumber, !constraints.includeNumber?.readonly); }); // now that outputs are set up, connect inputs @@ -111,11 +109,14 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy { /** attribute binding for numWords[max] */ protected maxNumWords: number; + /** display binding for enterprise policy notice */ + protected policyInEffect: boolean; + private toggleEnabled(setting: keyof typeof Controls, enabled: boolean) { if (enabled) { - this.settings.get(setting).enable(); + this.settings.get(setting).enable({ emitEvent: false }); } else { - this.settings.get(setting).disable(); + this.settings.get(setting).disable({ emitEvent: false }); } } diff --git a/libs/tools/generator/components/src/password-generator.component.html b/libs/tools/generator/components/src/password-generator.component.html index db5a1ed379..62bcdfa15d 100644 --- a/libs/tools/generator/components/src/password-generator.component.html +++ b/libs/tools/generator/components/src/password-generator.component.html @@ -13,10 +13,10 @@ -
+
-
+
@@ -30,13 +30,13 @@
- - -
{{ "options" | i18n }}
+
{{ "options" | i18n }}
- + {{ "length" | i18n }} - + {{ "numbersLabel" | i18n }}
- + {{ "avoidAmbiguous" | i18n }} +

{{ "generatorPolicyInEffect" | i18n }}

diff --git a/libs/tools/generator/components/src/password-settings.component.ts b/libs/tools/generator/components/src/password-settings.component.ts index e4f2bb57b8..2553bba3f7 100644 --- a/libs/tools/generator/components/src/password-settings.component.ts +++ b/libs/tools/generator/components/src/password-settings.component.ts @@ -1,6 +1,6 @@ import { OnInit, Input, Output, EventEmitter, Component, OnDestroy } from "@angular/core"; import { FormBuilder } from "@angular/forms"; -import { BehaviorSubject, skip, takeUntil, Subject, map } from "rxjs"; +import { BehaviorSubject, takeUntil, Subject, map, filter, tap, debounceTime, skip } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { UserId } from "@bitwarden/common/types/guid"; @@ -17,7 +17,7 @@ const Controls = Object.freeze({ length: "length", uppercase: "uppercase", lowercase: "lowercase", - numbers: "numbers", + number: "number", special: "special", minNumber: "minNumber", minSpecial: "minSpecial", @@ -27,7 +27,7 @@ const Controls = Object.freeze({ /** Options group for passwords */ @Component({ standalone: true, - selector: "bit-password-settings", + selector: "tools-password-settings", templateUrl: "password-settings.component.html", imports: [DependenciesModule], }) @@ -54,6 +54,10 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy { @Input() showHeader: boolean = true; + /** Number of milliseconds to wait before accepting user input. */ + @Input() + waitMs: number = 100; + /** 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, @@ -66,17 +70,34 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy { [Controls.length]: [Generators.Password.settings.initial.length], [Controls.uppercase]: [Generators.Password.settings.initial.uppercase], [Controls.lowercase]: [Generators.Password.settings.initial.lowercase], - [Controls.numbers]: [Generators.Password.settings.initial.number], + [Controls.number]: [Generators.Password.settings.initial.number], [Controls.special]: [Generators.Password.settings.initial.special], [Controls.minNumber]: [Generators.Password.settings.initial.minNumber], [Controls.minSpecial]: [Generators.Password.settings.initial.minSpecial], [Controls.avoidAmbiguous]: [!Generators.Password.settings.initial.ambiguous], }); + private get numbers() { + return this.settings.get(Controls.number); + } + + private get special() { + return this.settings.get(Controls.special); + } + + private get minNumber() { + return this.settings.get(Controls.minNumber); + } + + private get minSpecial() { + return this.settings.get(Controls.minSpecial); + } + async ngOnInit() { const singleUserId$ = this.singleUserId$(); const settings = await this.generatorService.settings(Generators.Password, { singleUserId$ }); + // bind settings to the UI settings .pipe( map((settings) => { @@ -93,47 +114,41 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy { 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); - - /// + // bind policy to the template this.generatorService .policy$(Generators.Password, { userId$: singleUserId$ }) .pipe(takeUntil(this.destroyed$)) - .subscribe((policy) => { + .subscribe(({ constraints }) => { this.settings .get(Controls.length) - .setValidators(toValidators(Controls.length, Generators.Password, policy)); + .setValidators(toValidators(Controls.length, Generators.Password, constraints)); - this.settings - .get(Controls.minNumber) - .setValidators(toValidators(Controls.minNumber, Generators.Password, policy)); + this.minNumber.setValidators( + toValidators(Controls.minNumber, Generators.Password, constraints), + ); - this.settings - .get(Controls.minSpecial) - .setValidators(toValidators(Controls.minSpecial, Generators.Password, policy)); + this.minSpecial.setValidators( + toValidators(Controls.minSpecial, Generators.Password, constraints), + ); // 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.minLength = policy.length?.min ?? Generators.Password.settings.constraints.length.min; - this.maxLength = policy.length?.max ?? Generators.Password.settings.constraints.length.max; - this.minMinNumber = - policy.minNumber?.min ?? Generators.Password.settings.constraints.minNumber.min; - this.maxMinNumber = - policy.minNumber?.max ?? Generators.Password.settings.constraints.minNumber.max; - this.minMinSpecial = - policy.minSpecial?.min ?? Generators.Password.settings.constraints.minSpecial.min; - this.maxMinSpecial = - policy.minSpecial?.max ?? Generators.Password.settings.constraints.minSpecial.max; + this.minLength = constraints.length.min; + this.maxLength = constraints.length.max; + this.minMinNumber = constraints.minNumber.min; + this.maxMinNumber = constraints.minNumber.max; + this.minMinSpecial = constraints.minSpecial.min; + this.maxMinSpecial = constraints.minSpecial.max; + + this.policyInEffect = constraints.policyInEffect; const toggles = [ - [Controls.length, policy.length.min < policy.length.max], - [Controls.uppercase, !policy.policy.useUppercase], - [Controls.lowercase, !policy.policy.useLowercase], - [Controls.numbers, !policy.policy.useNumbers], - [Controls.special, !policy.policy.useSpecial], - [Controls.minNumber, policy.minNumber.min < policy.minNumber.max], - [Controls.minSpecial, policy.minSpecial.min < policy.minSpecial.max], + [Controls.length, constraints.length.min < constraints.length.max], + [Controls.uppercase, !constraints.uppercase?.readonly], + [Controls.lowercase, !constraints.lowercase?.readonly], + [Controls.number, !constraints.number?.readonly], + [Controls.special, !constraints.special?.readonly], + [Controls.minNumber, constraints.minNumber.min < constraints.minNumber.max], + [Controls.minSpecial, constraints.minSpecial.min < constraints.minSpecial.max], ] as [keyof typeof Controls, boolean][]; for (const [control, enabled] of toggles) { @@ -141,9 +156,53 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy { } }); + // cascade selections between checkboxes and spinboxes + // before the group saves their values + let lastMinNumber = 1; + this.numbers.valueChanges + .pipe( + filter((checked) => !(checked && this.minNumber.value > 0)), + map((checked) => (checked ? lastMinNumber : 0)), + takeUntil(this.destroyed$), + ) + .subscribe((value) => this.minNumber.setValue(value, { emitEvent: false })); + + this.minNumber.valueChanges + .pipe( + map((value) => [value, value > 0] as const), + tap(([value]) => (lastMinNumber = this.numbers.value ? value : lastMinNumber)), + takeUntil(this.destroyed$), + ) + .subscribe(([, checked]) => this.numbers.setValue(checked, { emitEvent: false })); + + let lastMinSpecial = 1; + this.special.valueChanges + .pipe( + filter((checked) => !(checked && this.minSpecial.value > 0)), + map((checked) => (checked ? lastMinSpecial : 0)), + takeUntil(this.destroyed$), + ) + .subscribe((value) => this.minSpecial.setValue(value, { emitEvent: false })); + + this.minSpecial.valueChanges + .pipe( + map((value) => [value, value > 0] as const), + tap(([value]) => (lastMinSpecial = this.special.value ? value : lastMinSpecial)), + takeUntil(this.destroyed$), + ) + .subscribe(([, checked]) => this.special.setValue(checked, { emitEvent: false })); + + // `onUpdated` depends on `settings` because the UserStateSubject is asynchronous; + // subscribing directly to `this.settings.valueChanges` introduces a race condition. + // skip the first emission because it's the initial value, not an update. + settings.pipe(skip(1), takeUntil(this.destroyed$)).subscribe(this.onUpdated); + // now that outputs are set up, connect inputs this.settings.valueChanges .pipe( + // debounce ensures rapid edits to a field, such as partial edits to a + // spinbox or rapid button clicks don't emit spurious generator updates + debounceTime(this.waitMs), map((settings) => { // interface is "avoid" while storage is "include" const s: any = { ...settings }; @@ -174,11 +233,14 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy { /** attribute binding for minSpecial[max] */ protected maxMinSpecial: number; + /** display binding for enterprise policy notice */ + protected policyInEffect: boolean; + private toggleEnabled(setting: keyof typeof Controls, enabled: boolean) { if (enabled) { - this.settings.get(setting).enable(); + this.settings.get(setting).enable({ emitEvent: false }); } else { - this.settings.get(setting).disable(); + this.settings.get(setting).disable({ emitEvent: false }); } } diff --git a/libs/tools/generator/components/src/util.ts b/libs/tools/generator/components/src/util.ts index 07d6277c0c..2049a285e2 100644 --- a/libs/tools/generator/components/src/util.ts +++ b/libs/tools/generator/components/src/util.ts @@ -1,5 +1,5 @@ import { ValidatorFn, Validators } from "@angular/forms"; -import { map, pairwise, pipe, skipWhile, startWith, takeWhile } from "rxjs"; +import { distinctUntilChanged, map, pairwise, pipe, skipWhile, startWith, takeWhile } from "rxjs"; import { AnyConstraint, Constraints } from "@bitwarden/common/tools/types"; import { UserId } from "@bitwarden/common/types/guid"; @@ -13,6 +13,7 @@ export function completeOnAccountSwitch() { pairwise(), takeWhile(([prev, next]) => (prev ?? next) === next), map(([_, id]) => id), + distinctUntilChanged(), ); } diff --git a/libs/tools/generator/core/src/data/default-password-generation-options.ts b/libs/tools/generator/core/src/data/default-password-generation-options.ts index 1c26fd8f95..7ddee5f8d8 100644 --- a/libs/tools/generator/core/src/data/default-password-generation-options.ts +++ b/libs/tools/generator/core/src/data/default-password-generation-options.ts @@ -1,9 +1,10 @@ -import { PasswordGenerationOptions } from "../types"; +import { PasswordGenerationOptions, PasswordGeneratorSettings } from "../types"; import { DefaultPasswordBoundaries } from "./default-password-boundaries"; /** The default options for password generation. */ -export const DefaultPasswordGenerationOptions: Partial = Object.freeze({ +export const DefaultPasswordGenerationOptions: Partial & + PasswordGeneratorSettings = Object.freeze({ length: 14, minLength: DefaultPasswordBoundaries.length.min, ambiguous: true, diff --git a/libs/tools/generator/core/src/data/policies.ts b/libs/tools/generator/core/src/data/policies.ts index ed5e6c4e5a..4d758fc465 100644 --- a/libs/tools/generator/core/src/data/policies.ts +++ b/libs/tools/generator/core/src/data/policies.ts @@ -1,9 +1,11 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { + DynamicPasswordPolicyConstraints, passphraseLeastPrivilege, passwordLeastPrivilege, PassphraseGeneratorOptionsEvaluator, + PassphrasePolicyConstraints, PasswordGeneratorOptionsEvaluator, } from "../policies"; import { @@ -23,7 +25,7 @@ const PASSPHRASE = Object.freeze({ }), combine: passphraseLeastPrivilege, createEvaluator: (policy) => new PassphraseGeneratorOptionsEvaluator(policy), - createEvaluatorV2: (policy) => new PassphraseGeneratorOptionsEvaluator(policy), + toConstraints: (policy) => new PassphrasePolicyConstraints(policy), } as PolicyConfiguration); const PASSWORD = Object.freeze({ @@ -39,7 +41,7 @@ const PASSWORD = Object.freeze({ }), combine: passwordLeastPrivilege, createEvaluator: (policy) => new PasswordGeneratorOptionsEvaluator(policy), - createEvaluatorV2: (policy) => new PasswordGeneratorOptionsEvaluator(policy), + toConstraints: (policy) => new DynamicPasswordPolicyConstraints(policy), } as PolicyConfiguration); /** Policy configurations */ diff --git a/libs/tools/generator/core/src/policies/constraints.spec.ts b/libs/tools/generator/core/src/policies/constraints.spec.ts new file mode 100644 index 0000000000..38257bc314 --- /dev/null +++ b/libs/tools/generator/core/src/policies/constraints.spec.ts @@ -0,0 +1,280 @@ +import { Constraint } from "@bitwarden/common/tools/types"; + +import { + atLeast, + atLeastSum, + maybe, + maybeReadonly, + fitToBounds, + enforceConstant, + fitLength, + readonlyTrueWhen, + RequiresTrue, +} from "./constraints"; + +const SomeBooleanConstraint: Constraint = Object.freeze({}); + +describe("password generator constraint utilities", () => { + describe("atLeast", () => { + it("creates a minimum constraint when constraint is undefined", () => { + const result = atLeast(1); + + expect(result).toEqual({ min: 1 }); + }); + + it("returns the constraint when minimum is undefined", () => { + const constraint = {}; + const result = atLeast(undefined, constraint); + + expect(result).toBe(constraint); + }); + + it("adds a minimum member to a constraint", () => { + const result = atLeast(1, {}); + + expect(result).toEqual({ min: 1 }); + }); + + it("adjusts the minimum member of a constraint to the minimum value", () => { + const result = atLeast(2, { min: 1 }); + + expect(result).toEqual({ min: 2 }); + }); + + it("adjusts the maximum member of a constraint to the minimum value", () => { + const result = atLeast(2, { min: 0, max: 1 }); + + expect(result).toEqual({ min: 2, max: 2 }); + }); + + it("copies the constraint", () => { + const constraint = { min: 1, step: 1 }; + + const result = atLeast(1, constraint); + + expect(result).not.toBe(constraint); + expect(result).toEqual({ min: 1, step: 1 }); + }); + }); + + describe("atLeastSum", () => { + it("creates a minimum constraint", () => { + const result = atLeastSum(undefined, []); + + expect(result).toEqual({ min: 0 }); + }); + + it("creates a minimum constraint that is the sum of the dependencies' minimums", () => { + const result = atLeastSum(undefined, [{ min: 1 }, { min: 1 }]); + + expect(result).toEqual({ min: 2 }); + }); + + it("adds a minimum member to a constraint", () => { + const result = atLeastSum({}, []); + + expect(result).toEqual({ min: 0 }); + }); + + it("adjusts the minimum member of a constraint to the minimum sum", () => { + const result = atLeastSum({ min: 0 }, [{ min: 1 }]); + + expect(result).toEqual({ min: 1 }); + }); + + it("adjusts the maximum member of a constraint to the minimum sum", () => { + const result = atLeastSum({ min: 0, max: 1 }, [{ min: 2 }]); + + expect(result).toEqual({ min: 2, max: 2 }); + }); + + it("copies the constraint", () => { + const constraint = { step: 1 }; + + const result = atLeastSum(constraint, []); + + expect(result).not.toBe(constraint); + expect(result).toEqual({ min: 0, step: 1 }); + }); + }); + + describe("maybe", () => { + it("returns the constraint when it is enabled", () => { + const result = maybe(true, SomeBooleanConstraint); + + expect(result).toBe(SomeBooleanConstraint); + }); + + it("returns undefined when the constraint is disabled", () => { + const result = maybe(false, SomeBooleanConstraint); + + expect(result).toBeUndefined(); + }); + }); + + describe("maybeReadonly", () => { + it("returns the constraint when readonly is false", () => { + const result = maybeReadonly(false, SomeBooleanConstraint); + + expect(result).toBe(SomeBooleanConstraint); + }); + + it("adds a readonly member when readonly is true", () => { + const result = maybeReadonly(true, SomeBooleanConstraint); + + expect(result).toMatchObject({ readonly: true }); + }); + + it("copies the constraint when readonly is true", () => { + const result = maybeReadonly(true, { requiredValue: true }); + + expect(result).not.toBe(SomeBooleanConstraint); + expect(result).toMatchObject({ readonly: true, requiredValue: true }); + }); + + it("crates a readonly constraint when the input is undefined", () => { + const result = maybeReadonly(true); + + expect(result).not.toBe(SomeBooleanConstraint); + expect(result).toEqual({ readonly: true }); + }); + }); + + describe("fitToBounds", () => { + it("returns the value when the constraint is undefined", () => { + const result = fitToBounds(1, undefined); + + expect(result).toEqual(1); + }); + + it("applies the maximum bound", () => { + const result = fitToBounds(2, { max: 1 }); + + expect(result).toEqual(1); + }); + + it("applies the minimum bound", () => { + const result = fitToBounds(0, { min: 1 }); + + expect(result).toEqual(1); + }); + + it.each([[0], [1]])( + "returns 0 when value is undefined and 0 <= the maximum bound (= %p)", + (max) => { + const result = fitToBounds(undefined, { max }); + + expect(result).toEqual(0); + }, + ); + + it.each([[0], [-1]])( + "returns 0 when value is undefined and 0 >= the minimum bound (= %p)", + (min) => { + const result = fitToBounds(undefined, { min }); + + expect(result).toEqual(0); + }, + ); + + it("returns the maximum bound when value is undefined and 0 > the maximum bound", () => { + const result = fitToBounds(undefined, { max: -1 }); + + expect(result).toEqual(-1); + }); + + it("returns the minimum bound when value is undefined and 0 < the minimum bound", () => { + const result = fitToBounds(undefined, { min: 1 }); + + expect(result).toEqual(1); + }); + }); + + describe("fitLength", () => { + it("returns the value when the constraint is undefined", () => { + const result = fitLength("someValue", undefined); + + expect(result).toEqual("someValue"); + }); + + it.each([[null], [undefined]])( + "returns an empty string when the value is nullish (= %p)", + (value: string) => { + const result = fitLength(value, {}); + + expect(result).toEqual(""); + }, + ); + + it("applies the maxLength bound", () => { + const result = fitLength("some value", { maxLength: 4 }); + + expect(result).toEqual("some"); + }); + + it("applies the minLength bound", () => { + const result = fitLength("some", { minLength: 5 }); + + expect(result).toEqual("some "); + }); + + it("fills characters from the fillString", () => { + const result = fitLength("some", { minLength: 10 }, { fillString: " value" }); + + expect(result).toEqual("some value"); + }); + + it("repeats characters from the fillString", () => { + const result = fitLength("i", { minLength: 3 }, { fillString: "+" }); + + expect(result).toEqual("i++"); + }); + }); + + describe("enforceConstant", () => { + it("returns the requiredValue member from a readonly constraint", () => { + const result = enforceConstant(false, { readonly: true, requiredValue: true }); + + expect(result).toBeTruthy(); + }); + + it("returns undefined from a readonly constraint without a required value", () => { + const result = enforceConstant(false, { readonly: true }); + + expect(result).toBeUndefined(); + }); + + it.each([[{}], [{ readonly: false }]])( + "returns value when the constraint is writable (= %p)", + (constraint) => { + const result = enforceConstant(false, constraint); + + expect(result).toBeFalsy(); + }, + ); + + it("returns value when the constraint is undefined", () => { + const result = enforceConstant(false, undefined); + + expect(result).toBeFalsy(); + }); + }); + + describe("readonlyTrueWhen", () => { + it.each([[false], [null], [undefined]])( + "returns undefined when enabled is falsy (= %p)", + (value) => { + const result = readonlyTrueWhen(value); + + expect(result).toBeUndefined(); + }, + ); + + it("returns a readonly RequiresTrue when enabled is true", () => { + const result = readonlyTrueWhen(true); + + expect(result).toMatchObject({ readonly: true }); + expect(result).toMatchObject(RequiresTrue); + }); + }); +}); diff --git a/libs/tools/generator/core/src/policies/constraints.ts b/libs/tools/generator/core/src/policies/constraints.ts new file mode 100644 index 0000000000..6071b57048 --- /dev/null +++ b/libs/tools/generator/core/src/policies/constraints.ts @@ -0,0 +1,164 @@ +import { Constraint } from "@bitwarden/common/tools/types"; + +import { sum } from "../util"; + +const AtLeastOne: Constraint = { min: 1 }; +const RequiresTrue: Constraint = { requiredValue: true }; + +/** Ensures the minimum and maximum bounds of a constraint are at least as large as the + * combined minimum bounds of `dependencies`. + * @param current the constraint extended by the combinator. + * @param dependencies the constraints summed to determine the bounds of `current`. + * @returns a copy of `current` with the new bounds applied. + * + */ +function atLeastSum(current: Constraint, dependencies: Constraint[]) { + // length must be at least as long as the required character set + const minConsistentLength = sum(...dependencies.map((c) => c?.min)); + const minLength = Math.max(current?.min ?? 0, minConsistentLength); + const length = atLeast(minLength, current); + + return length; +} + +/** Extends a constraint with a readonly field. + * @param readonly Adds a readonly field when this is `true`. + * @param constraint the constraint extended by the combinator. + * @returns a copy of `constraint` with the readonly constraint applied as-needed. + */ +function maybeReadonly(readonly: boolean, constraint?: Constraint): Constraint { + if (!readonly) { + return constraint; + } + + const result: Constraint = Object.assign({}, constraint ?? {}); + result.readonly = true; + + return result; +} + +/** Conditionally enables a constraint. + * @param enabled the condition to evaluate + * @param constraint the condition to conditionally enable + * @returns `constraint` when `enabled` is true. Otherwise returns `undefined. + */ +function maybe(enabled: boolean, constraint: Constraint): Constraint { + return enabled ? constraint : undefined; +} + +// copies `constraint`; ensures both bounds >= value +/** Ensures the boundaries of a constraint are at least equal to the minimum. + * @param minimum the lower bound of the constraint. When this is `undefined` or `null`, + * the method returns `constraint`. + * @param constraint the constraint to evaluate. When this is `undefined` or `null`, + * the method creates a new constraint. + * @returns a copy of `constraint`. When `minimum` has a value, the returned constraint + * always includes a minimum bound. When `constraint` has a maximum defined, both + * its minimum and maximum are checked against `minimum`. + */ +function atLeast(minimum: number, constraint?: Constraint): Constraint { + if (minimum === undefined || minimum === null) { + return constraint; + } + + const atLeast = { ...(constraint ?? {}) }; + atLeast.min = Math.max(atLeast.min ?? -Infinity, minimum); + + if ("max" in atLeast) { + atLeast.max = Math.max(atLeast.max, minimum); + } + + return atLeast; +} + +/** Ensures a value falls within the minimum and maximum boundaries of a constraint. + * @param value the value to check. Nullish values are coerced to 0. + * @param constraint the constraint to evaluate against. + * @returns If the value is below the minimum constraint, the minimum bound is + * returned. If the value is above the maximum constraint, the maximum bound is + * returned. Otherwise, the value is returned. + */ +function fitToBounds(value: number, constraint: Constraint) { + if (!constraint) { + return value; + } + + const { min, max } = constraint; + + const withUpperBound = Math.min(value ?? 0, max ?? Infinity); + const withLowerBound = Math.max(withUpperBound, min ?? -Infinity); + + return withLowerBound; +} + +/** Fits the length of a string within the minimum and maximum length boundaries + * of a constraint. + * @param value the value to check. Nullish values are coerced to the empty string. + * @param constraint the constraint to evaluate against. + * @param options.fillString a string to fill values from. Defaults to a space. + * When fillString contains multiple characters, each is filled in order. The + * fill string repeats when it gets to the end of the string and there are + * more characters to fill. + * @returns If the value is below the required length, returns a copy padded + * by the fillString. If the value is above the required length, returns a copy + * padded to the maximum length. + * */ +function fitLength( + value: string, + constraint: Constraint, + options?: { fillString?: string }, +) { + if (!constraint) { + return value; + } + + const { minLength, maxLength } = constraint; + const { fillString } = options ?? { fillString: " " }; + + const trimmed = (value ?? "").slice(0, maxLength ?? Infinity); + const result = trimmed.padEnd(minLength ?? trimmed.length, fillString); + + return result; +} + +/** Enforces a readonly field has a required value. + * @param value the value to check. + * @param constraint the constraint to evaluate against. + * @returns If the constraint's readonly field is `true`, returns the + * constraint's required value or `undefined` if none is specified. + * Otherwise returns the value. + * @remarks This method can be used to ensure a conditionally-calculated + * field becomes undefined. Simply specify `readonly` without a `requiredValue` + * then use `??` to perform the calculation. + */ +function enforceConstant(value: boolean, constraint: Constraint) { + if (constraint?.readonly) { + return constraint.requiredValue; + } else { + return value; + } +} + +/** Conditionally create a readonly true value. + * @param enabled When true, create the value. + * @returns When enabled is true, a readonly constraint with a constant value + * of `true`. Otherwise returns `undefined`. + */ +function readonlyTrueWhen(enabled: boolean) { + const readonlyValue = maybeReadonly(enabled, RequiresTrue); + const maybeReadonlyValue = maybe(enabled, readonlyValue); + return maybeReadonlyValue; +} + +export { + atLeast, + atLeastSum, + maybe, + maybeReadonly, + fitToBounds, + enforceConstant, + readonlyTrueWhen, + fitLength, + AtLeastOne, + RequiresTrue, +}; diff --git a/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.spec.ts b/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.spec.ts new file mode 100644 index 0000000000..96f590f8ed --- /dev/null +++ b/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.spec.ts @@ -0,0 +1,262 @@ +import { DefaultPasswordBoundaries, DefaultPasswordGenerationOptions, Policies } from "../data"; + +import { AtLeastOne } from "./constraints"; +import { DynamicPasswordPolicyConstraints } from "./dynamic-password-policy-constraints"; + +describe("DynamicPasswordPolicyConstraints", () => { + describe("constructor", () => { + it("uses default boundaries when the policy is disabled", () => { + const { constraints } = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue); + + expect(constraints.policyInEffect).toBeFalsy(); + expect(constraints.length).toEqual(DefaultPasswordBoundaries.length); + expect(constraints.lowercase).toBeUndefined(); + expect(constraints.uppercase).toBeUndefined(); + expect(constraints.number).toBeUndefined(); + expect(constraints.special).toBeUndefined(); + expect(constraints.minLowercase).toBeUndefined(); + expect(constraints.minUppercase).toBeUndefined(); + expect(constraints.minNumber).toEqual(DefaultPasswordBoundaries.minDigits); + expect(constraints.minSpecial).toEqual(DefaultPasswordBoundaries.minSpecialCharacters); + }); + + it("1 <= minLowercase when the policy requires lowercase", () => { + const policy = { ...Policies.Password.disabledValue, useLowercase: true }; + const { constraints } = new DynamicPasswordPolicyConstraints(policy); + + expect(constraints.policyInEffect).toBeTruthy(); + expect(constraints.lowercase.readonly).toEqual(true); + expect(constraints.lowercase.requiredValue).toEqual(true); + expect(constraints.minLowercase).toEqual({ min: 1 }); + }); + + it("1 <= minUppercase when the policy requires uppercase", () => { + const policy = { ...Policies.Password.disabledValue, useUppercase: true }; + const { constraints } = new DynamicPasswordPolicyConstraints(policy); + + expect(constraints.policyInEffect).toBeTruthy(); + expect(constraints.uppercase.readonly).toEqual(true); + expect(constraints.uppercase.requiredValue).toEqual(true); + expect(constraints.minUppercase).toEqual({ min: 1 }); + }); + + it("1 <= minNumber <= 9 when the policy requires a number", () => { + const policy = { ...Policies.Password.disabledValue, useNumbers: true }; + const { constraints } = new DynamicPasswordPolicyConstraints(policy); + + expect(constraints.policyInEffect).toBeTruthy(); + expect(constraints.number.readonly).toEqual(true); + expect(constraints.number.requiredValue).toEqual(true); + expect(constraints.minNumber).toEqual({ min: 1, max: 9 }); + }); + + it("1 <= minSpecial <= 9 when the policy requires a special character", () => { + const policy = { ...Policies.Password.disabledValue, useSpecial: true }; + const { constraints } = new DynamicPasswordPolicyConstraints(policy); + + expect(constraints.policyInEffect).toBeTruthy(); + expect(constraints.special.readonly).toEqual(true); + expect(constraints.special.requiredValue).toEqual(true); + expect(constraints.minSpecial).toEqual({ min: 1, max: 9 }); + }); + + it("numberCount <= minNumber <= 9 when the policy requires numberCount", () => { + const policy = { ...Policies.Password.disabledValue, useNumbers: true, numberCount: 2 }; + const { constraints } = new DynamicPasswordPolicyConstraints(policy); + + expect(constraints.policyInEffect).toBeTruthy(); + expect(constraints.number.readonly).toEqual(true); + expect(constraints.number.requiredValue).toEqual(true); + expect(constraints.minNumber).toEqual({ min: 2, max: 9 }); + }); + + it("specialCount <= minSpecial <= 9 when the policy requires specialCount", () => { + const policy = { ...Policies.Password.disabledValue, useSpecial: true, specialCount: 2 }; + const { constraints } = new DynamicPasswordPolicyConstraints(policy); + + expect(constraints.policyInEffect).toBeTruthy(); + expect(constraints.special.readonly).toEqual(true); + expect(constraints.special.requiredValue).toEqual(true); + expect(constraints.minSpecial).toEqual({ min: 2, max: 9 }); + }); + + it("uses the policy's minimum length when the policy defines one", () => { + const policy = { ...Policies.Password.disabledValue, minLength: 10 }; + const { constraints } = new DynamicPasswordPolicyConstraints(policy); + + expect(constraints.policyInEffect).toBeTruthy(); + expect(constraints.length).toEqual({ min: 10, max: 128 }); + }); + + it("overrides the minimum length when it is less than the sum of minimums", () => { + const policy = { + ...Policies.Password.disabledValue, + useUppercase: true, + useLowercase: true, + useNumbers: true, + numberCount: 5, + useSpecial: true, + specialCount: 5, + }; + const { constraints } = new DynamicPasswordPolicyConstraints(policy); + + // lower + upper + number + special = 1 + 1 + 5 + 5 = 12 + expect(constraints.length).toEqual({ min: 12, max: 128 }); + }); + }); + + describe("calibrate", () => { + it("copies the boolean constraints into the calibration", () => { + const dynamic = new DynamicPasswordPolicyConstraints({ + ...Policies.Password.disabledValue, + useUppercase: true, + useLowercase: true, + useNumbers: true, + useSpecial: true, + }); + + const calibrated = dynamic.calibrate(DefaultPasswordGenerationOptions); + + expect(calibrated.constraints.uppercase).toEqual(dynamic.constraints.uppercase); + expect(calibrated.constraints.lowercase).toEqual(dynamic.constraints.lowercase); + expect(calibrated.constraints.number).toEqual(dynamic.constraints.number); + expect(calibrated.constraints.special).toEqual(dynamic.constraints.special); + }); + + it.each([[true], [false], [undefined]])( + "outputs at least 1 constraint when the state's lowercase flag is true and useLowercase is %p", + (useLowercase) => { + const dynamic = new DynamicPasswordPolicyConstraints({ + ...Policies.Password.disabledValue, + useLowercase, + }); + const state = { + ...DefaultPasswordGenerationOptions, + lowercase: true, + }; + + const calibrated = dynamic.calibrate(state); + + expect(calibrated.constraints.minLowercase).toEqual(AtLeastOne); + }, + ); + + it("outputs the `minLowercase` constraint when the state's lowercase flag is true and policy is disabled", () => { + const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue); + const state = { + ...DefaultPasswordGenerationOptions, + lowercase: true, + }; + + const calibrated = dynamic.calibrate(state); + + expect(calibrated.constraints.minLowercase).toEqual(AtLeastOne); + }); + + it("disables the minLowercase constraint when the state's lowercase flag is false", () => { + const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue); + const state = { + ...DefaultPasswordGenerationOptions, + lowercase: false, + }; + + const calibrated = dynamic.calibrate(state); + + expect(calibrated.constraints.minLowercase).toBeUndefined(); + }); + + it.each([[true], [false], [undefined]])( + "outputs at least 1 constraint when the state's uppercase flag is true and useUppercase is %p", + (useUppercase) => { + const dynamic = new DynamicPasswordPolicyConstraints({ + ...Policies.Password.disabledValue, + useUppercase, + }); + const state = { + ...DefaultPasswordGenerationOptions, + uppercase: true, + }; + + const calibrated = dynamic.calibrate(state); + + expect(calibrated.constraints.minUppercase).toEqual(AtLeastOne); + }, + ); + + it("disables the minUppercase constraint when the state's uppercase flag is false", () => { + const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue); + const state = { + ...DefaultPasswordGenerationOptions, + uppercase: false, + }; + + const calibrated = dynamic.calibrate(state); + + expect(calibrated.constraints.minUppercase).toBeUndefined(); + }); + + it("outputs the minNumber constraint when the state's number flag is true", () => { + const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue); + const state = { + ...DefaultPasswordGenerationOptions, + number: true, + }; + + const calibrated = dynamic.calibrate(state); + + expect(calibrated.constraints.minNumber).toEqual(dynamic.constraints.minNumber); + }); + + it("disables the minNumber constraint when the state's number flag is false", () => { + const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue); + const state = { + ...DefaultPasswordGenerationOptions, + number: false, + }; + + const calibrated = dynamic.calibrate(state); + + expect(calibrated.constraints.minNumber).toBeUndefined(); + }); + + it("outputs the minSpecial constraint when the state's special flag is true", () => { + const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue); + const state = { + ...DefaultPasswordGenerationOptions, + special: true, + }; + + const calibrated = dynamic.calibrate(state); + + expect(calibrated.constraints.minSpecial).toEqual(dynamic.constraints.minSpecial); + }); + + it("disables the minSpecial constraint when the state's special flag is false", () => { + const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue); + const state = { + ...DefaultPasswordGenerationOptions, + special: false, + }; + + const calibrated = dynamic.calibrate(state); + + expect(calibrated.constraints.minSpecial).toBeUndefined(); + }); + + it("copies the minimum length constraint", () => { + const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue); + + const calibrated = dynamic.calibrate(DefaultPasswordGenerationOptions); + + expect(calibrated.constraints.minSpecial).toBeUndefined(); + }); + + it("overrides the minimum length constraint when it is less than the sum of the state's minimums", () => { + const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue); + + const calibrated = dynamic.calibrate(DefaultPasswordGenerationOptions); + + expect(calibrated.constraints.minSpecial).toBeUndefined(); + }); + }); +}); diff --git a/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.ts b/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.ts new file mode 100644 index 0000000000..daff988254 --- /dev/null +++ b/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.ts @@ -0,0 +1,100 @@ +import { + DynamicStateConstraints, + PolicyConstraints, + StateConstraints, +} from "@bitwarden/common/tools/types"; + +import { DefaultPasswordBoundaries } from "../data"; +import { PasswordGeneratorPolicy, PasswordGeneratorSettings } from "../types"; + +import { atLeast, atLeastSum, maybe, readonlyTrueWhen, AtLeastOne } from "./constraints"; +import { PasswordPolicyConstraints } from "./password-policy-constraints"; + +/** Creates state constraints by blending policy and password settings. */ +export class DynamicPasswordPolicyConstraints + implements DynamicStateConstraints +{ + /** Instantiates the object. + * @param policy the password policy to enforce. This cannot be + * `null` or `undefined`. + */ + constructor(policy: PasswordGeneratorPolicy) { + const minLowercase = maybe(policy.useLowercase, AtLeastOne); + const minUppercase = maybe(policy.useUppercase, AtLeastOne); + + const minNumber = atLeast( + policy.numberCount || (policy.useNumbers && AtLeastOne.min), + DefaultPasswordBoundaries.minDigits, + ); + + const minSpecial = atLeast( + policy.specialCount || (policy.useSpecial && AtLeastOne.min), + DefaultPasswordBoundaries.minSpecialCharacters, + ); + + const baseLength = atLeast(policy.minLength, DefaultPasswordBoundaries.length); + const subLengths = [minLowercase, minUppercase, minNumber, minSpecial]; + const length = atLeastSum(baseLength, subLengths); + + this.constraints = Object.freeze({ + policyInEffect: policyInEffect(policy), + lowercase: readonlyTrueWhen(policy.useLowercase), + uppercase: readonlyTrueWhen(policy.useUppercase), + number: readonlyTrueWhen(policy.useNumbers), + special: readonlyTrueWhen(policy.useSpecial), + length, + minLowercase, + minUppercase, + minNumber, + minSpecial, + }); + } + + /** Constraints derived from the policy and application-defined defaults; + * @remarks these limits are absolute and should be transmitted to the UI + */ + readonly constraints: PolicyConstraints; + + calibrate(state: PasswordGeneratorSettings): StateConstraints { + // decide which constraints are active + const lowercase = state.lowercase || this.constraints.lowercase?.requiredValue || false; + const uppercase = state.uppercase || this.constraints.uppercase?.requiredValue || false; + const number = state.number || this.constraints.number?.requiredValue || false; + const special = state.special || this.constraints.special?.requiredValue || false; + + // minimum constraints cannot `atLeast(state...) because doing so would force + // the constrained value to only increase + const constraints: PolicyConstraints = { + ...this.constraints, + minLowercase: maybe(lowercase, this.constraints.minLowercase ?? AtLeastOne), + minUppercase: maybe(uppercase, this.constraints.minUppercase ?? AtLeastOne), + minNumber: maybe(number, this.constraints.minNumber), + minSpecial: maybe(special, this.constraints.minSpecial), + }; + + // lower bound of length must always at least fit its sub-lengths + constraints.length = atLeastSum(this.constraints.length, [ + atLeast(state.minNumber, constraints.minNumber), + atLeast(state.minSpecial, constraints.minSpecial), + atLeast(state.minLowercase, constraints.minLowercase), + atLeast(state.minUppercase, constraints.minUppercase), + ]); + + const stateConstraints = new PasswordPolicyConstraints(constraints); + return stateConstraints; + } +} + +function policyInEffect(policy: PasswordGeneratorPolicy): boolean { + const policies = [ + policy.useUppercase, + policy.useLowercase, + policy.useNumbers, + policy.useSpecial, + policy.minLength > DefaultPasswordBoundaries.length.min, + policy.numberCount > DefaultPasswordBoundaries.minDigits.min, + policy.specialCount > DefaultPasswordBoundaries.minSpecialCharacters.min, + ]; + + return policies.includes(true); +} diff --git a/libs/tools/generator/core/src/policies/index.ts b/libs/tools/generator/core/src/policies/index.ts index bce363e6da..0d05e70230 100644 --- a/libs/tools/generator/core/src/policies/index.ts +++ b/libs/tools/generator/core/src/policies/index.ts @@ -1,5 +1,7 @@ export { DefaultPolicyEvaluator } from "./default-policy-evaluator"; +export { DynamicPasswordPolicyConstraints } from "./dynamic-password-policy-constraints"; export { PassphraseGeneratorOptionsEvaluator } from "./passphrase-generator-options-evaluator"; +export { PassphrasePolicyConstraints } from "./passphrase-policy-constraints"; export { PasswordGeneratorOptionsEvaluator } from "./password-generator-options-evaluator"; export { passphraseLeastPrivilege } from "./passphrase-least-privilege"; export { passwordLeastPrivilege } from "./password-least-privilege"; diff --git a/libs/tools/generator/core/src/policies/passphrase-policy-constraints.spec.ts b/libs/tools/generator/core/src/policies/passphrase-policy-constraints.spec.ts new file mode 100644 index 0000000000..034a823422 --- /dev/null +++ b/libs/tools/generator/core/src/policies/passphrase-policy-constraints.spec.ts @@ -0,0 +1,134 @@ +import { DefaultPassphraseBoundaries, Policies } from "../data"; + +import { PassphrasePolicyConstraints } from "./passphrase-policy-constraints"; + +const SomeSettings = { + capitalize: false, + includeNumber: false, + numWords: 3, + wordSeparator: "-", +}; + +describe("PassphrasePolicyConstraints", () => { + describe("constructor", () => { + it("uses default boundaries when the policy is disabled", () => { + const { constraints } = new PassphrasePolicyConstraints(Policies.Passphrase.disabledValue); + + expect(constraints.policyInEffect).toBeFalsy(); + expect(constraints.capitalize).toBeUndefined(); + expect(constraints.includeNumber).toBeUndefined(); + expect(constraints.numWords).toEqual(DefaultPassphraseBoundaries.numWords); + }); + + it("requires capitalization when the policy requires capitalization", () => { + const { constraints } = new PassphrasePolicyConstraints({ + ...Policies.Passphrase.disabledValue, + capitalize: true, + }); + + expect(constraints.policyInEffect).toBeTruthy(); + expect(constraints.capitalize).toMatchObject({ readonly: true, requiredValue: true }); + }); + + it("requires a number when the policy requires a number", () => { + const { constraints } = new PassphrasePolicyConstraints({ + ...Policies.Passphrase.disabledValue, + includeNumber: true, + }); + + expect(constraints.policyInEffect).toBeTruthy(); + expect(constraints.includeNumber).toMatchObject({ readonly: true, requiredValue: true }); + }); + + it("minNumberWords <= numWords.min when the policy requires numberCount", () => { + const { constraints } = new PassphrasePolicyConstraints({ + ...Policies.Passphrase.disabledValue, + minNumberWords: 10, + }); + + expect(constraints.policyInEffect).toBeTruthy(); + expect(constraints.numWords).toMatchObject({ + min: 10, + max: DefaultPassphraseBoundaries.numWords.max, + }); + }); + }); + + describe("adjust", () => { + it("allows an empty word separator", () => { + const policy = new PassphrasePolicyConstraints(Policies.Passphrase.disabledValue); + + const { wordSeparator } = policy.adjust({ ...SomeSettings, wordSeparator: "" }); + + expect(wordSeparator).toEqual(""); + }); + + it("takes only the first character of wordSeparator", () => { + const policy = new PassphrasePolicyConstraints(Policies.Passphrase.disabledValue); + + const { wordSeparator } = policy.adjust({ ...SomeSettings, wordSeparator: "?." }); + + expect(wordSeparator).toEqual("?"); + }); + + it.each([ + [1, 3], + [21, 20], + ])("fits numWords (=%p) within the default bounds (3 <= %p <= 20)", (value, expected) => { + const policy = new PassphrasePolicyConstraints(Policies.Passphrase.disabledValue); + + const { numWords } = policy.adjust({ ...SomeSettings, numWords: value }); + + expect(numWords).toEqual(expected); + }); + + it.each([ + [1, 4, 4], + [21, 20, 20], + ])( + "fits numWords (=%p) within the policy bounds (%p <= %p <= 20)", + (value, minNumberWords, expected) => { + const policy = new PassphrasePolicyConstraints({ + ...Policies.Passphrase.disabledValue, + minNumberWords, + }); + + const { numWords } = policy.adjust({ ...SomeSettings, numWords: value }); + + expect(numWords).toEqual(expected); + }, + ); + + it("sets capitalize to true when the policy requires it", () => { + const policy = new PassphrasePolicyConstraints({ + ...Policies.Passphrase.disabledValue, + capitalize: true, + }); + + const { capitalize } = policy.adjust({ ...SomeSettings, capitalize: false }); + + expect(capitalize).toBeTruthy(); + }); + + it("sets includeNumber to true when the policy requires it", () => { + const policy = new PassphrasePolicyConstraints({ + ...Policies.Passphrase.disabledValue, + includeNumber: true, + }); + + const { includeNumber } = policy.adjust({ ...SomeSettings, capitalize: false }); + + expect(includeNumber).toBeTruthy(); + }); + }); + + describe("fix", () => { + it("returns its input", () => { + const policy = new PassphrasePolicyConstraints(Policies.Passphrase.disabledValue); + + const result = policy.fix(SomeSettings); + + expect(result).toBe(SomeSettings); + }); + }); +}); diff --git a/libs/tools/generator/core/src/policies/passphrase-policy-constraints.ts b/libs/tools/generator/core/src/policies/passphrase-policy-constraints.ts new file mode 100644 index 0000000000..27fb76991e --- /dev/null +++ b/libs/tools/generator/core/src/policies/passphrase-policy-constraints.ts @@ -0,0 +1,51 @@ +import { PolicyConstraints, StateConstraints } from "@bitwarden/common/tools/types"; + +import { DefaultPassphraseBoundaries, DefaultPassphraseGenerationOptions } from "../data"; +import { PassphraseGenerationOptions, PassphraseGeneratorPolicy } from "../types"; + +import { atLeast, enforceConstant, fitLength, fitToBounds, readonlyTrueWhen } from "./constraints"; + +export class PassphrasePolicyConstraints implements StateConstraints { + /** Creates a passphrase policy constraints + * @param policy the password policy to enforce. This cannot be + * `null` or `undefined`. + */ + constructor(readonly policy: PassphraseGeneratorPolicy) { + this.constraints = { + policyInEffect: policyInEffect(policy), + wordSeparator: { minLength: 0, maxLength: 1 }, + capitalize: readonlyTrueWhen(policy.capitalize), + includeNumber: readonlyTrueWhen(policy.includeNumber), + numWords: atLeast(policy.minNumberWords, DefaultPassphraseBoundaries.numWords), + }; + } + + constraints: Readonly>; + + adjust(state: PassphraseGenerationOptions): PassphraseGenerationOptions { + const result: PassphraseGenerationOptions = { + wordSeparator: fitLength(state.wordSeparator, this.constraints.wordSeparator, { + fillString: DefaultPassphraseGenerationOptions.wordSeparator, + }), + capitalize: enforceConstant(state.capitalize, this.constraints.capitalize), + includeNumber: enforceConstant(state.includeNumber, this.constraints.includeNumber), + numWords: fitToBounds(state.numWords, this.constraints.numWords), + }; + + return result; + } + + fix(state: PassphraseGenerationOptions): PassphraseGenerationOptions { + return state; + } +} + +function policyInEffect(policy: PassphraseGeneratorPolicy): boolean { + const policies = [ + policy.capitalize, + policy.includeNumber, + policy.minNumberWords > DefaultPassphraseBoundaries.numWords.min, + ]; + + return policies.includes(true); +} diff --git a/libs/tools/generator/core/src/policies/password-policy-constraints.spec.ts b/libs/tools/generator/core/src/policies/password-policy-constraints.spec.ts new file mode 100644 index 0000000000..4d95a36d58 --- /dev/null +++ b/libs/tools/generator/core/src/policies/password-policy-constraints.spec.ts @@ -0,0 +1,130 @@ +import { PasswordGeneratorSettings } from "../types"; + +import { PasswordPolicyConstraints } from "./password-policy-constraints"; + +const EmptyState = { + length: 0, + ambiguous: false, + lowercase: false, + uppercase: false, + number: false, + special: false, + minUppercase: 0, + minLowercase: 0, + minNumber: 0, + minSpecial: 0, +}; + +describe("PasswordPolicyConstraints", () => { + describe("adjust", () => { + it("returns its input when the constraints are empty", () => { + const constraint = new PasswordPolicyConstraints({}); + const expected = { + length: -1, + ambiguous: true, + lowercase: true, + uppercase: true, + number: true, + special: true, + minUppercase: -1, + minLowercase: -1, + minNumber: -1, + minSpecial: -1, + }; + + const result = constraint.adjust(expected); + + expect(result).toEqual(expected); + }); + + it.each([ + ["length", 0, 1], + ["length", 1, 1], + ["length", 2, 2], + ["length", 3, 2], + ["minLowercase", 0, 1], + ["minLowercase", 1, 1], + ["minLowercase", 2, 2], + ["minLowercase", 3, 2], + ["minUppercase", 0, 1], + ["minUppercase", 1, 1], + ["minUppercase", 2, 2], + ["minUppercase", 3, 2], + ["minNumber", 0, 1], + ["minNumber", 1, 1], + ["minNumber", 2, 2], + ["minNumber", 3, 2], + ["minSpecial", 0, 1], + ["minSpecial", 1, 1], + ["minSpecial", 2, 2], + ["minSpecial", 3, 2], + ] as [keyof PasswordGeneratorSettings, number, number][])( + `fits %s (= %p) within the bounds (1 <= %p <= 2)`, + (property, input, expected) => { + const constraint = new PasswordPolicyConstraints({ [property]: { min: 1, max: 2 } }); + const state = { ...EmptyState, [property]: input }; + + const result = constraint.adjust(state); + + expect(result).toMatchObject({ [property]: expected }); + }, + ); + + it.each([["lowercase"], ["uppercase"], ["number"], ["special"]] as [ + keyof PasswordGeneratorSettings, + ][])("returns state.%s when the matching readonly constraint is writable", (property) => { + const constraint = new PasswordPolicyConstraints({ [property]: { readonly: false } }); + const state = { ...EmptyState, [property]: true }; + + const result = constraint.adjust(state); + + expect(result).toEqual({ ...EmptyState, [property]: true }); + }); + + it("sets `lowercase` and `uppercase` to `true` when no flags are defined", () => { + const constraint = new PasswordPolicyConstraints({}); + const result = constraint.adjust(EmptyState); + + expect(result).toMatchObject({ lowercase: true, uppercase: true }); + }); + + it.each([["number"], ["special"]] as [keyof PasswordGeneratorSettings][])( + "returns a consistent state.%s = undefined when the matching readonly constraint is active without a required value", + (property) => { + const constraint = new PasswordPolicyConstraints({ [property]: { readonly: true } }); + const state = { + ...EmptyState, + [property]: true, + }; + + const result = constraint.adjust(state); + + expect(result).toMatchObject({ [property]: false }); + }, + ); + + it.each([["number"], ["special"]] as [keyof PasswordGeneratorSettings][])( + "returns state.%s = requiredValue when matching constraint is active with a required value", + (property) => { + const constraint = new PasswordPolicyConstraints({ + [property]: { readonly: true, requiredValue: false }, + }); + const state = { ...EmptyState, [property]: true }; + + const result = constraint.adjust(state); + + expect(result).toMatchObject({ [property]: false }); + }, + ); + }); + + describe("fix", () => { + it("returns its input", () => { + const policy = new PasswordPolicyConstraints({}); + + const result = policy.fix(EmptyState); + + expect(result).toBe(EmptyState); + }); + }); +}); diff --git a/libs/tools/generator/core/src/policies/password-policy-constraints.ts b/libs/tools/generator/core/src/policies/password-policy-constraints.ts new file mode 100644 index 0000000000..1b4d07660a --- /dev/null +++ b/libs/tools/generator/core/src/policies/password-policy-constraints.ts @@ -0,0 +1,50 @@ +import { PolicyConstraints, StateConstraints } from "@bitwarden/common/tools/types"; + +import { DefaultPasswordGenerationOptions } from "../data"; +import { PasswordGeneratorSettings } from "../types"; + +import { fitToBounds, enforceConstant } from "./constraints"; + +export class PasswordPolicyConstraints implements StateConstraints { + /** Creates a password policy constraints + * @param constraints Constraints derived from the policy and application-defined defaults + */ + constructor(readonly constraints: PolicyConstraints) {} + + adjust(state: PasswordGeneratorSettings): PasswordGeneratorSettings { + // constrain values + const result: PasswordGeneratorSettings = { + ...(state ?? DefaultPasswordGenerationOptions), + length: fitToBounds(state.length, this.constraints.length), + lowercase: enforceConstant(state.lowercase, this.constraints.lowercase), + uppercase: enforceConstant(state.uppercase, this.constraints.uppercase), + number: enforceConstant(state.number, this.constraints.number), + special: enforceConstant(state.special, this.constraints.special), + minLowercase: fitToBounds(state.minLowercase, this.constraints.minLowercase), + minUppercase: fitToBounds(state.minUppercase, this.constraints.minUppercase), + minNumber: fitToBounds(state.minNumber, this.constraints.minNumber), + minSpecial: fitToBounds(state.minSpecial, this.constraints.minSpecial), + }; + + // ensure include flags are consistent with the constrained values + result.lowercase ||= state.minLowercase > 0; + result.uppercase ||= state.minUppercase > 0; + result.number ||= state.minNumber > 0; + result.special ||= state.minSpecial > 0; + + // when all flags are disabled, enable a few + const anyEnabled = [result.lowercase, result.uppercase, result.number, result.special].some( + (flag) => flag, + ); + if (!anyEnabled) { + result.lowercase = true; + result.uppercase = true; + } + + return result; + } + + fix(state: PasswordGeneratorSettings): PasswordGeneratorSettings { + return state; + } +} diff --git a/libs/tools/generator/core/src/rx.ts b/libs/tools/generator/core/src/rx.ts index e3c02be129..070d34d37d 100644 --- a/libs/tools/generator/core/src/rx.ts +++ b/libs/tools/generator/core/src/rx.ts @@ -18,16 +18,16 @@ export function mapPolicyToEvaluator( ); } -/** Maps an administrative console policy to a policy evaluator using the provided configuration. - * @param configuration the configuration that constructs the evaluator. +/** Maps an administrative console policy to constraints using the provided configuration. + * @param configuration the configuration that constructs the constraints. */ -export function mapPolicyToEvaluatorV2( +export function mapPolicyToConstraints( configuration: PolicyConfiguration, ) { return pipe( reduceCollection(configuration.combine, configuration.disabledValue), distinctIfShallowMatch(), - map(configuration.createEvaluatorV2), + map(configuration.toConstraints), ); } 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 index 5b784b3d07..7e249bc135 100644 --- a/libs/tools/generator/core/src/services/credential-generator.service.spec.ts +++ b/libs/tools/generator/core/src/services/credential-generator.service.spec.ts @@ -5,7 +5,7 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli 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 { StateConstraints } from "@bitwarden/common/tools/types"; import { OrganizationId, PolicyId, UserId } from "@bitwarden/common/types/guid"; import { @@ -14,8 +14,12 @@ import { awaitAsync, ObservableTracker, } from "../../../../../common/spec"; -import { PolicyEvaluator, Randomizer } from "../abstractions"; -import { CredentialGeneratorConfiguration, GeneratedCredential } from "../types"; +import { Randomizer } from "../abstractions"; +import { + CredentialGeneratorConfiguration, + GeneratedCredential, + GeneratorConstraints, +} from "../types"; import { CredentialGeneratorService } from "./credential-generator.service"; @@ -72,18 +76,37 @@ const SomeConfiguration: CredentialGeneratorConfiguration { 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; + toConstraints: (policy) => { + if (policy.fooPolicy) { + return { + constraints: { + policyInEffect: true, + }, + calibrate(state: SomeSettings) { + return { + constraints: {}, + adjust(state: SomeSettings) { + return { foo: `adjusted(${state.foo})` }; + }, + fix(state: SomeSettings) { + return { foo: `fixed(${state.foo})` }; + }, + } satisfies StateConstraints; + }, + } satisfies GeneratorConstraints; + } else { + return { + constraints: { + policyInEffect: false, + }, + adjust(state: SomeSettings) { + return state; + }, + fix(state: SomeSettings) { + return state; + }, + } satisfies GeneratorConstraints; + } }, }, }; @@ -378,7 +401,7 @@ describe("CredentialGeneratorService", () => { const result = await firstValueFrom(generator.settings$(SomeConfiguration)); - expect(result).toEqual({ foo: "sanitize(apply(value))" }); + expect(result).toEqual({ foo: "adjusted(value)" }); }); it("follows changes to the active user", async () => { @@ -525,17 +548,16 @@ describe("CredentialGeneratorService", () => { }); describe("policy$", () => { - it("creates a disabled policy evaluator when there is no policy", async () => { + it("creates constraints without policy in effect when there is no policy", async () => { const generator = new CredentialGeneratorService(randomizer, 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(); + expect(result.constraints.policyInEffect).toBeFalsy(); }); - it("creates an active policy evaluator when there is a policy", async () => { + it("creates constraints with policy in effect when there is a policy", async () => { const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); const userId$ = new BehaviorSubject(SomeUser).asObservable(); const policy$ = new BehaviorSubject([somePolicy]); @@ -543,8 +565,7 @@ describe("CredentialGeneratorService", () => { const result = await firstValueFrom(generator.policy$(SomeConfiguration, { userId$ })); - expect(result.policy).toEqual({ fooPolicy: true }); - expect(result.policyInEffect).toBeTruthy(); + expect(result.constraints.policyInEffect).toBeTruthy(); }); it("follows policy emissions", async () => { @@ -553,7 +574,7 @@ describe("CredentialGeneratorService", () => { const userId$ = userId.asObservable(); const somePolicySubject = new BehaviorSubject([somePolicy]); policyService.getAll$.mockReturnValueOnce(somePolicySubject.asObservable()); - const emissions: any = []; + const emissions: GeneratorConstraints[] = []; const sub = generator .policy$(SomeConfiguration, { userId$ }) .subscribe((policy) => emissions.push(policy)); @@ -564,10 +585,8 @@ describe("CredentialGeneratorService", () => { 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(); + expect(someResult.constraints.policyInEffect).toBeTruthy(); + expect(anotherResult.constraints.policyInEffect).toBeFalsy(); }); it("follows user emissions", async () => { @@ -577,7 +596,7 @@ describe("CredentialGeneratorService", () => { const somePolicy$ = new BehaviorSubject([somePolicy]).asObservable(); const anotherPolicy$ = new BehaviorSubject([]).asObservable(); policyService.getAll$.mockReturnValueOnce(somePolicy$).mockReturnValueOnce(anotherPolicy$); - const emissions: any = []; + const emissions: GeneratorConstraints[] = []; const sub = generator .policy$(SomeConfiguration, { userId$ }) .subscribe((policy) => emissions.push(policy)); @@ -588,10 +607,8 @@ describe("CredentialGeneratorService", () => { 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(); + expect(someResult.constraints.policyInEffect).toBeTruthy(); + expect(anotherResult.constraints.policyInEffect).toBeFalsy(); }); it("errors when the user errors", async () => { diff --git a/libs/tools/generator/core/src/services/credential-generator.service.ts b/libs/tools/generator/core/src/services/credential-generator.service.ts index d2012ecf20..dc6b861940 100644 --- a/libs/tools/generator/core/src/services/credential-generator.service.ts +++ b/libs/tools/generator/core/src/services/credential-generator.service.ts @@ -24,12 +24,13 @@ import { SingleUserDependency, UserDependency, } from "@bitwarden/common/tools/dependencies"; +import { isDynamic } from "@bitwarden/common/tools/state/state-constraints-dependency"; import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject"; -import { Constraints } from "@bitwarden/common/tools/types"; -import { PolicyEvaluator, Randomizer } from "../abstractions"; -import { mapPolicyToEvaluatorV2 } from "../rx"; +import { Randomizer } from "../abstractions"; +import { mapPolicyToConstraints } from "../rx"; import { CredentialGeneratorConfiguration as Configuration } from "../types/credential-generator-configuration"; +import { GeneratorConstraints } from "../types/generator-constraints"; type Policy$Dependencies = UserDependency; type Settings$Dependencies = Partial; @@ -44,9 +45,6 @@ type Generate$Dependencies = Simplify & 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( @@ -61,7 +59,7 @@ export class CredentialGeneratorService { * this emits. Otherwise, a new credential is emitted when the settings * update. */ - generate$( + generate$( configuration: Readonly>, dependencies?: Generate$Dependencies, ) { @@ -96,7 +94,7 @@ export class CredentialGeneratorService { * @returns an observable that emits settings * @remarks the observable enforces policies on the settings */ - settings$( + settings$( configuration: Configuration, dependencies?: Settings$Dependencies, ) { @@ -118,10 +116,9 @@ export class CredentialGeneratorService { 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; + const calibration = isDynamic(policy) ? policy.calibrate(settings) : policy; + const adjusted = calibration.adjust(settings); + return adjusted; }), ); @@ -135,7 +132,7 @@ export class CredentialGeneratorService { * `dependencies.singleUserId$` becomes available. * @remarks the subject enforces policy for the settings */ - async settings( + async settings( configuration: Readonly>, dependencies: SingleUserDependency, ) { @@ -143,19 +140,14 @@ export class CredentialGeneratorService { dependencies.singleUserId$.pipe(filter((userId) => !!userId)), ); const state = this.stateProvider.getUser(userId, configuration.settings.account); + const constraints$ = this.policy$(configuration, { userId$: dependencies.singleUserId$ }); - // 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); + const subject = new UserStateSubject(state, { ...dependencies, constraints$ }); return subject; } - /** Get the policy for the provided configuration + /** Get the policy constraints 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. @@ -163,20 +155,20 @@ export class CredentialGeneratorService { policy$( configuration: Configuration, dependencies: Policy$Dependencies, - ): Observable> { + ): Observable> { const completion$ = dependencies.userId$.pipe(ignoreElements(), endWith(true)); - const policy$ = dependencies.userId$.pipe( + const constraints$ = dependencies.userId$.pipe( mergeMap((userId) => { - // complete policy emissions otherwise `mergeMap` holds `policy$` open indefinitely + // complete policy emissions otherwise `mergeMap` holds `policies$` open indefinitely const policies$ = this.policyService .getAll$(configuration.policy.type, userId) .pipe(takeUntil(completion$)); return policies$; }), - mapPolicyToEvaluatorV2(configuration.policy), + mapPolicyToConstraints(configuration.policy), ); - return policy$; + return constraints$; } } diff --git a/libs/tools/generator/core/src/types/generator-constraints.ts b/libs/tools/generator/core/src/types/generator-constraints.ts new file mode 100644 index 0000000000..fe972df394 --- /dev/null +++ b/libs/tools/generator/core/src/types/generator-constraints.ts @@ -0,0 +1,11 @@ +import { + DynamicStateConstraints, + PolicyConstraints, + StateConstraints, +} from "@bitwarden/common/tools/types"; + +/** Specializes state constraints to include policy. */ +export type GeneratorConstraints = { constraints: PolicyConstraints } & ( + | DynamicStateConstraints + | StateConstraints +); diff --git a/libs/tools/generator/core/src/types/index.ts b/libs/tools/generator/core/src/types/index.ts index 229ac1c0c3..4f74c487f2 100644 --- a/libs/tools/generator/core/src/types/index.ts +++ b/libs/tools/generator/core/src/types/index.ts @@ -5,6 +5,7 @@ export * from "./credential-generator"; export * from "./credential-generator-configuration"; export * from "./eff-username-generator-options"; export * from "./forwarder-options"; +export * from "./generator-constraints"; export * from "./generated-credential"; export * from "./generator-options"; export * from "./generator-type"; diff --git a/libs/tools/generator/core/src/types/password-generation-options.ts b/libs/tools/generator/core/src/types/password-generation-options.ts index 0272cce205..76e8827d4d 100644 --- a/libs/tools/generator/core/src/types/password-generation-options.ts +++ b/libs/tools/generator/core/src/types/password-generation-options.ts @@ -1,3 +1,61 @@ +/** Settings format for password credential generation. + */ +export type PasswordGeneratorSettings = { + /** The length of the password selected by the user */ + length: number; + + /** `true` when ambiguous characters may be included in the output. + * `false` when ambiguous characters should not be included in the output. + */ + ambiguous: boolean; + + /** `true` when uppercase ASCII characters should be included in the output + * This value defaults to `false. + */ + uppercase: boolean; + + /** The minimum number of uppercase characters to include in the output. + * The value is ignored when `uppercase` is `false`. + * The value defaults to 1 when `uppercase` is `true`. + */ + minUppercase: number; + + /** `true` when lowercase ASCII characters should be included in the output. + * This value defaults to `false`. + */ + lowercase: boolean; + + /** The minimum number of lowercase characters to include in the output. + * The value defaults to 1 when `lowercase` is `true`. + * The value defaults to 0 when `lowercase` is `false`. + */ + minLowercase: number; + + /** Whether or not to include ASCII digits in the output + * This value defaults to `true` when `minNumber` is at least 1. + * This value defaults to `false` when `minNumber` is less than 1. + */ + number: boolean; + + /** The minimum number of digits to include in the output. + * The value defaults to 1 when `number` is `true`. + * The value defaults to 0 when `number` is `false`. + */ + minNumber: number; + + /** Whether or not to include special characters in the output. + * This value defaults to `true` when `minSpecial` is at least 1. + * This value defaults to `false` when `minSpecial` is less than 1. + */ + special: boolean; + + /** The minimum number of special characters to include in the output. + * This value defaults to 1 when `special` is `true`. + * This value defaults to 0 when `special` is `false`. + */ + minSpecial: number; +}; + /** Request format for password credential generation. * All members of this type may be `undefined` when the user is * generating a passphrase. @@ -6,63 +64,9 @@ * it is used with the "password generator" types. The name * `PasswordGeneratorOptions` is already in use by legacy code. */ -export type PasswordGenerationOptions = { - /** The length of the password selected by the user */ - length?: number; - +export type PasswordGenerationOptions = Partial & { /** The minimum length of the password. This defaults to 5, and increases * to ensure `minLength` is at least as large as the sum of the other minimums. */ minLength?: number; - - /** `true` when ambiguous characters may be included in the output. - * `false` when ambiguous characters should not be included in the output. - */ - ambiguous?: boolean; - - /** `true` when uppercase ASCII characters should be included in the output - * This value defaults to `false. - */ - uppercase?: boolean; - - /** The minimum number of uppercase characters to include in the output. - * The value is ignored when `uppercase` is `false`. - * The value defaults to 1 when `uppercase` is `true`. - */ - minUppercase?: number; - - /** `true` when lowercase ASCII characters should be included in the output. - * This value defaults to `false`. - */ - lowercase?: boolean; - - /** The minimum number of lowercase characters to include in the output. - * The value defaults to 1 when `lowercase` is `true`. - * The value defaults to 0 when `lowercase` is `false`. - */ - minLowercase?: number; - - /** Whether or not to include ASCII digits in the output - * This value defaults to `true` when `minNumber` is at least 1. - * This value defaults to `false` when `minNumber` is less than 1. - */ - number?: boolean; - - /** The minimum number of digits to include in the output. - * The value defaults to 1 when `number` is `true`. - * The value defaults to 0 when `number` is `false`. - */ - minNumber?: number; - - /** Whether or not to include special characters in the output. - * This value defaults to `true` when `minSpecial` is at least 1. - * This value defaults to `false` when `minSpecial` is less than 1. - */ - special?: boolean; - - /** The minimum number of special characters to include in the output. - * This value defaults to 1 when `special` is `true`. - * This value defaults to 0 when `special` is `false`. - */ - minSpecial?: number; }; diff --git a/libs/tools/generator/core/src/types/policy-configuration.ts b/libs/tools/generator/core/src/types/policy-configuration.ts index 6ec077bcb6..2b01a04b92 100644 --- a/libs/tools/generator/core/src/types/policy-configuration.ts +++ b/libs/tools/generator/core/src/types/policy-configuration.ts @@ -1,9 +1,10 @@ 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"; +import { GeneratorConstraints } from "./generator-constraints"; + /** Determines how to construct a password generator policy */ export type PolicyConfiguration = { type: PolicyType; @@ -17,13 +18,15 @@ export type PolicyConfiguration = { combine: (acc: Policy, policy: AdminPolicy) => Policy; /** Converts policy service data into an actionable policy. + * @deprecated provided only for backwards compatibility. + * Use `toConstraints` instead. */ createEvaluator: (policy: Policy) => PolicyEvaluator; - /** Converts policy service data into an actionable policy. + /** Converts policy service data into actionable policy constraints. * @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; + toConstraints: (policy: Policy) => GeneratorConstraints; };