1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-17 16:53:34 +00:00

[PM-6818] legacy generator service adapter (#8582)

* introduce legacy generators
* introduce generator navigation service
* Introduce default options. These accept a userId so that they can be policy-defined
* replace `GeneratorOptions` with backwards compatible `GeneratorNavigation`
This commit is contained in:
✨ Audrey ✨
2024-04-03 13:48:33 -04:00
committed by GitHub
parent ff3ff89e20
commit b579bc8f96
64 changed files with 2759 additions and 622 deletions

View File

@@ -0,0 +1,100 @@
/**
* include structuredClone in test environment.
* @jest-environment ../../../../shared/test.environment.ts
*/
import { mock } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
import { PolicyService } from "../../../admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "../../../admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy } from "../../../admin-console/models/domain/policy";
import { UserId } from "../../../types/guid";
import { GENERATOR_SETTINGS } from "../key-definitions";
import {
GeneratorNavigationEvaluator,
DefaultGeneratorNavigationService,
DefaultGeneratorNavigation,
} from "./";
const SomeUser = "some user" as UserId;
describe("DefaultGeneratorNavigationService", () => {
describe("options$", () => {
it("emits options", async () => {
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
const settings = { type: "password" as const };
await stateProvider.setUserState(GENERATOR_SETTINGS, settings, SomeUser);
const navigation = new DefaultGeneratorNavigationService(stateProvider, null);
const result = await firstValueFrom(navigation.options$(SomeUser));
expect(result).toEqual(settings);
});
});
describe("defaults$", () => {
it("emits default options", async () => {
const navigation = new DefaultGeneratorNavigationService(null, null);
const result = await firstValueFrom(navigation.defaults$(SomeUser));
expect(result).toEqual(DefaultGeneratorNavigation);
});
});
describe("evaluator$", () => {
it("emits a GeneratorNavigationEvaluator", async () => {
const policyService = mock<PolicyService>({
getAll$() {
return of([]);
},
});
const navigation = new DefaultGeneratorNavigationService(null, policyService);
const result = await firstValueFrom(navigation.evaluator$(SomeUser));
expect(result).toBeInstanceOf(GeneratorNavigationEvaluator);
});
});
describe("enforcePolicy", () => {
it("applies policy", async () => {
const policyService = mock<PolicyService>({
getAll$(_type: PolicyType, _user: UserId) {
return of([
new Policy({
id: "" as any,
organizationId: "" as any,
enabled: true,
type: PolicyType.PasswordGenerator,
data: { defaultType: "password" },
}),
]);
},
});
const navigation = new DefaultGeneratorNavigationService(null, policyService);
const options = {};
const result = await navigation.enforcePolicy(SomeUser, options);
expect(result).toMatchObject({ type: "password" });
});
});
describe("saveOptions", () => {
it("updates options$", async () => {
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
const navigation = new DefaultGeneratorNavigationService(stateProvider, null);
const settings = { type: "password" as const };
await navigation.saveOptions(SomeUser, settings);
const result = await firstValueFrom(navigation.options$(SomeUser));
expect(result).toEqual(settings);
});
});
});

View File

@@ -0,0 +1,71 @@
import { BehaviorSubject, Observable, firstValueFrom, map } from "rxjs";
import { PolicyService } from "../../../admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "../../../admin-console/enums";
import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { GeneratorNavigationService } from "../abstractions/generator-navigation.service.abstraction";
import { GENERATOR_SETTINGS } from "../key-definitions";
import { reduceCollection } from "../reduce-collection.operator";
import { DefaultGeneratorNavigation, GeneratorNavigation } from "./generator-navigation";
import { GeneratorNavigationEvaluator } from "./generator-navigation-evaluator";
import { DisabledGeneratorNavigationPolicy, preferPassword } from "./generator-navigation-policy";
export class DefaultGeneratorNavigationService implements GeneratorNavigationService {
/** instantiates the password generator strategy.
* @param stateProvider provides durable state
* @param policy provides the policy to enforce
*/
constructor(
private readonly stateProvider: StateProvider,
private readonly policy: PolicyService,
) {}
/** An observable monitoring the options saved to disk.
* The observable updates when the options are saved.
* @param userId: Identifies the user making the request
*/
options$(userId: UserId): Observable<GeneratorNavigation> {
return this.stateProvider.getUserState$(GENERATOR_SETTINGS, userId);
}
/** Gets the default options. */
defaults$(userId: UserId): Observable<GeneratorNavigation> {
return new BehaviorSubject({ ...DefaultGeneratorNavigation });
}
/** An observable monitoring the options used to enforce policy.
* The observable updates when the policy changes.
* @param userId: Identifies the user making the request
*/
evaluator$(userId: UserId) {
const evaluator$ = this.policy.getAll$(PolicyType.PasswordGenerator, userId).pipe(
reduceCollection(preferPassword, DisabledGeneratorNavigationPolicy),
map((policy) => new GeneratorNavigationEvaluator(policy)),
);
return evaluator$;
}
/** Enforces the policy on the given options
* @param userId: Identifies the user making the request
* @param options the options to enforce the policy on
* @returns a new instance of the options with the policy enforced
*/
async enforcePolicy(userId: UserId, options: GeneratorNavigation) {
const evaluator = await firstValueFrom(this.evaluator$(userId));
const applied = evaluator.applyPolicy(options);
const sanitized = evaluator.sanitize(applied);
return sanitized;
}
/** Saves the navigation options to disk.
* @param userId: Identifies the user making the request
* @param options the options to save
* @returns a promise that resolves when the options are saved
*/
async saveOptions(userId: UserId, options: GeneratorNavigation): Promise<void> {
await this.stateProvider.setUserState(GENERATOR_SETTINGS, options, userId);
}
}

View File

@@ -0,0 +1,64 @@
import { DefaultGeneratorNavigation } from "./generator-navigation";
import { GeneratorNavigationEvaluator } from "./generator-navigation-evaluator";
describe("GeneratorNavigationEvaluator", () => {
describe("policyInEffect", () => {
it.each([["passphrase"], ["password"]] as const)(
"returns true if the policy has a defaultType (= %p)",
(defaultType) => {
const evaluator = new GeneratorNavigationEvaluator({ defaultType });
expect(evaluator.policyInEffect).toEqual(true);
},
);
it.each([[undefined], [null], ["" as any]])(
"returns false if the policy has a falsy defaultType (= %p)",
(defaultType) => {
const evaluator = new GeneratorNavigationEvaluator({ defaultType });
expect(evaluator.policyInEffect).toEqual(false);
},
);
});
describe("applyPolicy", () => {
it("returns the input options", () => {
const evaluator = new GeneratorNavigationEvaluator(null);
const options = { type: "password" as const };
const result = evaluator.applyPolicy(options);
expect(result).toEqual(options);
});
});
describe("sanitize", () => {
it.each([["passphrase"], ["password"]] as const)(
"defaults options to the policy's default type (= %p) when a policy is in effect",
(defaultType) => {
const evaluator = new GeneratorNavigationEvaluator({ defaultType });
const result = evaluator.sanitize({});
expect(result).toEqual({ type: defaultType });
},
);
it("defaults options to the default generator navigation type when a policy is not in effect", () => {
const evaluator = new GeneratorNavigationEvaluator(null);
const result = evaluator.sanitize({});
expect(result.type).toEqual(DefaultGeneratorNavigation.type);
});
it("retains the options type when it is set", () => {
const evaluator = new GeneratorNavigationEvaluator({ defaultType: "passphrase" });
const result = evaluator.sanitize({ type: "password" });
expect(result).toEqual({ type: "password" });
});
});
});

View File

@@ -0,0 +1,43 @@
import { PolicyEvaluator } from "../abstractions";
import { DefaultGeneratorNavigation, GeneratorNavigation } from "./generator-navigation";
import { GeneratorNavigationPolicy } from "./generator-navigation-policy";
/** Enforces policy for generator navigation options.
*/
export class GeneratorNavigationEvaluator
implements PolicyEvaluator<GeneratorNavigationPolicy, GeneratorNavigation>
{
/** Instantiates the evaluator.
* @param policy The policy applied by the evaluator. When this conflicts with
* the defaults, the policy takes precedence.
*/
constructor(readonly policy: GeneratorNavigationPolicy) {}
/** {@link PolicyEvaluator.policyInEffect} */
get policyInEffect(): boolean {
return this.policy?.defaultType ? true : false;
}
/** Apply policy to the input options.
* @param options The options to build from. These options are not altered.
* @returns A new password generation request with policy applied.
*/
applyPolicy(options: GeneratorNavigation): GeneratorNavigation {
return options;
}
/** Ensures internal options consistency.
* @param options The options to cascade. These options are not altered.
* @returns A passphrase generation request with cascade applied.
*/
sanitize(options: GeneratorNavigation): GeneratorNavigation {
const defaultType = this.policyInEffect
? this.policy.defaultType
: DefaultGeneratorNavigation.type;
return {
...options,
type: options.type ?? defaultType,
};
}
}

View File

@@ -0,0 +1,63 @@
import { PolicyType } from "../../../admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy } from "../../../admin-console/models/domain/policy";
import { PolicyId } from "../../../types/guid";
import { DisabledGeneratorNavigationPolicy, preferPassword } from "./generator-navigation-policy";
function createPolicy(
data: any,
type: PolicyType = PolicyType.PasswordGenerator,
enabled: boolean = true,
) {
return new Policy({
id: "id" as PolicyId,
organizationId: "organizationId",
data,
enabled,
type,
});
}
describe("leastPrivilege", () => {
it("should return the accumulator when the policy type does not apply", () => {
const policy = createPolicy({}, PolicyType.RequireSso);
const result = preferPassword(DisabledGeneratorNavigationPolicy, policy);
expect(result).toEqual(DisabledGeneratorNavigationPolicy);
});
it("should return the accumulator when the policy is not enabled", () => {
const policy = createPolicy({}, PolicyType.PasswordGenerator, false);
const result = preferPassword(DisabledGeneratorNavigationPolicy, policy);
expect(result).toEqual(DisabledGeneratorNavigationPolicy);
});
it("should take the %p from the policy", () => {
const policy = createPolicy({ defaultType: "passphrase" });
const result = preferPassword({ ...DisabledGeneratorNavigationPolicy }, policy);
expect(result).toEqual({ defaultType: "passphrase" });
});
it("should override passphrase with password", () => {
const policy = createPolicy({ defaultType: "password" });
const result = preferPassword({ defaultType: "passphrase" }, policy);
expect(result).toEqual({ defaultType: "password" });
});
it("should not override password", () => {
const policy = createPolicy({ defaultType: "passphrase" });
const result = preferPassword({ defaultType: "password" }, policy);
expect(result).toEqual({ defaultType: "password" });
});
});

View File

@@ -0,0 +1,39 @@
import { PolicyType } from "../../../admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy } from "../../../admin-console/models/domain/policy";
import { GeneratorType } from "../generator-type";
/** Policy settings affecting password generator navigation */
export type GeneratorNavigationPolicy = {
/** The type of generator that should be shown by default when opening
* the password generator.
*/
defaultType?: GeneratorType;
};
/** Reduces a policy into an accumulator by preferring the password generator
* type to other generator types.
* @param acc the accumulator
* @param policy the policy to reduce
* @returns the resulting `GeneratorNavigationPolicy`
*/
export function preferPassword(
acc: GeneratorNavigationPolicy,
policy: Policy,
): GeneratorNavigationPolicy {
const isEnabled = policy.type === PolicyType.PasswordGenerator && policy.enabled;
if (!isEnabled) {
return acc;
}
const isOverridable = acc.defaultType !== "password" && policy.data.defaultType;
const result = isOverridable ? { ...acc, defaultType: policy.data.defaultType } : acc;
return result;
}
/** The default options for password generation policy. */
export const DisabledGeneratorNavigationPolicy: GeneratorNavigationPolicy = Object.freeze({
defaultType: undefined,
});

View File

@@ -0,0 +1,26 @@
import { GeneratorType } from "../generator-type";
import { ForwarderId } from "../username/options";
import { UsernameGeneratorType } from "../username/options/generator-options";
/** Stores credential generator UI state. */
export type GeneratorNavigation = {
/** The kind of credential being generated.
* @remarks The legacy generator only supports "password" and "passphrase".
* The componentized generator supports all values.
*/
type?: GeneratorType;
/** When `type === "username"`, this stores the username algorithm. */
username?: UsernameGeneratorType;
/** When `username === "forwarded"`, this stores the forwarder implementation. */
forwarder?: ForwarderId | "";
};
/** The default options for password generation. */
export const DefaultGeneratorNavigation: Partial<GeneratorNavigation> = Object.freeze({
type: "password",
username: "word",
forwarder: "",
});

View File

@@ -0,0 +1,3 @@
export { GeneratorNavigationEvaluator } from "./generator-navigation-evaluator";
export { DefaultGeneratorNavigationService } from "./default-generator-navigation.service";
export { GeneratorNavigation, DefaultGeneratorNavigation } from "./generator-navigation";