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 @@
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 }}
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;
};