mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-25 12:15:18 +01:00
[PM-11418] generator policy constraints (#11014)
* add constraint support to UserStateSubject * add dynamic constraints * implement password policy constraints * replace policy evaluator with constraints in credential generation service * add cascade between minNumber and minSpecial Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com>
This commit is contained in:
parent
9a89ef9b4f
commit
cf48db5ed1
@ -489,6 +489,10 @@
|
|||||||
"message": "Avoid ambiguous characters",
|
"message": "Avoid ambiguous characters",
|
||||||
"description": "Label for the avoid ambiguous characters checkbox."
|
"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": {
|
"searchVault": {
|
||||||
"message": "Search vault"
|
"message": "Search vault"
|
||||||
},
|
},
|
||||||
|
@ -1 +1 @@
|
|||||||
<bit-password-generator />
|
<tools-password-generator />
|
||||||
|
@ -444,6 +444,10 @@
|
|||||||
"ambiguous": {
|
"ambiguous": {
|
||||||
"message": "Avoid ambiguous characters"
|
"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": {
|
"searchCollection": {
|
||||||
"message": "Search collection"
|
"message": "Search collection"
|
||||||
},
|
},
|
||||||
|
@ -1475,6 +1475,10 @@
|
|||||||
"includeNumber": {
|
"includeNumber": {
|
||||||
"message": "Include number"
|
"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": {
|
"passwordHistory": {
|
||||||
"message": "Password history"
|
"message": "Password history"
|
||||||
},
|
},
|
||||||
|
24
libs/common/src/tools/state/identity-state-constraint.ts
Normal file
24
libs/common/src/tools/state/identity-state-constraint.ts
Normal file
@ -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<any>(Object.freeze({}), {
|
||||||
|
get() {
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A constraint that does nothing. */
|
||||||
|
export class IdentityConstraint<State extends object> implements StateConstraints<State> {
|
||||||
|
/** Instantiate the identity constraint */
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
readonly constraints: Readonly<Constraints<State>> = EMPTY_CONSTRAINTS;
|
||||||
|
|
||||||
|
adjust(state: State) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
fix(state: State) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
@ -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<TestType> {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
29
libs/common/src/tools/state/state-constraints-dependency.ts
Normal file
29
libs/common/src/tools/state/state-constraints-dependency.ts
Normal file
@ -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<State> = {
|
||||||
|
/** A stream that emits constraints when subscribed and when the
|
||||||
|
* constraints change. The stream should not emit `null` or
|
||||||
|
* `undefined`.
|
||||||
|
*/
|
||||||
|
constraints$: Observable<StateConstraints<State> | DynamicStateConstraints<State>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Returns `true` if the input constraint is a `DynamicStateConstraints<T>`.
|
||||||
|
* Otherwise, returns false.
|
||||||
|
* @param constraints the constraint to evaluate.
|
||||||
|
* */
|
||||||
|
export function isDynamic<State>(
|
||||||
|
constraints: StateConstraints<State> | DynamicStateConstraints<State>,
|
||||||
|
): constraints is DynamicStateConstraints<State> {
|
||||||
|
return constraints && "calibrate" in constraints;
|
||||||
|
}
|
@ -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<State, Dependency> = Simplify<
|
||||||
|
SingleUserDependency &
|
||||||
|
Partial<WhenDependency> &
|
||||||
|
Partial<Dependencies<Dependency>> &
|
||||||
|
Partial<StateConstraintsDependency<State>> & {
|
||||||
|
/** 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<TCombine>`
|
||||||
|
* @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<TCombine>`
|
||||||
|
* @returns `true` if the value should be stored, otherwise `false`.
|
||||||
|
*/
|
||||||
|
shouldUpdate?: (value: State, next: State, dependencies?: Dependency) => boolean;
|
||||||
|
}
|
||||||
|
>;
|
@ -2,13 +2,37 @@ import { BehaviorSubject, of, Subject } from "rxjs";
|
|||||||
|
|
||||||
import { UserId } from "@bitwarden/common/types/guid";
|
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";
|
import { UserStateSubject } from "./user-state-subject";
|
||||||
|
|
||||||
const SomeUser = "some user" as UserId;
|
const SomeUser = "some user" as UserId;
|
||||||
type TestType = { foo: string };
|
type TestType = { foo: string };
|
||||||
|
|
||||||
|
function fooMaxLength(maxLength: number): StateConstraints<TestType> {
|
||||||
|
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("UserStateSubject", () => {
|
||||||
describe("dependencies", () => {
|
describe("dependencies", () => {
|
||||||
it("ignores repeated when$ emissions", async () => {
|
it("ignores repeated when$ emissions", async () => {
|
||||||
@ -54,6 +78,19 @@ describe("UserStateSubject", () => {
|
|||||||
|
|
||||||
expect(nextValue).toHaveBeenCalledTimes(1);
|
expect(nextValue).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("waits for constraints$", async () => {
|
||||||
|
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||||
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
|
const constraints$ = new Subject<StateConstraints<TestType>>();
|
||||||
|
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", () => {
|
describe("next", () => {
|
||||||
@ -246,6 +283,116 @@ describe("UserStateSubject", () => {
|
|||||||
|
|
||||||
expect(nextValue).toHaveBeenCalled();
|
expect(nextValue).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("applies constraints$ on init", async () => {
|
||||||
|
const state = new FakeSingleUserState<TestType>(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<TestType>(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<TestType>(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<TestType>(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<TestType>(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<TestType>(SomeUser, { foo: "init" });
|
||||||
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
|
const constraints$ = new Subject<StateConstraints<TestType>>();
|
||||||
|
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<TestType>(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<TestType>(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", () => {
|
describe("error", () => {
|
||||||
@ -474,4 +621,150 @@ describe("UserStateSubject", () => {
|
|||||||
expect(subject.userId).toEqual(SomeUser);
|
expect(subject.userId).toEqual(SomeUser);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("withConstraints$", () => {
|
||||||
|
it("emits the next value with an empty constraint", async () => {
|
||||||
|
const state = new FakeSingleUserState<TestType>(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<TestType>(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<TestType>(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<TestType>(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<TestType>(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<TestType>(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<TestType>(SomeUser, { foo: "init" });
|
||||||
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
|
const constraints$ = new Subject<StateConstraints<TestType>>();
|
||||||
|
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<TestType>(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<TestType>(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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -17,37 +17,20 @@ import {
|
|||||||
startWith,
|
startWith,
|
||||||
Observable,
|
Observable,
|
||||||
Subscription,
|
Subscription,
|
||||||
|
last,
|
||||||
|
concat,
|
||||||
|
combineLatestWith,
|
||||||
|
catchError,
|
||||||
|
EMPTY,
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
import { Simplify } from "type-fest";
|
|
||||||
|
|
||||||
import { SingleUserState } from "@bitwarden/common/platform/state";
|
import { SingleUserState } from "@bitwarden/common/platform/state";
|
||||||
|
|
||||||
import { Dependencies, SingleUserDependency, WhenDependency } from "../dependencies";
|
import { WithConstraints } from "../types";
|
||||||
|
|
||||||
/** dependencies accepted by the user state subject */
|
import { IdentityConstraint } from "./identity-state-constraint";
|
||||||
export type UserStateSubjectDependencies<State, Dependency> = Simplify<
|
import { isDynamic } from "./state-constraints-dependency";
|
||||||
SingleUserDependency &
|
import { UserStateSubjectDependencies } from "./user-state-subject-dependencies";
|
||||||
Partial<WhenDependency> &
|
|
||||||
Partial<Dependencies<Dependency>> & {
|
|
||||||
/** 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<TCombine>`
|
|
||||||
* @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<TCombine>`
|
|
||||||
* @returns `true` if the value should be stored, otherwise `false`.
|
|
||||||
*/
|
|
||||||
shouldUpdate?: (value: State, next: State, dependencies?: Dependency) => boolean;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adapt a state provider to an rxjs subject.
|
* Adapt a state provider to an rxjs subject.
|
||||||
@ -61,7 +44,7 @@ export type UserStateSubjectDependencies<State, Dependency> = Simplify<
|
|||||||
* @template State the state stored by the subject
|
* @template State the state stored by the subject
|
||||||
* @template Dependencies use-specific dependencies provided by the user.
|
* @template Dependencies use-specific dependencies provided by the user.
|
||||||
*/
|
*/
|
||||||
export class UserStateSubject<State, Dependencies = null>
|
export class UserStateSubject<State extends object, Dependencies = null>
|
||||||
extends Observable<State>
|
extends Observable<State>
|
||||||
implements SubjectLike<State>
|
implements SubjectLike<State>
|
||||||
{
|
{
|
||||||
@ -99,6 +82,35 @@ export class UserStateSubject<State, Dependencies = null>
|
|||||||
}),
|
}),
|
||||||
distinctUntilChanged(),
|
distinctUntilChanged(),
|
||||||
);
|
);
|
||||||
|
const constraints$ = (
|
||||||
|
this.dependencies.constraints$ ?? new BehaviorSubject(new IdentityConstraint<State>())
|
||||||
|
).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
|
// observe completion
|
||||||
const whenComplete$ = when$.pipe(ignoreElements(), endWith(true));
|
const whenComplete$ = when$.pipe(ignoreElements(), endWith(true));
|
||||||
@ -106,9 +118,24 @@ export class UserStateSubject<State, Dependencies = null>
|
|||||||
const userIdComplete$ = this.dependencies.singleUserId$.pipe(ignoreElements(), endWith(true));
|
const userIdComplete$ = this.dependencies.singleUserId$.pipe(ignoreElements(), endWith(true));
|
||||||
const completion$ = race(whenComplete$, inputComplete$, userIdComplete$);
|
const completion$ = race(whenComplete$, inputComplete$, userIdComplete$);
|
||||||
|
|
||||||
// wire subscriptions
|
// wire output before input so that output normalizes the current state
|
||||||
this.outputSubscription = this.state.state$.subscribe(this.output);
|
// before any `next` value is processed
|
||||||
this.inputSubscription = combineLatest([this.input, when$, userIdAvailable$])
|
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(
|
.pipe(
|
||||||
filter(([_, when]) => when),
|
filter(([_, when]) => when),
|
||||||
map(([state]) => state),
|
map(([state]) => state),
|
||||||
@ -144,14 +171,19 @@ export class UserStateSubject<State, Dependencies = null>
|
|||||||
* @returns the subscription
|
* @returns the subscription
|
||||||
*/
|
*/
|
||||||
subscribe(observer?: Partial<Observer<State>> | ((value: State) => void) | null): Subscription {
|
subscribe(observer?: Partial<Observer<State>> | ((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;
|
// using subjects to ensure the right semantics are followed;
|
||||||
// if greater efficiency becomes desirable, consider implementing
|
// if greater efficiency becomes desirable, consider implementing
|
||||||
// `SubjectLike` directly
|
// `SubjectLike` directly
|
||||||
private input = new Subject<State>();
|
private input = new Subject<State>();
|
||||||
private readonly output = new ReplaySubject<State>(1);
|
private readonly output = new ReplaySubject<WithConstraints<State>>(1);
|
||||||
|
|
||||||
|
/** A stream containing settings and their last-applied constraints. */
|
||||||
|
get withConstraints$() {
|
||||||
|
return this.output.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
private inputSubscription: Unsubscribable;
|
private inputSubscription: Unsubscribable;
|
||||||
private outputSubscription: Unsubscribable;
|
private outputSubscription: Unsubscribable;
|
||||||
|
@ -2,8 +2,11 @@ import { Simplify } from "type-fest";
|
|||||||
|
|
||||||
/** Constraints that are shared by all primitive field types */
|
/** Constraints that are shared by all primitive field types */
|
||||||
type PrimitiveConstraint = {
|
type PrimitiveConstraint = {
|
||||||
/** presence indicates the field is required */
|
/** `true` indicates the field is required; otherwise the field is optional */
|
||||||
required?: true;
|
required?: boolean;
|
||||||
|
|
||||||
|
/** `true` indicates the field is immutable; otherwise the field is mutable */
|
||||||
|
readonly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Constraints that are shared by string fields */
|
/** Constraints that are shared by string fields */
|
||||||
@ -23,29 +26,108 @@ type NumberConstraints = {
|
|||||||
/** maximum number value. When absent, min value is unbounded. */
|
/** maximum number value. When absent, min value is unbounded. */
|
||||||
max?: number;
|
max?: number;
|
||||||
|
|
||||||
/** presence indicates the field only accepts integer values */
|
/** requires the number be a multiple of the step value;
|
||||||
integer?: true;
|
* this field must be a positive number. +0 and Infinity are
|
||||||
|
* prohibited. When absent, any number is accepted.
|
||||||
/** requires the number be a multiple of the step value */
|
* @remarks set this to `1` to require integer values.
|
||||||
|
*/
|
||||||
step?: number;
|
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<T> = PrimitiveConstraint &
|
||||||
|
(T extends string
|
||||||
|
? StringConstraints
|
||||||
|
: T extends number
|
||||||
|
? NumberConstraints
|
||||||
|
: T extends boolean
|
||||||
|
? BooleanConstraint
|
||||||
|
: never);
|
||||||
|
|
||||||
/** Utility type that transforms keys of T into their supported
|
/** Utility type that transforms keys of T into their supported
|
||||||
* validators.
|
* validators.
|
||||||
*/
|
*/
|
||||||
export type Constraints<T> = {
|
export type Constraints<T> = {
|
||||||
[Key in keyof T]: Simplify<
|
[Key in keyof T]?: Simplify<Constraint<T[Key]>>;
|
||||||
PrimitiveConstraint &
|
|
||||||
(T[Key] extends string
|
|
||||||
? StringConstraints
|
|
||||||
: T[Key] extends number
|
|
||||||
? NumberConstraints
|
|
||||||
: never)
|
|
||||||
>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Utility type that tracks whether a set of constraints was
|
||||||
|
* produced by an active policy.
|
||||||
|
*/
|
||||||
|
export type PolicyConstraints<T> = {
|
||||||
|
/** When true, the constraints were derived from an active policy. */
|
||||||
|
policyInEffect?: boolean;
|
||||||
|
} & Constraints<T>;
|
||||||
|
|
||||||
/** utility type for methods that evaluate constraints generically. */
|
/** 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<State> = {
|
||||||
|
/** the state */
|
||||||
|
readonly state: State;
|
||||||
|
|
||||||
|
/** the constraints enforced upon the type. */
|
||||||
|
readonly constraints: Constraints<State>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Creates constraints that are applied automatically to application
|
||||||
|
* state.
|
||||||
|
* This type is mutually exclusive with `StateConstraints`.
|
||||||
|
*/
|
||||||
|
export type DynamicStateConstraints<State> = {
|
||||||
|
/** 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<State>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 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<State>`
|
||||||
|
* instead.
|
||||||
|
*/
|
||||||
|
export type StateConstraints<State> = {
|
||||||
|
/** Well-known constraints of `State` */
|
||||||
|
readonly constraints: Readonly<Constraints<State>>;
|
||||||
|
|
||||||
|
/** 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
|
/** Options that provide contextual information about the application state
|
||||||
* when a generator is invoked.
|
* when a generator is invoked.
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
<form class="box" [formGroup]="settings" class="tw-container">
|
<form class="box" [formGroup]="settings" class="tw-container">
|
||||||
<div class="tw-mb-4">
|
<div class="tw-mb-4">
|
||||||
<bit-card>
|
<bit-card>
|
||||||
<bit-form-field>
|
<bit-form-field disableMargin>
|
||||||
<bit-label>{{ "numWords" | i18n }}</bit-label>
|
<bit-label>{{ "numWords" | i18n }}</bit-label>
|
||||||
<input
|
<input
|
||||||
bitInput
|
bitInput
|
||||||
@ -28,10 +28,11 @@
|
|||||||
<input bitCheckbox formControlName="capitalize" id="capitalize" type="checkbox" />
|
<input bitCheckbox formControlName="capitalize" id="capitalize" type="checkbox" />
|
||||||
<bit-label>{{ "capitalize" | i18n }}</bit-label>
|
<bit-label>{{ "capitalize" | i18n }}</bit-label>
|
||||||
</bit-form-control>
|
</bit-form-control>
|
||||||
<bit-form-control>
|
<bit-form-control [disableMargin]="!policyInEffect">
|
||||||
<input bitCheckbox formControlName="includeNumber" id="include-number" type="checkbox" />
|
<input bitCheckbox formControlName="includeNumber" id="include-number" type="checkbox" />
|
||||||
<bit-label>{{ "includeNumber" | i18n }}</bit-label>
|
<bit-label>{{ "includeNumber" | i18n }}</bit-label>
|
||||||
</bit-form-control>
|
</bit-form-control>
|
||||||
|
<p *ngIf="policyInEffect" bitTypography="helper">{{ "generatorPolicyInEffect" | i18n }}</p>
|
||||||
</bit-card>
|
</bit-card>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
@ -23,7 +23,7 @@ const Controls = Object.freeze({
|
|||||||
/** Options group for passphrases */
|
/** Options group for passphrases */
|
||||||
@Component({
|
@Component({
|
||||||
standalone: true,
|
standalone: true,
|
||||||
selector: "bit-passphrase-settings",
|
selector: "tools-passphrase-settings",
|
||||||
templateUrl: "passphrase-settings.component.html",
|
templateUrl: "passphrase-settings.component.html",
|
||||||
imports: [DependenciesModule],
|
imports: [DependenciesModule],
|
||||||
})
|
})
|
||||||
@ -81,24 +81,22 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy {
|
|||||||
this.generatorService
|
this.generatorService
|
||||||
.policy$(Generators.Passphrase, { userId$: singleUserId$ })
|
.policy$(Generators.Passphrase, { userId$: singleUserId$ })
|
||||||
.pipe(takeUntil(this.destroyed$))
|
.pipe(takeUntil(this.destroyed$))
|
||||||
.subscribe((policy) => {
|
.subscribe(({ constraints }) => {
|
||||||
this.settings
|
this.settings
|
||||||
.get(Controls.numWords)
|
.get(Controls.numWords)
|
||||||
.setValidators(toValidators(Controls.numWords, Generators.Passphrase, policy));
|
.setValidators(toValidators(Controls.numWords, Generators.Passphrase, constraints));
|
||||||
|
|
||||||
this.settings
|
this.settings
|
||||||
.get(Controls.wordSeparator)
|
.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)
|
// 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 = constraints.numWords.min;
|
||||||
this.minNumWords =
|
this.maxNumWords = constraints.numWords.max;
|
||||||
policy.numWords?.min ?? Generators.Passphrase.settings.constraints.numWords.min;
|
this.policyInEffect = constraints.policyInEffect;
|
||||||
this.maxNumWords =
|
|
||||||
policy.numWords?.max ?? Generators.Passphrase.settings.constraints.numWords.max;
|
|
||||||
|
|
||||||
this.toggleEnabled(Controls.capitalize, !policy.policy.capitalize);
|
this.toggleEnabled(Controls.capitalize, !constraints.capitalize?.readonly);
|
||||||
this.toggleEnabled(Controls.includeNumber, !policy.policy.includeNumber);
|
this.toggleEnabled(Controls.includeNumber, !constraints.includeNumber?.readonly);
|
||||||
});
|
});
|
||||||
|
|
||||||
// now that outputs are set up, connect inputs
|
// now that outputs are set up, connect inputs
|
||||||
@ -111,11 +109,14 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy {
|
|||||||
/** attribute binding for numWords[max] */
|
/** attribute binding for numWords[max] */
|
||||||
protected maxNumWords: number;
|
protected maxNumWords: number;
|
||||||
|
|
||||||
|
/** display binding for enterprise policy notice */
|
||||||
|
protected policyInEffect: boolean;
|
||||||
|
|
||||||
private toggleEnabled(setting: keyof typeof Controls, enabled: boolean) {
|
private toggleEnabled(setting: keyof typeof Controls, enabled: boolean) {
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
this.settings.get(setting).enable();
|
this.settings.get(setting).enable({ emitEvent: false });
|
||||||
} else {
|
} else {
|
||||||
this.settings.get(setting).disable();
|
this.settings.get(setting).disable({ emitEvent: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -13,10 +13,10 @@
|
|||||||
</bit-toggle>
|
</bit-toggle>
|
||||||
</bit-toggle-group>
|
</bit-toggle-group>
|
||||||
<bit-card class="tw-flex tw-justify-between tw-mb-4">
|
<bit-card class="tw-flex tw-justify-between tw-mb-4">
|
||||||
<div class="tw-grow">
|
<div class="tw-grow tw-flex tw-items-center">
|
||||||
<bit-color-password class="tw-font-mono" [password]="value$ | async"></bit-color-password>
|
<bit-color-password class="tw-font-mono" [password]="value$ | async"></bit-color-password>
|
||||||
</div>
|
</div>
|
||||||
<div class="tw-space-x-1 tw-flex-none tw-w-4">
|
<div class="tw-space-x-1">
|
||||||
<button type="button" bitIconButton="bwi-generate" buttonType="main" (click)="generate$.next()">
|
<button type="button" bitIconButton="bwi-generate" buttonType="main" (click)="generate$.next()">
|
||||||
{{ "generatePassword" | i18n }}
|
{{ "generatePassword" | i18n }}
|
||||||
</button>
|
</button>
|
||||||
@ -30,13 +30,13 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</bit-card>
|
</bit-card>
|
||||||
<bit-password-settings
|
<tools-password-settings
|
||||||
class="tw-mt-6"
|
class="tw-mt-6"
|
||||||
*ngIf="(credentialType$ | async) === 'password'"
|
*ngIf="(credentialType$ | async) === 'password'"
|
||||||
[userId]="this.userId$ | async"
|
[userId]="this.userId$ | async"
|
||||||
(onUpdated)="generate$.next()"
|
(onUpdated)="generate$.next()"
|
||||||
/>
|
/>
|
||||||
<bit-passphrase-settings
|
<tools-passphrase-settings
|
||||||
class="tw-mt-6"
|
class="tw-mt-6"
|
||||||
*ngIf="(credentialType$ | async) === 'passphrase'"
|
*ngIf="(credentialType$ | async) === 'passphrase'"
|
||||||
[userId]="this.userId$ | async"
|
[userId]="this.userId$ | async"
|
||||||
|
@ -13,7 +13,7 @@ import { PasswordSettingsComponent } from "./password-settings.component";
|
|||||||
/** Options group for passwords */
|
/** Options group for passwords */
|
||||||
@Component({
|
@Component({
|
||||||
standalone: true,
|
standalone: true,
|
||||||
selector: "bit-password-generator",
|
selector: "tools-password-generator",
|
||||||
templateUrl: "password-generator.component.html",
|
templateUrl: "password-generator.component.html",
|
||||||
imports: [DependenciesModule, PasswordSettingsComponent, PassphraseSettingsComponent],
|
imports: [DependenciesModule, PasswordSettingsComponent, PassphraseSettingsComponent],
|
||||||
})
|
})
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
<bit-section>
|
<bit-section>
|
||||||
<bit-section-header *ngIf="showHeader">
|
<bit-section-header *ngIf="showHeader">
|
||||||
<h5 bitTypography="h5">{{ "options" | i18n }}</h5>
|
<h6 bitTypography="h6">{{ "options" | i18n }}</h6>
|
||||||
</bit-section-header>
|
</bit-section-header>
|
||||||
<form class="box" [formGroup]="settings" class="tw-container">
|
<form class="box" [formGroup]="settings" class="tw-container">
|
||||||
<div class="tw-mb-4">
|
<div class="tw-mb-4">
|
||||||
<bit-card>
|
<bit-card>
|
||||||
<bit-form-field>
|
<bit-form-field disableMargin>
|
||||||
<bit-label>{{ "length" | i18n }}</bit-label>
|
<bit-label>{{ "length" | i18n }}</bit-label>
|
||||||
<input
|
<input
|
||||||
bitInput
|
bitInput
|
||||||
@ -42,7 +42,7 @@
|
|||||||
attr.aria-description="{{ 'numbersDescription' | i18n }}"
|
attr.aria-description="{{ 'numbersDescription' | i18n }}"
|
||||||
title="{{ 'numbersDescription' | i18n }}"
|
title="{{ 'numbersDescription' | i18n }}"
|
||||||
>
|
>
|
||||||
<input bitCheckbox type="checkbox" formControlName="numbers" />
|
<input bitCheckbox type="checkbox" formControlName="number" />
|
||||||
<bit-label>{{ "numbersLabel" | i18n }}</bit-label>
|
<bit-label>{{ "numbersLabel" | i18n }}</bit-label>
|
||||||
</bit-form-control>
|
</bit-form-control>
|
||||||
<bit-form-control
|
<bit-form-control
|
||||||
@ -76,10 +76,11 @@
|
|||||||
/>
|
/>
|
||||||
</bit-form-field>
|
</bit-form-field>
|
||||||
</div>
|
</div>
|
||||||
<bit-form-control>
|
<bit-form-control [disableMargin]="!policyInEffect">
|
||||||
<input bitCheckbox type="checkbox" formControlName="avoidAmbiguous" />
|
<input bitCheckbox type="checkbox" formControlName="avoidAmbiguous" />
|
||||||
<bit-label>{{ "avoidAmbiguous" | i18n }}</bit-label>
|
<bit-label>{{ "avoidAmbiguous" | i18n }}</bit-label>
|
||||||
</bit-form-control>
|
</bit-form-control>
|
||||||
|
<p *ngIf="policyInEffect" bitTypography="helper">{{ "generatorPolicyInEffect" | i18n }}</p>
|
||||||
</bit-card>
|
</bit-card>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { OnInit, Input, Output, EventEmitter, Component, OnDestroy } from "@angular/core";
|
import { OnInit, Input, Output, EventEmitter, Component, OnDestroy } from "@angular/core";
|
||||||
import { FormBuilder } from "@angular/forms";
|
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 { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { UserId } from "@bitwarden/common/types/guid";
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
@ -17,7 +17,7 @@ const Controls = Object.freeze({
|
|||||||
length: "length",
|
length: "length",
|
||||||
uppercase: "uppercase",
|
uppercase: "uppercase",
|
||||||
lowercase: "lowercase",
|
lowercase: "lowercase",
|
||||||
numbers: "numbers",
|
number: "number",
|
||||||
special: "special",
|
special: "special",
|
||||||
minNumber: "minNumber",
|
minNumber: "minNumber",
|
||||||
minSpecial: "minSpecial",
|
minSpecial: "minSpecial",
|
||||||
@ -27,7 +27,7 @@ const Controls = Object.freeze({
|
|||||||
/** Options group for passwords */
|
/** Options group for passwords */
|
||||||
@Component({
|
@Component({
|
||||||
standalone: true,
|
standalone: true,
|
||||||
selector: "bit-password-settings",
|
selector: "tools-password-settings",
|
||||||
templateUrl: "password-settings.component.html",
|
templateUrl: "password-settings.component.html",
|
||||||
imports: [DependenciesModule],
|
imports: [DependenciesModule],
|
||||||
})
|
})
|
||||||
@ -54,6 +54,10 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy {
|
|||||||
@Input()
|
@Input()
|
||||||
showHeader: boolean = true;
|
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.
|
/** Emits settings updates and completes if the settings become unavailable.
|
||||||
* @remarks this does not emit the initial settings. If you would like
|
* @remarks this does not emit the initial settings. If you would like
|
||||||
* to receive live settings updates including the initial update,
|
* 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.length]: [Generators.Password.settings.initial.length],
|
||||||
[Controls.uppercase]: [Generators.Password.settings.initial.uppercase],
|
[Controls.uppercase]: [Generators.Password.settings.initial.uppercase],
|
||||||
[Controls.lowercase]: [Generators.Password.settings.initial.lowercase],
|
[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.special]: [Generators.Password.settings.initial.special],
|
||||||
[Controls.minNumber]: [Generators.Password.settings.initial.minNumber],
|
[Controls.minNumber]: [Generators.Password.settings.initial.minNumber],
|
||||||
[Controls.minSpecial]: [Generators.Password.settings.initial.minSpecial],
|
[Controls.minSpecial]: [Generators.Password.settings.initial.minSpecial],
|
||||||
[Controls.avoidAmbiguous]: [!Generators.Password.settings.initial.ambiguous],
|
[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() {
|
async ngOnInit() {
|
||||||
const singleUserId$ = this.singleUserId$();
|
const singleUserId$ = this.singleUserId$();
|
||||||
const settings = await this.generatorService.settings(Generators.Password, { singleUserId$ });
|
const settings = await this.generatorService.settings(Generators.Password, { singleUserId$ });
|
||||||
|
|
||||||
|
// bind settings to the UI
|
||||||
settings
|
settings
|
||||||
.pipe(
|
.pipe(
|
||||||
map((settings) => {
|
map((settings) => {
|
||||||
@ -93,47 +114,41 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy {
|
|||||||
this.settings.patchValue(s, { emitEvent: false });
|
this.settings.patchValue(s, { emitEvent: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
// the first emission is the current value; subsequent emissions are updates
|
// bind policy to the template
|
||||||
settings.pipe(skip(1), takeUntil(this.destroyed$)).subscribe(this.onUpdated);
|
|
||||||
|
|
||||||
///
|
|
||||||
this.generatorService
|
this.generatorService
|
||||||
.policy$(Generators.Password, { userId$: singleUserId$ })
|
.policy$(Generators.Password, { userId$: singleUserId$ })
|
||||||
.pipe(takeUntil(this.destroyed$))
|
.pipe(takeUntil(this.destroyed$))
|
||||||
.subscribe((policy) => {
|
.subscribe(({ constraints }) => {
|
||||||
this.settings
|
this.settings
|
||||||
.get(Controls.length)
|
.get(Controls.length)
|
||||||
.setValidators(toValidators(Controls.length, Generators.Password, policy));
|
.setValidators(toValidators(Controls.length, Generators.Password, constraints));
|
||||||
|
|
||||||
this.settings
|
this.minNumber.setValidators(
|
||||||
.get(Controls.minNumber)
|
toValidators(Controls.minNumber, Generators.Password, constraints),
|
||||||
.setValidators(toValidators(Controls.minNumber, Generators.Password, policy));
|
);
|
||||||
|
|
||||||
this.settings
|
this.minSpecial.setValidators(
|
||||||
.get(Controls.minSpecial)
|
toValidators(Controls.minSpecial, Generators.Password, constraints),
|
||||||
.setValidators(toValidators(Controls.minSpecial, Generators.Password, policy));
|
);
|
||||||
|
|
||||||
// forward word boundaries to the template (can't do it through the rx form)
|
// 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 = constraints.length.min;
|
||||||
this.minLength = policy.length?.min ?? Generators.Password.settings.constraints.length.min;
|
this.maxLength = constraints.length.max;
|
||||||
this.maxLength = policy.length?.max ?? Generators.Password.settings.constraints.length.max;
|
this.minMinNumber = constraints.minNumber.min;
|
||||||
this.minMinNumber =
|
this.maxMinNumber = constraints.minNumber.max;
|
||||||
policy.minNumber?.min ?? Generators.Password.settings.constraints.minNumber.min;
|
this.minMinSpecial = constraints.minSpecial.min;
|
||||||
this.maxMinNumber =
|
this.maxMinSpecial = constraints.minSpecial.max;
|
||||||
policy.minNumber?.max ?? Generators.Password.settings.constraints.minNumber.max;
|
|
||||||
this.minMinSpecial =
|
this.policyInEffect = constraints.policyInEffect;
|
||||||
policy.minSpecial?.min ?? Generators.Password.settings.constraints.minSpecial.min;
|
|
||||||
this.maxMinSpecial =
|
|
||||||
policy.minSpecial?.max ?? Generators.Password.settings.constraints.minSpecial.max;
|
|
||||||
|
|
||||||
const toggles = [
|
const toggles = [
|
||||||
[Controls.length, policy.length.min < policy.length.max],
|
[Controls.length, constraints.length.min < constraints.length.max],
|
||||||
[Controls.uppercase, !policy.policy.useUppercase],
|
[Controls.uppercase, !constraints.uppercase?.readonly],
|
||||||
[Controls.lowercase, !policy.policy.useLowercase],
|
[Controls.lowercase, !constraints.lowercase?.readonly],
|
||||||
[Controls.numbers, !policy.policy.useNumbers],
|
[Controls.number, !constraints.number?.readonly],
|
||||||
[Controls.special, !policy.policy.useSpecial],
|
[Controls.special, !constraints.special?.readonly],
|
||||||
[Controls.minNumber, policy.minNumber.min < policy.minNumber.max],
|
[Controls.minNumber, constraints.minNumber.min < constraints.minNumber.max],
|
||||||
[Controls.minSpecial, policy.minSpecial.min < policy.minSpecial.max],
|
[Controls.minSpecial, constraints.minSpecial.min < constraints.minSpecial.max],
|
||||||
] as [keyof typeof Controls, boolean][];
|
] as [keyof typeof Controls, boolean][];
|
||||||
|
|
||||||
for (const [control, enabled] of toggles) {
|
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
|
// now that outputs are set up, connect inputs
|
||||||
this.settings.valueChanges
|
this.settings.valueChanges
|
||||||
.pipe(
|
.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) => {
|
map((settings) => {
|
||||||
// interface is "avoid" while storage is "include"
|
// interface is "avoid" while storage is "include"
|
||||||
const s: any = { ...settings };
|
const s: any = { ...settings };
|
||||||
@ -174,11 +233,14 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy {
|
|||||||
/** attribute binding for minSpecial[max] */
|
/** attribute binding for minSpecial[max] */
|
||||||
protected maxMinSpecial: number;
|
protected maxMinSpecial: number;
|
||||||
|
|
||||||
|
/** display binding for enterprise policy notice */
|
||||||
|
protected policyInEffect: boolean;
|
||||||
|
|
||||||
private toggleEnabled(setting: keyof typeof Controls, enabled: boolean) {
|
private toggleEnabled(setting: keyof typeof Controls, enabled: boolean) {
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
this.settings.get(setting).enable();
|
this.settings.get(setting).enable({ emitEvent: false });
|
||||||
} else {
|
} else {
|
||||||
this.settings.get(setting).disable();
|
this.settings.get(setting).disable({ emitEvent: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { ValidatorFn, Validators } from "@angular/forms";
|
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 { AnyConstraint, Constraints } from "@bitwarden/common/tools/types";
|
||||||
import { UserId } from "@bitwarden/common/types/guid";
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
@ -13,6 +13,7 @@ export function completeOnAccountSwitch() {
|
|||||||
pairwise(),
|
pairwise(),
|
||||||
takeWhile(([prev, next]) => (prev ?? next) === next),
|
takeWhile(([prev, next]) => (prev ?? next) === next),
|
||||||
map(([_, id]) => id),
|
map(([_, id]) => id),
|
||||||
|
distinctUntilChanged(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import { PasswordGenerationOptions } from "../types";
|
import { PasswordGenerationOptions, PasswordGeneratorSettings } from "../types";
|
||||||
|
|
||||||
import { DefaultPasswordBoundaries } from "./default-password-boundaries";
|
import { DefaultPasswordBoundaries } from "./default-password-boundaries";
|
||||||
|
|
||||||
/** The default options for password generation. */
|
/** The default options for password generation. */
|
||||||
export const DefaultPasswordGenerationOptions: Partial<PasswordGenerationOptions> = Object.freeze({
|
export const DefaultPasswordGenerationOptions: Partial<PasswordGenerationOptions> &
|
||||||
|
PasswordGeneratorSettings = Object.freeze({
|
||||||
length: 14,
|
length: 14,
|
||||||
minLength: DefaultPasswordBoundaries.length.min,
|
minLength: DefaultPasswordBoundaries.length.min,
|
||||||
ambiguous: true,
|
ambiguous: true,
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
DynamicPasswordPolicyConstraints,
|
||||||
passphraseLeastPrivilege,
|
passphraseLeastPrivilege,
|
||||||
passwordLeastPrivilege,
|
passwordLeastPrivilege,
|
||||||
PassphraseGeneratorOptionsEvaluator,
|
PassphraseGeneratorOptionsEvaluator,
|
||||||
|
PassphrasePolicyConstraints,
|
||||||
PasswordGeneratorOptionsEvaluator,
|
PasswordGeneratorOptionsEvaluator,
|
||||||
} from "../policies";
|
} from "../policies";
|
||||||
import {
|
import {
|
||||||
@ -23,7 +25,7 @@ const PASSPHRASE = Object.freeze({
|
|||||||
}),
|
}),
|
||||||
combine: passphraseLeastPrivilege,
|
combine: passphraseLeastPrivilege,
|
||||||
createEvaluator: (policy) => new PassphraseGeneratorOptionsEvaluator(policy),
|
createEvaluator: (policy) => new PassphraseGeneratorOptionsEvaluator(policy),
|
||||||
createEvaluatorV2: (policy) => new PassphraseGeneratorOptionsEvaluator(policy),
|
toConstraints: (policy) => new PassphrasePolicyConstraints(policy),
|
||||||
} as PolicyConfiguration<PassphraseGeneratorPolicy, PassphraseGenerationOptions>);
|
} as PolicyConfiguration<PassphraseGeneratorPolicy, PassphraseGenerationOptions>);
|
||||||
|
|
||||||
const PASSWORD = Object.freeze({
|
const PASSWORD = Object.freeze({
|
||||||
@ -39,7 +41,7 @@ const PASSWORD = Object.freeze({
|
|||||||
}),
|
}),
|
||||||
combine: passwordLeastPrivilege,
|
combine: passwordLeastPrivilege,
|
||||||
createEvaluator: (policy) => new PasswordGeneratorOptionsEvaluator(policy),
|
createEvaluator: (policy) => new PasswordGeneratorOptionsEvaluator(policy),
|
||||||
createEvaluatorV2: (policy) => new PasswordGeneratorOptionsEvaluator(policy),
|
toConstraints: (policy) => new DynamicPasswordPolicyConstraints(policy),
|
||||||
} as PolicyConfiguration<PasswordGeneratorPolicy, PasswordGenerationOptions>);
|
} as PolicyConfiguration<PasswordGeneratorPolicy, PasswordGenerationOptions>);
|
||||||
|
|
||||||
/** Policy configurations */
|
/** Policy configurations */
|
||||||
|
280
libs/tools/generator/core/src/policies/constraints.spec.ts
Normal file
280
libs/tools/generator/core/src/policies/constraints.spec.ts
Normal file
@ -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<boolean> = 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
164
libs/tools/generator/core/src/policies/constraints.ts
Normal file
164
libs/tools/generator/core/src/policies/constraints.ts
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
import { Constraint } from "@bitwarden/common/tools/types";
|
||||||
|
|
||||||
|
import { sum } from "../util";
|
||||||
|
|
||||||
|
const AtLeastOne: Constraint<number> = { min: 1 };
|
||||||
|
const RequiresTrue: Constraint<boolean> = { 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<number>, dependencies: Constraint<number>[]) {
|
||||||
|
// 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<boolean>): Constraint<boolean> {
|
||||||
|
if (!readonly) {
|
||||||
|
return constraint;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Constraint<boolean> = 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<T>(enabled: boolean, constraint: Constraint<T>): Constraint<T> {
|
||||||
|
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<number>): Constraint<number> {
|
||||||
|
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<number>) {
|
||||||
|
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<string>,
|
||||||
|
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<boolean>) {
|
||||||
|
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,
|
||||||
|
};
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -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<PasswordGeneratorSettings>
|
||||||
|
{
|
||||||
|
/** 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<PasswordGeneratorSettings>;
|
||||||
|
|
||||||
|
calibrate(state: PasswordGeneratorSettings): StateConstraints<PasswordGeneratorSettings> {
|
||||||
|
// 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<PasswordGeneratorSettings> = {
|
||||||
|
...this.constraints,
|
||||||
|
minLowercase: maybe<number>(lowercase, this.constraints.minLowercase ?? AtLeastOne),
|
||||||
|
minUppercase: maybe<number>(uppercase, this.constraints.minUppercase ?? AtLeastOne),
|
||||||
|
minNumber: maybe<number>(number, this.constraints.minNumber),
|
||||||
|
minSpecial: maybe<number>(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);
|
||||||
|
}
|
@ -1,5 +1,7 @@
|
|||||||
export { DefaultPolicyEvaluator } from "./default-policy-evaluator";
|
export { DefaultPolicyEvaluator } from "./default-policy-evaluator";
|
||||||
|
export { DynamicPasswordPolicyConstraints } from "./dynamic-password-policy-constraints";
|
||||||
export { PassphraseGeneratorOptionsEvaluator } from "./passphrase-generator-options-evaluator";
|
export { PassphraseGeneratorOptionsEvaluator } from "./passphrase-generator-options-evaluator";
|
||||||
|
export { PassphrasePolicyConstraints } from "./passphrase-policy-constraints";
|
||||||
export { PasswordGeneratorOptionsEvaluator } from "./password-generator-options-evaluator";
|
export { PasswordGeneratorOptionsEvaluator } from "./password-generator-options-evaluator";
|
||||||
export { passphraseLeastPrivilege } from "./passphrase-least-privilege";
|
export { passphraseLeastPrivilege } from "./passphrase-least-privilege";
|
||||||
export { passwordLeastPrivilege } from "./password-least-privilege";
|
export { passwordLeastPrivilege } from "./password-least-privilege";
|
||||||
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -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<PassphraseGenerationOptions> {
|
||||||
|
/** 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<PolicyConstraints<PassphraseGenerationOptions>>;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -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<PasswordGeneratorSettings> {
|
||||||
|
/** Creates a password policy constraints
|
||||||
|
* @param constraints Constraints derived from the policy and application-defined defaults
|
||||||
|
*/
|
||||||
|
constructor(readonly constraints: PolicyConstraints<PasswordGeneratorSettings>) {}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
@ -18,16 +18,16 @@ export function mapPolicyToEvaluator<Policy, Evaluator>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Maps an administrative console policy to a policy evaluator using the provided configuration.
|
/** Maps an administrative console policy to constraints using the provided configuration.
|
||||||
* @param configuration the configuration that constructs the evaluator.
|
* @param configuration the configuration that constructs the constraints.
|
||||||
*/
|
*/
|
||||||
export function mapPolicyToEvaluatorV2<Policy, Evaluator>(
|
export function mapPolicyToConstraints<Policy, Evaluator>(
|
||||||
configuration: PolicyConfiguration<Policy, Evaluator>,
|
configuration: PolicyConfiguration<Policy, Evaluator>,
|
||||||
) {
|
) {
|
||||||
return pipe(
|
return pipe(
|
||||||
reduceCollection(configuration.combine, configuration.disabledValue),
|
reduceCollection(configuration.combine, configuration.disabledValue),
|
||||||
distinctIfShallowMatch(),
|
distinctIfShallowMatch(),
|
||||||
map(configuration.createEvaluatorV2),
|
map(configuration.toConstraints),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5,7 +5,7 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
|
|||||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||||
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
|
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 { OrganizationId, PolicyId, UserId } from "@bitwarden/common/types/guid";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -14,8 +14,12 @@ import {
|
|||||||
awaitAsync,
|
awaitAsync,
|
||||||
ObservableTracker,
|
ObservableTracker,
|
||||||
} from "../../../../../common/spec";
|
} from "../../../../../common/spec";
|
||||||
import { PolicyEvaluator, Randomizer } from "../abstractions";
|
import { Randomizer } from "../abstractions";
|
||||||
import { CredentialGeneratorConfiguration, GeneratedCredential } from "../types";
|
import {
|
||||||
|
CredentialGeneratorConfiguration,
|
||||||
|
GeneratedCredential,
|
||||||
|
GeneratorConstraints,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
import { CredentialGeneratorService } from "./credential-generator.service";
|
import { CredentialGeneratorService } from "./credential-generator.service";
|
||||||
|
|
||||||
@ -72,18 +76,37 @@ const SomeConfiguration: CredentialGeneratorConfiguration<SomeSettings, SomePoli
|
|||||||
createEvaluator: () => {
|
createEvaluator: () => {
|
||||||
throw new Error("this should never be called");
|
throw new Error("this should never be called");
|
||||||
},
|
},
|
||||||
createEvaluatorV2: (policy) => {
|
toConstraints: (policy) => {
|
||||||
return {
|
if (policy.fooPolicy) {
|
||||||
foo: {},
|
return {
|
||||||
policy,
|
constraints: {
|
||||||
policyInEffect: policy.fooPolicy,
|
policyInEffect: true,
|
||||||
applyPolicy: (settings) => {
|
},
|
||||||
return policy.fooPolicy ? { foo: `apply(${settings.foo})` } : settings;
|
calibrate(state: SomeSettings) {
|
||||||
},
|
return {
|
||||||
sanitize: (settings) => {
|
constraints: {},
|
||||||
return policy.fooPolicy ? { foo: `sanitize(${settings.foo})` } : settings;
|
adjust(state: SomeSettings) {
|
||||||
},
|
return { foo: `adjusted(${state.foo})` };
|
||||||
} as PolicyEvaluator<SomePolicy, SomeSettings> & Constraints<SomeSettings>;
|
},
|
||||||
|
fix(state: SomeSettings) {
|
||||||
|
return { foo: `fixed(${state.foo})` };
|
||||||
|
},
|
||||||
|
} satisfies StateConstraints<SomeSettings>;
|
||||||
|
},
|
||||||
|
} satisfies GeneratorConstraints<SomeSettings>;
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
constraints: {
|
||||||
|
policyInEffect: false,
|
||||||
|
},
|
||||||
|
adjust(state: SomeSettings) {
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
fix(state: SomeSettings) {
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
} satisfies GeneratorConstraints<SomeSettings>;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -378,7 +401,7 @@ describe("CredentialGeneratorService", () => {
|
|||||||
|
|
||||||
const result = await firstValueFrom(generator.settings$(SomeConfiguration));
|
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 () => {
|
it("follows changes to the active user", async () => {
|
||||||
@ -525,17 +548,16 @@ describe("CredentialGeneratorService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("policy$", () => {
|
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 generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
||||||
const userId$ = new BehaviorSubject(SomeUser).asObservable();
|
const userId$ = new BehaviorSubject(SomeUser).asObservable();
|
||||||
|
|
||||||
const result = await firstValueFrom(generator.policy$(SomeConfiguration, { userId$ }));
|
const result = await firstValueFrom(generator.policy$(SomeConfiguration, { userId$ }));
|
||||||
|
|
||||||
expect(result.policy).toEqual(SomeConfiguration.policy.disabledValue);
|
expect(result.constraints.policyInEffect).toBeFalsy();
|
||||||
expect(result.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 generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
||||||
const userId$ = new BehaviorSubject(SomeUser).asObservable();
|
const userId$ = new BehaviorSubject(SomeUser).asObservable();
|
||||||
const policy$ = new BehaviorSubject([somePolicy]);
|
const policy$ = new BehaviorSubject([somePolicy]);
|
||||||
@ -543,8 +565,7 @@ describe("CredentialGeneratorService", () => {
|
|||||||
|
|
||||||
const result = await firstValueFrom(generator.policy$(SomeConfiguration, { userId$ }));
|
const result = await firstValueFrom(generator.policy$(SomeConfiguration, { userId$ }));
|
||||||
|
|
||||||
expect(result.policy).toEqual({ fooPolicy: true });
|
expect(result.constraints.policyInEffect).toBeTruthy();
|
||||||
expect(result.policyInEffect).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("follows policy emissions", async () => {
|
it("follows policy emissions", async () => {
|
||||||
@ -553,7 +574,7 @@ describe("CredentialGeneratorService", () => {
|
|||||||
const userId$ = userId.asObservable();
|
const userId$ = userId.asObservable();
|
||||||
const somePolicySubject = new BehaviorSubject([somePolicy]);
|
const somePolicySubject = new BehaviorSubject([somePolicy]);
|
||||||
policyService.getAll$.mockReturnValueOnce(somePolicySubject.asObservable());
|
policyService.getAll$.mockReturnValueOnce(somePolicySubject.asObservable());
|
||||||
const emissions: any = [];
|
const emissions: GeneratorConstraints<SomeSettings>[] = [];
|
||||||
const sub = generator
|
const sub = generator
|
||||||
.policy$(SomeConfiguration, { userId$ })
|
.policy$(SomeConfiguration, { userId$ })
|
||||||
.subscribe((policy) => emissions.push(policy));
|
.subscribe((policy) => emissions.push(policy));
|
||||||
@ -564,10 +585,8 @@ describe("CredentialGeneratorService", () => {
|
|||||||
sub.unsubscribe();
|
sub.unsubscribe();
|
||||||
const [someResult, anotherResult] = emissions;
|
const [someResult, anotherResult] = emissions;
|
||||||
|
|
||||||
expect(someResult.policy).toEqual({ fooPolicy: true });
|
expect(someResult.constraints.policyInEffect).toBeTruthy();
|
||||||
expect(someResult.policyInEffect).toBeTruthy();
|
expect(anotherResult.constraints.policyInEffect).toBeFalsy();
|
||||||
expect(anotherResult.policy).toEqual(SomeConfiguration.policy.disabledValue);
|
|
||||||
expect(anotherResult.policyInEffect).toBeFalsy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("follows user emissions", async () => {
|
it("follows user emissions", async () => {
|
||||||
@ -577,7 +596,7 @@ describe("CredentialGeneratorService", () => {
|
|||||||
const somePolicy$ = new BehaviorSubject([somePolicy]).asObservable();
|
const somePolicy$ = new BehaviorSubject([somePolicy]).asObservable();
|
||||||
const anotherPolicy$ = new BehaviorSubject([]).asObservable();
|
const anotherPolicy$ = new BehaviorSubject([]).asObservable();
|
||||||
policyService.getAll$.mockReturnValueOnce(somePolicy$).mockReturnValueOnce(anotherPolicy$);
|
policyService.getAll$.mockReturnValueOnce(somePolicy$).mockReturnValueOnce(anotherPolicy$);
|
||||||
const emissions: any = [];
|
const emissions: GeneratorConstraints<SomeSettings>[] = [];
|
||||||
const sub = generator
|
const sub = generator
|
||||||
.policy$(SomeConfiguration, { userId$ })
|
.policy$(SomeConfiguration, { userId$ })
|
||||||
.subscribe((policy) => emissions.push(policy));
|
.subscribe((policy) => emissions.push(policy));
|
||||||
@ -588,10 +607,8 @@ describe("CredentialGeneratorService", () => {
|
|||||||
sub.unsubscribe();
|
sub.unsubscribe();
|
||||||
const [someResult, anotherResult] = emissions;
|
const [someResult, anotherResult] = emissions;
|
||||||
|
|
||||||
expect(someResult.policy).toEqual({ fooPolicy: true });
|
expect(someResult.constraints.policyInEffect).toBeTruthy();
|
||||||
expect(someResult.policyInEffect).toBeTruthy();
|
expect(anotherResult.constraints.policyInEffect).toBeFalsy();
|
||||||
expect(anotherResult.policy).toEqual(SomeConfiguration.policy.disabledValue);
|
|
||||||
expect(anotherResult.policyInEffect).toBeFalsy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("errors when the user errors", async () => {
|
it("errors when the user errors", async () => {
|
||||||
|
@ -24,12 +24,13 @@ import {
|
|||||||
SingleUserDependency,
|
SingleUserDependency,
|
||||||
UserDependency,
|
UserDependency,
|
||||||
} from "@bitwarden/common/tools/dependencies";
|
} 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 { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject";
|
||||||
import { Constraints } from "@bitwarden/common/tools/types";
|
|
||||||
|
|
||||||
import { PolicyEvaluator, Randomizer } from "../abstractions";
|
import { Randomizer } from "../abstractions";
|
||||||
import { mapPolicyToEvaluatorV2 } from "../rx";
|
import { mapPolicyToConstraints } from "../rx";
|
||||||
import { CredentialGeneratorConfiguration as Configuration } from "../types/credential-generator-configuration";
|
import { CredentialGeneratorConfiguration as Configuration } from "../types/credential-generator-configuration";
|
||||||
|
import { GeneratorConstraints } from "../types/generator-constraints";
|
||||||
|
|
||||||
type Policy$Dependencies = UserDependency;
|
type Policy$Dependencies = UserDependency;
|
||||||
type Settings$Dependencies = Partial<UserDependency>;
|
type Settings$Dependencies = Partial<UserDependency>;
|
||||||
@ -44,9 +45,6 @@ type Generate$Dependencies = Simplify<Partial<OnDependency> & Partial<UserDepend
|
|||||||
*/
|
*/
|
||||||
website$?: Observable<string>;
|
website$?: Observable<string>;
|
||||||
};
|
};
|
||||||
// FIXME: once the modernization is complete, switch the type parameters
|
|
||||||
// in `PolicyEvaluator<P, S>` and bake-in the constraints type.
|
|
||||||
type Evaluator<Settings, Policy> = PolicyEvaluator<Policy, Settings> & Constraints<Settings>;
|
|
||||||
|
|
||||||
export class CredentialGeneratorService {
|
export class CredentialGeneratorService {
|
||||||
constructor(
|
constructor(
|
||||||
@ -61,7 +59,7 @@ export class CredentialGeneratorService {
|
|||||||
* this emits. Otherwise, a new credential is emitted when the settings
|
* this emits. Otherwise, a new credential is emitted when the settings
|
||||||
* update.
|
* update.
|
||||||
*/
|
*/
|
||||||
generate$<Settings, Policy>(
|
generate$<Settings extends object, Policy>(
|
||||||
configuration: Readonly<Configuration<Settings, Policy>>,
|
configuration: Readonly<Configuration<Settings, Policy>>,
|
||||||
dependencies?: Generate$Dependencies,
|
dependencies?: Generate$Dependencies,
|
||||||
) {
|
) {
|
||||||
@ -96,7 +94,7 @@ export class CredentialGeneratorService {
|
|||||||
* @returns an observable that emits settings
|
* @returns an observable that emits settings
|
||||||
* @remarks the observable enforces policies on the settings
|
* @remarks the observable enforces policies on the settings
|
||||||
*/
|
*/
|
||||||
settings$<Settings, Policy>(
|
settings$<Settings extends object, Policy>(
|
||||||
configuration: Configuration<Settings, Policy>,
|
configuration: Configuration<Settings, Policy>,
|
||||||
dependencies?: Settings$Dependencies,
|
dependencies?: Settings$Dependencies,
|
||||||
) {
|
) {
|
||||||
@ -118,10 +116,9 @@ export class CredentialGeneratorService {
|
|||||||
|
|
||||||
const settings$ = combineLatest([state$, this.policy$(configuration, { userId$ })]).pipe(
|
const settings$ = combineLatest([state$, this.policy$(configuration, { userId$ })]).pipe(
|
||||||
map(([settings, policy]) => {
|
map(([settings, policy]) => {
|
||||||
// FIXME: create `onLoadApply` that wraps these operations
|
const calibration = isDynamic(policy) ? policy.calibrate(settings) : policy;
|
||||||
const applied = policy.applyPolicy(settings);
|
const adjusted = calibration.adjust(settings);
|
||||||
const sanitized = policy.sanitize(applied);
|
return adjusted;
|
||||||
return sanitized;
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -135,7 +132,7 @@ export class CredentialGeneratorService {
|
|||||||
* `dependencies.singleUserId$` becomes available.
|
* `dependencies.singleUserId$` becomes available.
|
||||||
* @remarks the subject enforces policy for the settings
|
* @remarks the subject enforces policy for the settings
|
||||||
*/
|
*/
|
||||||
async settings<Settings, Policy>(
|
async settings<Settings extends object, Policy>(
|
||||||
configuration: Readonly<Configuration<Settings, Policy>>,
|
configuration: Readonly<Configuration<Settings, Policy>>,
|
||||||
dependencies: SingleUserDependency,
|
dependencies: SingleUserDependency,
|
||||||
) {
|
) {
|
||||||
@ -143,19 +140,14 @@ export class CredentialGeneratorService {
|
|||||||
dependencies.singleUserId$.pipe(filter((userId) => !!userId)),
|
dependencies.singleUserId$.pipe(filter((userId) => !!userId)),
|
||||||
);
|
);
|
||||||
const state = this.stateProvider.getUser(userId, configuration.settings.account);
|
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.
|
const subject = new UserStateSubject(state, { ...dependencies, constraints$ });
|
||||||
// Note that policies could be evaluated when the settings are saved or when they
|
|
||||||
// are loaded. The existing subject presently could only apply settings on save
|
|
||||||
// (by wiring the policy in as a dependency and applying with "nextState"), and
|
|
||||||
// even that has a limitation since arbitrary dependencies do not trigger state
|
|
||||||
// emissions.
|
|
||||||
const subject = new UserStateSubject(state, dependencies);
|
|
||||||
|
|
||||||
return subject;
|
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
|
* @param dependencies.userId$ determines which user's policy is loaded
|
||||||
* @returns an observable that emits the policy once `dependencies.userId$`
|
* @returns an observable that emits the policy once `dependencies.userId$`
|
||||||
* and the policy become available.
|
* and the policy become available.
|
||||||
@ -163,20 +155,20 @@ export class CredentialGeneratorService {
|
|||||||
policy$<Settings, Policy>(
|
policy$<Settings, Policy>(
|
||||||
configuration: Configuration<Settings, Policy>,
|
configuration: Configuration<Settings, Policy>,
|
||||||
dependencies: Policy$Dependencies,
|
dependencies: Policy$Dependencies,
|
||||||
): Observable<Evaluator<Settings, Policy>> {
|
): Observable<GeneratorConstraints<Settings>> {
|
||||||
const completion$ = dependencies.userId$.pipe(ignoreElements(), endWith(true));
|
const completion$ = dependencies.userId$.pipe(ignoreElements(), endWith(true));
|
||||||
|
|
||||||
const policy$ = dependencies.userId$.pipe(
|
const constraints$ = dependencies.userId$.pipe(
|
||||||
mergeMap((userId) => {
|
mergeMap((userId) => {
|
||||||
// complete policy emissions otherwise `mergeMap` holds `policy$` open indefinitely
|
// complete policy emissions otherwise `mergeMap` holds `policies$` open indefinitely
|
||||||
const policies$ = this.policyService
|
const policies$ = this.policyService
|
||||||
.getAll$(configuration.policy.type, userId)
|
.getAll$(configuration.policy.type, userId)
|
||||||
.pipe(takeUntil(completion$));
|
.pipe(takeUntil(completion$));
|
||||||
return policies$;
|
return policies$;
|
||||||
}),
|
}),
|
||||||
mapPolicyToEvaluatorV2(configuration.policy),
|
mapPolicyToConstraints(configuration.policy),
|
||||||
);
|
);
|
||||||
|
|
||||||
return policy$;
|
return constraints$;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
11
libs/tools/generator/core/src/types/generator-constraints.ts
Normal file
11
libs/tools/generator/core/src/types/generator-constraints.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import {
|
||||||
|
DynamicStateConstraints,
|
||||||
|
PolicyConstraints,
|
||||||
|
StateConstraints,
|
||||||
|
} from "@bitwarden/common/tools/types";
|
||||||
|
|
||||||
|
/** Specializes state constraints to include policy. */
|
||||||
|
export type GeneratorConstraints<Settings> = { constraints: PolicyConstraints<Settings> } & (
|
||||||
|
| DynamicStateConstraints<Settings>
|
||||||
|
| StateConstraints<Settings>
|
||||||
|
);
|
@ -5,6 +5,7 @@ export * from "./credential-generator";
|
|||||||
export * from "./credential-generator-configuration";
|
export * from "./credential-generator-configuration";
|
||||||
export * from "./eff-username-generator-options";
|
export * from "./eff-username-generator-options";
|
||||||
export * from "./forwarder-options";
|
export * from "./forwarder-options";
|
||||||
|
export * from "./generator-constraints";
|
||||||
export * from "./generated-credential";
|
export * from "./generated-credential";
|
||||||
export * from "./generator-options";
|
export * from "./generator-options";
|
||||||
export * from "./generator-type";
|
export * from "./generator-type";
|
||||||
|
@ -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.
|
/** Request format for password credential generation.
|
||||||
* All members of this type may be `undefined` when the user is
|
* All members of this type may be `undefined` when the user is
|
||||||
* generating a passphrase.
|
* generating a passphrase.
|
||||||
@ -6,63 +64,9 @@
|
|||||||
* it is used with the "password generator" types. The name
|
* it is used with the "password generator" types. The name
|
||||||
* `PasswordGeneratorOptions` is already in use by legacy code.
|
* `PasswordGeneratorOptions` is already in use by legacy code.
|
||||||
*/
|
*/
|
||||||
export type PasswordGenerationOptions = {
|
export type PasswordGenerationOptions = Partial<PasswordGeneratorSettings> & {
|
||||||
/** The length of the password selected by the user */
|
|
||||||
length?: number;
|
|
||||||
|
|
||||||
/** The minimum length of the password. This defaults to 5, and increases
|
/** 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.
|
* to ensure `minLength` is at least as large as the sum of the other minimums.
|
||||||
*/
|
*/
|
||||||
minLength?: number;
|
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;
|
|
||||||
};
|
};
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||||
import { Policy as AdminPolicy } from "@bitwarden/common/admin-console/models/domain/policy";
|
import { Policy as AdminPolicy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||||
import { Constraints } from "@bitwarden/common/tools/types";
|
|
||||||
|
|
||||||
import { PolicyEvaluator } from "../abstractions";
|
import { PolicyEvaluator } from "../abstractions";
|
||||||
|
|
||||||
|
import { GeneratorConstraints } from "./generator-constraints";
|
||||||
|
|
||||||
/** Determines how to construct a password generator policy */
|
/** Determines how to construct a password generator policy */
|
||||||
export type PolicyConfiguration<Policy, Settings> = {
|
export type PolicyConfiguration<Policy, Settings> = {
|
||||||
type: PolicyType;
|
type: PolicyType;
|
||||||
@ -17,13 +18,15 @@ export type PolicyConfiguration<Policy, Settings> = {
|
|||||||
combine: (acc: Policy, policy: AdminPolicy) => Policy;
|
combine: (acc: Policy, policy: AdminPolicy) => Policy;
|
||||||
|
|
||||||
/** Converts policy service data into an actionable policy.
|
/** Converts policy service data into an actionable policy.
|
||||||
|
* @deprecated provided only for backwards compatibility.
|
||||||
|
* Use `toConstraints` instead.
|
||||||
*/
|
*/
|
||||||
createEvaluator: (policy: Policy) => PolicyEvaluator<Policy, Settings>;
|
createEvaluator: (policy: Policy) => PolicyEvaluator<Policy, Settings>;
|
||||||
|
|
||||||
/** 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;
|
* @remarks this version includes constraints needed for the reactive forms;
|
||||||
* it was introduced so that the constraints can be incrementally introduced
|
* it was introduced so that the constraints can be incrementally introduced
|
||||||
* as the new UI is built.
|
* as the new UI is built.
|
||||||
*/
|
*/
|
||||||
createEvaluatorV2?: (policy: Policy) => PolicyEvaluator<Policy, Settings> & Constraints<Settings>;
|
toConstraints: (policy: Policy) => GeneratorConstraints<Settings>;
|
||||||
};
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user