1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-11 05:53:42 +00:00

introduce generator profile provider

This commit is contained in:
✨ Audrey ✨
2025-01-06 15:34:37 -05:00
parent fd395a53c4
commit 3473ac7ef2
17 changed files with 761 additions and 374 deletions

View File

@@ -3,6 +3,8 @@ import { Observable } from "rxjs";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { Account } from "../auth/abstractions/account.service";
import { OrganizationEncryptor } from "./cryptography/organization-encryptor.abstraction";
import { UserEncryptor } from "./cryptography/user-encryptor.abstraction";
@@ -151,6 +153,25 @@ export type SingleUserDependency = {
singleUserId$: Observable<UserId>;
};
/** A pattern for types that depend upon a fixed account and return
* an observable.
*
* Consumers of this dependency should emit a `UserChangedError` if
* the value of `singleAccount$` changes. If `singleAccount$` completes,
* the consumer should also complete. If `singleAccount$` errors, the
* consumer should also emit the error.
*
* @remarks Check the consumer's documentation to determine how it
* responds to repeat emissions.
*/
export type SingleAccountDependency = {
/** A stream that emits an account when subscribed and the user's account
* is unlocked, and completes when the account is locked or logged out.
* The stream should not emit null or undefined.
*/
singleAccount$: Observable<Account>;
};
/** A pattern for types that emit values exclusively when the dependency
* emits a message.
*

View File

@@ -2,11 +2,11 @@ import { StateProvider } from "@bitwarden/common/platform/state";
import { LegacyEncryptorProvider } from "../cryptography/legacy-encryptor-provider";
import { RuntimeExtensionRegistry } from "./runtime-extension-registry";
import { ExtensionRegistry } from "./extension-registry.abstraction";
export class ExtensionService {
constructor(
private readonly registry: RuntimeExtensionRegistry,
private readonly registry: ExtensionRegistry,
private readonly stateProvider: StateProvider,
private readonly encryptorProvider: LegacyEncryptorProvider,
) {}

View File

@@ -96,6 +96,7 @@ export class UserStateSubject<
*/
constructor(
private key: UserKeyDefinition<State> | ObjectKey<State, Secret, Disclosed>,
// FIXME: `getState` should initialize using a state provider
getState: (key: UserKeyDefinition<unknown>) => SingleUserState<unknown>,
private context: UserStateSubjectDependencies<State, Dependencies>,
) {
@@ -222,7 +223,7 @@ export class UserStateSubject<
// `init$` becomes the accumulator for `scan`
init$.pipe(
first(),
map((init) => [init, null] as const),
map((init) => [init, null] as [State, Dependencies]),
),
input$.pipe(
map((constrained) => constrained.state),
@@ -235,7 +236,7 @@ export class UserStateSubject<
if (shouldUpdate) {
// actual update
const next = this.context.nextValue?.(prev, pending, dependencies) ?? pending;
return [next, dependencies];
return [next, dependencies] as const;
} else {
// false update
return [prev, null];

View File

@@ -29,13 +29,13 @@ export const Type = Object.freeze({
} as const);
/** categorizes settings according to their expected use-case within Bitwarden */
export const Purpose = Object.freeze({
export const Profile = Object.freeze({
/** account-level generator options. This is the default.
* @remarks these are the options displayed on the generator tab
*/
account: "account",
// FIXME: consider adding a purpose for bitwarden's master password
// FIXME: consider adding a profile for bitwarden's master password
});
/** Credential generation algorithms grouped by purpose. */

View File

@@ -1,5 +1,3 @@
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { GENERATOR_DISK } from "@bitwarden/common/platform/state";
import { PublicClassifier } from "@bitwarden/common/tools/public-classifier";
@@ -9,13 +7,12 @@ import {
CatchallGenerationOptions,
CredentialGenerator,
GeneratorDependencyProvider,
NoPolicy,
} from "../../types";
import { deepFreeze } from "../../util";
import { Algorithm, Type } from "../data";
import { Algorithm, Type, Profile } from "../data";
import { GeneratorMetadata } from "../generator-metadata";
const catchall: GeneratorMetadata<CatchallGenerationOptions, NoPolicy> = deepFreeze({
const catchall: GeneratorMetadata<CatchallGenerationOptions> = deepFreeze({
id: Algorithm.catchall,
category: Type.email,
i18nKeys: {
@@ -36,9 +33,9 @@ const catchall: GeneratorMetadata<CatchallGenerationOptions, NoPolicy> = deepFre
return new EmailRandomizer(dependencies.randomizer);
},
},
options: {
constraints: { catchallDomain: { minLength: 1 } },
account: {
profiles: {
[Profile.account]: {
type: "core",
storage: {
key: "catchallGeneratorSettings",
target: "object",
@@ -57,20 +54,14 @@ const catchall: GeneratorMetadata<CatchallGenerationOptions, NoPolicy> = deepFre
clearOn: ["logout"],
},
},
policy: {
type: PolicyType.PasswordGenerator,
disabledValue: {},
constraints: {
default: { catchallDomain: { minLength: 1 } },
create(_policies, context) {
return new CatchallConstraints(context.email);
},
},
},
},
policy: {
combine(_acc: NoPolicy, _policy: Policy) {
return {};
},
toConstraints(_policy: NoPolicy, email: string) {
return new CatchallConstraints(email);
},
},
});
export default catchall;

View File

@@ -7,7 +7,7 @@ import { ForwarderConfiguration } from "../../engine";
import { Forwarder } from "../../engine/forwarder";
import { GeneratorDependencyProvider, NoPolicy } from "../../types";
import { deepFreeze } from "../../util";
import { Purpose, Type } from "../data";
import { Profile, Type } from "../data";
import { GeneratorMetadata } from "../generator-metadata";
import { toForwarderIntegration } from "../util";
@@ -37,7 +37,7 @@ export function toGeneratorMetadata<Settings extends ApiSettings = ApiSettings>(
},
options: {
constraints: configuration.forwarder.settingsConstraints,
[Purpose.account]: {
[Profile.account]: {
storage: configuration.forwarder.local.settings,
policy: {
type: PolicyType.PasswordGenerator,

View File

@@ -1,5 +1,3 @@
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { GENERATOR_DISK } from "@bitwarden/common/platform/state";
import { PublicClassifier } from "@bitwarden/common/tools/public-classifier";
@@ -8,14 +6,13 @@ import { SubaddressConstraints } from "../../policies/subaddress-constraints";
import {
CredentialGenerator,
GeneratorDependencyProvider,
NoPolicy,
SubaddressGenerationOptions,
} from "../../types";
import { deepFreeze } from "../../util";
import { Algorithm, Purpose, Type } from "../data";
import { Algorithm, Profile, Type } from "../data";
import { GeneratorMetadata } from "../generator-metadata";
const plusAddress: GeneratorMetadata<SubaddressGenerationOptions, NoPolicy> = deepFreeze({
const plusAddress: GeneratorMetadata<SubaddressGenerationOptions> = deepFreeze({
id: Algorithm.plusAddress,
category: Type.email,
i18nKeys: {
@@ -36,9 +33,9 @@ const plusAddress: GeneratorMetadata<SubaddressGenerationOptions, NoPolicy> = de
return new EmailRandomizer(dependencies.randomizer);
},
},
options: {
constraints: {},
[Purpose.account]: {
profiles: {
[Profile.account]: {
type: "core",
storage: {
key: "subaddressGeneratorSettings",
target: "object",
@@ -59,21 +56,14 @@ const plusAddress: GeneratorMetadata<SubaddressGenerationOptions, NoPolicy> = de
clearOn: ["logout"],
},
},
policy: {
type: PolicyType.PasswordGenerator,
disabledValue: {},
constraints: {
default: {},
create(_policy, context) {
return new SubaddressConstraints(context.email);
},
},
},
},
policy: {
combine(_acc: NoPolicy, _policy: Policy) {
return {};
},
toConstraints(_policy: NoPolicy, email: string) {
return new SubaddressConstraints(email);
},
},
});
export default plusAddress;

View File

@@ -1,12 +1,8 @@
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Policy as AdminPolicy } from "@bitwarden/common/admin-console/models/domain/policy";
import { ObjectKey } from "@bitwarden/common/tools/state/object-key";
import { Constraints } from "@bitwarden/common/tools/types";
import { CredentialGenerator, GeneratorConstraints, GeneratorDependencyProvider } from "../types";
import { CredentialGenerator, GeneratorDependencyProvider } from "../types";
import { AlgorithmMetadata } from "./algorithm-metadata";
import { Purpose } from "./data";
import { Profile } from "./data";
import { ProfileMetadata } from "./profile-metadata";
/** Extends the algorithm metadata with storage and engine configurations.
* @example
@@ -15,7 +11,7 @@ import { Purpose } from "./data";
* const meta : CredentialGeneratorInfo = // ...
* const { forwarder } = isForwarderIntegration(meta.id) ? credentialId : {};
*/
export type GeneratorMetadata<Options, Policy> = AlgorithmMetadata & {
export type GeneratorMetadata<Options> = AlgorithmMetadata & {
/** An algorithm that generates credentials when ran. */
engine: {
/** Factory for the generator
@@ -24,45 +20,10 @@ export type GeneratorMetadata<Options, Policy> = AlgorithmMetadata & {
};
/** Defines parameters for credential generation */
options: {
/** global constraints; these apply to *all* generators */
constraints: Constraints<Options>;
/** account-local generator options */
[Purpose.account]: {
/** plaintext import buffer */
import?: ObjectKey<Options, Record<string, never>, Options> & { format: "plain" };
/** persistent storage location */
storage: ObjectKey<Options>;
/** policy enforced when saving the options */
policy: {
/** policy administration storage location for the policy */
type: PolicyType;
/** The value of the policy when it is not in effect. */
disabledValue: Policy;
};
};
};
/** Defines parameters for policy transformations */
policy: {
/** Combines multiple policies set by the administrative console into
* a single policy.
profiles: {
/** profiles supported by this generator; when `undefined`,
* the generator does not support the profile.
*/
combine: (acc: Policy, policy: AdminPolicy) => Policy;
/** Converts policy service data into actionable policy constraints.
*
* @param policy - the policy to map into policy constraints.
* @param email - the default email to extend.
*
* @remarks this version includes constraints needed for the reactive forms;
* it was introduced so that the constraints can be incrementally introduced
* as the new UI is built.
*/
toConstraints: (policy: Policy, email: string) => GeneratorConstraints<Options>;
[K in keyof typeof Profile]?: ProfileMetadata<Options>;
};
};

View File

@@ -9,12 +9,11 @@ import {
CredentialGenerator,
GeneratorDependencyProvider,
PassphraseGenerationOptions,
PassphraseGeneratorPolicy,
} from "../../types";
import { Algorithm, Purpose, Type } from "../data";
import { Algorithm, Profile, Type } from "../data";
import { GeneratorMetadata } from "../generator-metadata";
const passphrase: GeneratorMetadata<PassphraseGenerationOptions, PassphraseGeneratorPolicy> = {
const passphrase: GeneratorMetadata<PassphraseGenerationOptions> = {
id: Algorithm.passphrase,
category: Type.password,
i18nKeys: {
@@ -34,16 +33,9 @@ const passphrase: GeneratorMetadata<PassphraseGenerationOptions, PassphraseGener
return new PasswordRandomizer(dependencies.randomizer);
},
},
options: {
constraints: {
numWords: {
min: 3,
max: 20,
recommendation: 6,
},
wordSeparator: { maxLength: 1 },
},
[Purpose.account]: {
profiles: {
[Profile.account]: {
type: "core",
storage: {
key: "passphraseGeneratorSettings",
target: "object",
@@ -68,22 +60,29 @@ const passphrase: GeneratorMetadata<PassphraseGenerationOptions, PassphraseGener
clearOn: ["logout"],
},
} satisfies ObjectKey<PassphraseGenerationOptions>,
policy: {
constraints: {
type: PolicyType.PasswordGenerator,
disabledValue: {
minNumberWords: 0,
capitalize: false,
includeNumber: false,
default: {
wordSeparator: { maxLength: 1 },
numWords: {
min: 3,
max: 20,
recommendation: 6,
},
},
create(policies, context) {
const initial = {
minNumberWords: 0,
capitalize: false,
includeNumber: false,
};
const policy = policies.reduce(passphraseLeastPrivilege, initial);
const constraints = new PassphrasePolicyConstraints(policy, context.defaultConstraints);
return constraints;
},
},
},
},
policy: {
combine: passphraseLeastPrivilege,
toConstraints(policy) {
return new PassphrasePolicyConstraints(policy, passphrase.options.constraints);
},
},
};
export default passphrase;

View File

@@ -8,13 +8,12 @@ import {
CredentialGenerator,
GeneratorDependencyProvider,
PasswordGenerationOptions,
PasswordGeneratorPolicy,
} from "../../types";
import { deepFreeze } from "../../util";
import { Algorithm, Purpose, Type } from "../data";
import { Algorithm, Profile, Type } from "../data";
import { GeneratorMetadata } from "../generator-metadata";
const password: GeneratorMetadata<PasswordGenerationOptions, PasswordGeneratorPolicy> = deepFreeze({
const password: GeneratorMetadata<PasswordGenerationOptions> = deepFreeze({
id: Algorithm.password,
category: Type.password,
i18nKeys: {
@@ -34,23 +33,9 @@ const password: GeneratorMetadata<PasswordGenerationOptions, PasswordGeneratorPo
return new PasswordRandomizer(dependencies.randomizer);
},
},
options: {
constraints: {
length: {
min: 5,
max: 128,
recommendation: 14,
},
minNumber: {
min: 0,
max: 9,
},
minSpecial: {
min: 0,
max: 9,
},
},
[Purpose.account]: {
profiles: {
[Profile.account]: {
type: "core",
storage: {
key: "passwordGeneratorSettings",
target: "object",
@@ -87,26 +72,43 @@ const password: GeneratorMetadata<PasswordGenerationOptions, PasswordGeneratorPo
clearOn: ["logout"],
},
},
policy: {
constraints: {
type: PolicyType.PasswordGenerator,
disabledValue: {
minLength: 0,
useUppercase: false,
useLowercase: false,
useNumbers: false,
numberCount: 0,
useSpecial: false,
specialCount: 0,
default: {
length: {
min: 5,
max: 128,
recommendation: 14,
},
minNumber: {
min: 0,
max: 9,
},
minSpecial: {
min: 0,
max: 9,
},
},
create(policies, context) {
const initial = {
minLength: 0,
useUppercase: false,
useLowercase: false,
useNumbers: false,
numberCount: 0,
useSpecial: false,
specialCount: 0,
};
const policy = policies.reduce(passwordLeastPrivilege, initial);
const constraints = new DynamicPasswordPolicyConstraints(
policy,
context.defaultConstraints,
);
return constraints;
},
},
},
},
policy: {
combine: passwordLeastPrivilege,
toConstraints(policy) {
return new DynamicPasswordPolicyConstraints(policy, password.options.constraints);
},
},
});
export default password;

View File

@@ -0,0 +1,80 @@
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { SiteId } from "@bitwarden/common/tools/extension/type";
import { ObjectKey } from "@bitwarden/common/tools/state/object-key";
import { Constraints } from "@bitwarden/common/tools/types";
import { GeneratorConstraints } from "../types";
export type ProfileContext<Options> = {
/** The email address for the current user;
* `undefined` when no email is available.
*/
email?: string;
/** Default application limits for the profile */
defaultConstraints: Constraints<Options>;
};
type ProfileConstraints<Options> = {
/** The key used to locate this profile's policies in the admin console.
* When this type is undefined, no policy is defined for the profile.
*/
type?: PolicyType;
/** default application limits for this profile; these are overridden
* by the policy
*/
default: Constraints<Options>;
/** Constructs generator constraints from a policy.
* @param policies the administrative policy to apply to the provided constraints
* When `type` is undefined then `policy` is `undefined` this is an empty array.
* @param defaultConstraints application constraints; typically those defined in
* the `default` member, above.
* @returns the generator constraints to apply to this profile's options.
*/
create: (policies: Policy[], context: ProfileContext<Options>) => GeneratorConstraints<Options>;
};
/** Generator profiles partition generator operations
* according to where they're used within the password
* manager. Core profiles store their data using the
* generator's system storage.
*/
export type CoreProfileMetadata<Options> = {
/** distinguishes profile metadata types */
type: "core";
/** plaintext import buffer */
import?: ObjectKey<Options, Record<string, never>, Options> & { format: "plain" };
/** persistent storage location */
storage: ObjectKey<Options>;
/** policy enforced when saving the options */
constraints: ProfileConstraints<Options>;
};
/** Generator profiles partition generator operations
* according to where they're used within the password
* manager. Extension profiles store their data
* using the extension system.
*/
export type ExtensionProfileMetadata<Options, Site extends SiteId> = {
/** distinguishes profile metadata types */
type: "extension";
/** The extension site described by this metadata */
site: Site;
constraints: ProfileConstraints<Options>;
};
/** Generator profiles partition generator operations
* according to where they're used within the password
* manager
*/
export type ProfileMetadata<Options> =
| CoreProfileMetadata<Options>
| ExtensionProfileMetadata<Options, "forwarder">;

View File

@@ -1,12 +1,12 @@
import { IntegrationId } from "@bitwarden/common/tools/integration";
import { AlgorithmsByType, Purpose, Type } from "./data";
import { AlgorithmsByType, Profile, Type } from "./data";
/** categorizes credentials according to their use-case outside of Bitwarden */
export type CredentialType = keyof typeof Type;
/** categorizes credentials according to their expected use-case within Bitwarden */
export type CredentialPurpose = keyof typeof Purpose;
export type GeneratorProfile = keyof typeof Profile;
/** A type of password that may be generated by the credential generator. */
export type PasswordAlgorithm = (typeof AlgorithmsByType.password)[number];

View File

@@ -1,5 +1,3 @@
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { GENERATOR_DISK } from "@bitwarden/common/platform/state";
import { PublicClassifier } from "@bitwarden/common/tools/public-classifier";
import { IdentityConstraint } from "@bitwarden/common/tools/state/identity-state-constraint";
@@ -9,13 +7,12 @@ import {
CredentialGenerator,
EffUsernameGenerationOptions,
GeneratorDependencyProvider,
NoPolicy,
} from "../../types";
import { deepFreeze } from "../../util";
import { Algorithm, Purpose, Type } from "../data";
import { Algorithm, Profile, Type } from "../data";
import { GeneratorMetadata } from "../generator-metadata";
const effWordList: GeneratorMetadata<EffUsernameGenerationOptions, NoPolicy> = deepFreeze({
const effWordList: GeneratorMetadata<EffUsernameGenerationOptions> = deepFreeze({
id: Algorithm.username,
category: Type.username,
i18nKeys: {
@@ -35,9 +32,9 @@ const effWordList: GeneratorMetadata<EffUsernameGenerationOptions, NoPolicy> = d
return new UsernameRandomizer(dependencies.randomizer);
},
},
options: {
constraints: {},
[Purpose.account]: {
profiles: {
[Profile.account]: {
type: "core",
storage: {
key: "effUsernameGeneratorSettings",
target: "object",
@@ -57,20 +54,14 @@ const effWordList: GeneratorMetadata<EffUsernameGenerationOptions, NoPolicy> = d
clearOn: ["logout"],
},
},
policy: {
type: PolicyType.PasswordGenerator,
disabledValue: {},
constraints: {
default: {},
create(_policies, _context) {
return new IdentityConstraint<EffUsernameGenerationOptions>();
},
},
},
},
policy: {
combine(_acc: NoPolicy, _policy: Policy) {
return {};
},
toConstraints(_policy: NoPolicy) {
return new IdentityConstraint<EffUsernameGenerationOptions>();
},
},
});
export default effWordList;

View File

@@ -5,6 +5,7 @@ import {
} from "@bitwarden/common/tools/integration";
import { AlgorithmsByType } from "./data";
import { CoreProfileMetadata, ExtensionProfileMetadata, ProfileMetadata } from "./profile-metadata";
import {
CredentialAlgorithm,
EmailAlgorithm,
@@ -39,6 +40,7 @@ export function isEmailAlgorithm(algorithm: CredentialAlgorithm): algorithm is E
return AlgorithmsByType.email.includes(algorithm as any) || isForwarderIntegration(algorithm);
}
/** Returns true when the algorithms are the same. */
export function isSameAlgorithm(lhs: CredentialAlgorithm, rhs: CredentialAlgorithm) {
if (lhs === rhs) {
return true;
@@ -49,6 +51,7 @@ export function isSameAlgorithm(lhs: CredentialAlgorithm, rhs: CredentialAlgorit
}
}
/** @deprecated this shouldn't be used; if you see this remove it immediately */
export function toForwarderIntegration(value: IntegrationMetadata): ForwarderIntegration;
export function toForwarderIntegration(value: IntegrationId): ForwarderIntegration;
export function toForwarderIntegration(
@@ -73,3 +76,17 @@ export function toForwarderIntegration(
throw new Error("Invalid `value` received.");
}
}
/** Returns true when the input describes a core profile. */
export function isCoreProfile<Options>(
value: ProfileMetadata<Options>,
): value is CoreProfileMetadata<Options> {
return value.type === "core";
}
/** Returns true when the input describes a forwarder extension profile. */
export function isForwarderProfile<Options>(
value: ProfileMetadata<Options>,
): value is ExtensionProfileMetadata<Options, "forwarder"> {
return value.type === "extension";
}

View File

@@ -1104,204 +1104,4 @@ describe("CredentialGeneratorService", () => {
expect(count).toEqual(1);
});
});
describe("settings", () => {
it("writes to the user's state", async () => {
const singleUserId$ = new BehaviorSubject(SomeUser).asObservable();
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
);
const subject = await generator.settings(SomeConfiguration, { singleUserId$ });
subject.next({ foo: "next value" });
await awaitAsync();
const result = await firstValueFrom(stateProvider.getUserState$(SettingsKey, SomeUser));
expect(result).toEqual({
foo: "next value",
// FIXME: don't leak this detail into the test
"$^$ALWAYS_UPDATE_KLUDGE_PROPERTY$^$": 0,
});
});
it("waits for the user to become available", async () => {
const singleUserId = new BehaviorSubject(null);
const singleUserId$ = singleUserId.asObservable();
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
);
let completed = false;
const promise = generator.settings(SomeConfiguration, { singleUserId$ }).then((settings) => {
completed = true;
return settings;
});
await awaitAsync();
expect(completed).toBeFalsy();
singleUserId.next(SomeUser);
const result = await promise;
expect(result.userId).toEqual(SomeUser);
});
});
describe("policy$", () => {
it("creates constraints without policy in effect when there is no policy", async () => {
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
);
const userId$ = new BehaviorSubject(SomeUser).asObservable();
const result = await firstValueFrom(generator.policy$(SomeConfiguration, { userId$ }));
expect(result.constraints.policyInEffect).toBeFalsy();
});
it("creates constraints with policy in effect when there is a policy", async () => {
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
);
const userId$ = new BehaviorSubject(SomeUser).asObservable();
const policy$ = new BehaviorSubject([somePolicy]);
policyService.getAll$.mockReturnValue(policy$);
const result = await firstValueFrom(generator.policy$(SomeConfiguration, { userId$ }));
expect(result.constraints.policyInEffect).toBeTruthy();
});
it("follows policy emissions", async () => {
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
const somePolicySubject = new BehaviorSubject([somePolicy]);
policyService.getAll$.mockReturnValueOnce(somePolicySubject.asObservable());
const emissions: GeneratorConstraints<SomeSettings>[] = [];
const sub = generator
.policy$(SomeConfiguration, { userId$ })
.subscribe((policy) => emissions.push(policy));
// swap the active policy for an inactive policy
somePolicySubject.next([]);
await awaitAsync();
sub.unsubscribe();
const [someResult, anotherResult] = emissions;
expect(someResult.constraints.policyInEffect).toBeTruthy();
expect(anotherResult.constraints.policyInEffect).toBeFalsy();
});
it("follows user emissions", async () => {
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
const somePolicy$ = new BehaviorSubject([somePolicy]).asObservable();
const anotherPolicy$ = new BehaviorSubject([]).asObservable();
policyService.getAll$.mockReturnValueOnce(somePolicy$).mockReturnValueOnce(anotherPolicy$);
const emissions: GeneratorConstraints<SomeSettings>[] = [];
const sub = generator
.policy$(SomeConfiguration, { userId$ })
.subscribe((policy) => emissions.push(policy));
// swapping the user invokes the return for `anotherPolicy$`
userId.next(AnotherUser);
await awaitAsync();
sub.unsubscribe();
const [someResult, anotherResult] = emissions;
expect(someResult.constraints.policyInEffect).toBeTruthy();
expect(anotherResult.constraints.policyInEffect).toBeFalsy();
});
it("errors when the user errors", async () => {
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
const expectedError = { some: "error" };
let actualError: any = null;
generator.policy$(SomeConfiguration, { userId$ }).subscribe({
error: (e: unknown) => {
actualError = e;
},
});
userId.error(expectedError);
await awaitAsync();
expect(actualError).toEqual(expectedError);
});
it("completes when the user completes", async () => {
const generator = new CredentialGeneratorService(
randomizer,
stateProvider,
policyService,
apiService,
i18nService,
encryptorProvider,
accountService,
);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
let completed = false;
generator.policy$(SomeConfiguration, { userId$ }).subscribe({
complete: () => {
completed = true;
},
});
userId.complete();
await awaitAsync();
expect(completed).toBeTruthy();
});
});
});

View File

@@ -0,0 +1,402 @@
import { mock } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
import { LegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/legacy-encryptor-provider";
import { UserEncryptor } from "@bitwarden/common/tools/cryptography/user-encryptor.abstraction";
import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier";
import { IdentityConstraint } from "@bitwarden/common/tools/state/identity-state-constraint";
import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject";
import { StateConstraints } from "@bitwarden/common/tools/types";
import { OrganizationId, PolicyId, UserId } from "@bitwarden/common/types/guid";
import { FakeStateProvider, FakeAccountService, awaitAsync } from "../../../../../common/spec";
import { CoreProfileMetadata, ProfileContext } from "../metadata/profile-metadata";
import { GeneratorConstraints } from "../types";
import { GeneratorProfileProvider } from "./generator-profile-provider";
// arbitrary settings types
type SomeSettings = { foo: string };
// fake user information
const SomeUser = "SomeUser" as UserId;
const AnotherUser = "SomeOtherUser" as UserId;
const UnverifiedEmailUser = "UnverifiedEmailUser" as UserId;
const accounts: Record<UserId, Account> = {
[SomeUser]: {
id: SomeUser,
name: "some user",
email: "some.user@example.com",
emailVerified: true,
},
[AnotherUser]: {
id: AnotherUser,
name: "some other user",
email: "some.other.user@example.com",
emailVerified: true,
},
[UnverifiedEmailUser]: {
id: UnverifiedEmailUser,
name: "a user with an unverfied email",
email: "unverified@example.com",
emailVerified: false,
},
};
const accountService = new FakeAccountService(accounts);
const policyService = mock<PolicyService>();
const somePolicy = new Policy({
data: { fooPolicy: true },
type: PolicyType.PasswordGenerator,
id: "" as PolicyId,
organizationId: "" as OrganizationId,
enabled: true,
});
const stateProvider = new FakeStateProvider(accountService);
const encryptor = mock<UserEncryptor>();
const encryptorProvider = mock<LegacyEncryptorProvider>();
// settings storage location
const SettingsKey = new UserKeyDefinition<SomeSettings>(GENERATOR_DISK, "SomeSettings", {
deserializer: (value) => value,
clearOn: [],
});
// fake the configuration
const SomeProfile: CoreProfileMetadata<SomeSettings> = {
type: "core",
storage: {
target: "object",
key: "SomeSettings",
state: GENERATOR_DISK,
classifier: new PrivateClassifier(),
format: "plain",
options: {
deserializer: (value) => value,
clearOn: [],
},
initial: { foo: "initial" },
},
constraints: {
type: PolicyType.PasswordGenerator,
default: { foo: {} },
create: jest.fn((policies, context) => {
const combined = policies.reduce(
(acc, policy) => ({ fooPolicy: acc.fooPolicy || policy.data.fooPolicy }),
{ fooPolicy: false },
);
if (combined.fooPolicy) {
return {
constraints: {
policyInEffect: true,
},
calibrate(state: SomeSettings) {
return {
constraints: {},
adjust(state: SomeSettings) {
return { foo: `adjusted(${state.foo})` };
},
fix(state: SomeSettings) {
return { foo: `fixed(${state.foo})` };
},
} satisfies StateConstraints<SomeSettings>;
},
} satisfies GeneratorConstraints<SomeSettings>;
} else {
return {
constraints: {
policyInEffect: false,
},
adjust(state: SomeSettings) {
return state;
},
fix(state: SomeSettings) {
return state;
},
} satisfies GeneratorConstraints<SomeSettings>;
}
}),
},
};
const NoPolicyProfile: CoreProfileMetadata<SomeSettings> = {
type: "core",
storage: {
target: "object",
key: "SomeSettings",
state: GENERATOR_DISK,
classifier: new PrivateClassifier(),
format: "classified",
options: {
deserializer: (value) => value,
clearOn: [],
},
initial: { foo: "initial" },
},
constraints: {
default: { foo: {} },
create: jest.fn((policies, context) => new IdentityConstraint()),
},
};
describe("GeneratorProfileProvider", () => {
beforeEach(async () => {
policyService.getAll$.mockImplementation(() => new BehaviorSubject([]).asObservable());
const encryptor$ = new BehaviorSubject({ userId: SomeUser, encryptor });
encryptorProvider.userEncryptor$.mockReturnValue(encryptor$);
jest.clearAllMocks();
});
describe("settings$$", () => {
it("writes to the user's state", async () => {
const singleAccount$ = new BehaviorSubject(accounts[SomeUser]).asObservable();
const profileProvider = new GeneratorProfileProvider(
stateProvider,
encryptorProvider,
policyService,
);
const settings = await firstValueFrom(
profileProvider.settings$$(SomeProfile, { singleAccount$ }),
);
settings.next({ foo: "next value" });
await awaitAsync();
const result = await firstValueFrom(stateProvider.getUserState$(SettingsKey, SomeUser));
expect(result).toEqual({
foo: "next value",
// FIXME: don't leak this detail into the test
"$^$ALWAYS_UPDATE_KLUDGE_PROPERTY$^$": 0,
});
});
it("waits for the user to become available", async () => {
const singleAccount = new BehaviorSubject<Account>(null);
const singleAccount$ = singleAccount.asObservable();
const profileProvider = new GeneratorProfileProvider(
stateProvider,
encryptorProvider,
policyService,
);
let result: UserStateSubject<SomeSettings> = undefined;
profileProvider.settings$$(SomeProfile, { singleAccount$ }).subscribe({
next(settings) {
result = settings;
},
});
await awaitAsync();
expect(result).toBeUndefined();
singleAccount.next(accounts[SomeUser]);
await awaitAsync();
expect(result.userId).toEqual(SomeUser);
});
});
describe("constraints$", () => {
it("creates constraints without policy in effect when there is no policy", async () => {
const profileProvider = new GeneratorProfileProvider(
stateProvider,
encryptorProvider,
policyService,
);
const singleAccount$ = new BehaviorSubject(accounts[SomeUser]).asObservable();
const result = await firstValueFrom(
profileProvider.constraints$(SomeProfile, { singleAccount$ }),
);
expect(result.constraints.policyInEffect).toBeFalsy();
});
it("creates constraints with policy in effect when there is a policy", async () => {
const profileProvider = new GeneratorProfileProvider(
stateProvider,
encryptorProvider,
policyService,
);
const singleAccount$ = new BehaviorSubject(accounts[SomeUser]).asObservable();
const policy$ = new BehaviorSubject([somePolicy]);
policyService.getAll$.mockReturnValue(policy$);
const result = await firstValueFrom(
profileProvider.constraints$(SomeProfile, { singleAccount$ }),
);
expect(result.constraints.policyInEffect).toBeTruthy();
});
it("sends the policy list to profile.constraint.create(...) when a type is specified", async () => {
const profileProvider = new GeneratorProfileProvider(
stateProvider,
encryptorProvider,
policyService,
);
const singleAccount$ = new BehaviorSubject(accounts[SomeUser]).asObservable();
const expectedPolicy = [somePolicy];
const policy$ = new BehaviorSubject(expectedPolicy);
policyService.getAll$.mockReturnValue(policy$);
await firstValueFrom(profileProvider.constraints$(SomeProfile, { singleAccount$ }));
expect(SomeProfile.constraints.create).toHaveBeenCalledWith(
expectedPolicy,
expect.any(Object),
);
});
it("sends an empty policy list to profile.constraint.create(...) when a type is omitted", async () => {
const profileProvider = new GeneratorProfileProvider(
stateProvider,
encryptorProvider,
policyService,
);
const singleAccount$ = new BehaviorSubject(accounts[SomeUser]).asObservable();
await firstValueFrom(profileProvider.constraints$(NoPolicyProfile, { singleAccount$ }));
expect(NoPolicyProfile.constraints.create).toHaveBeenCalledWith([], expect.any(Object));
});
it("sends the context to profile.constraint.create(...)", async () => {
const profileProvider = new GeneratorProfileProvider(
stateProvider,
encryptorProvider,
policyService,
);
const singleAccount$ = new BehaviorSubject(accounts[SomeUser]).asObservable();
const expectedContext: ProfileContext<SomeSettings> = {
defaultConstraints: NoPolicyProfile.constraints.default,
email: accounts[SomeUser].email,
};
await firstValueFrom(profileProvider.constraints$(NoPolicyProfile, { singleAccount$ }));
expect(NoPolicyProfile.constraints.create).toHaveBeenCalledWith(
expect.any(Array),
expectedContext,
);
});
it("omits nonverified emails from the context sent to profile.constraint.create(...)", async () => {
const profileProvider = new GeneratorProfileProvider(
stateProvider,
encryptorProvider,
policyService,
);
const singleAccount$ = new BehaviorSubject(accounts[UnverifiedEmailUser]).asObservable();
const expectedContext: ProfileContext<SomeSettings> = {
defaultConstraints: NoPolicyProfile.constraints.default,
};
await firstValueFrom(profileProvider.constraints$(NoPolicyProfile, { singleAccount$ }));
expect(NoPolicyProfile.constraints.create).toHaveBeenCalledWith(
expect.any(Array),
expectedContext,
);
});
// FIXME: implement this test case once the fake account service mock supports email verification
it.todo("invokes profile.constraint.create(...) when the user's email address is verified");
// FIXME: implement this test case once the fake account service mock supports email updates
it.todo("invokes profile.constraint.create(...) when the user's email address changes");
it("follows policy emissions", async () => {
const profileProvider = new GeneratorProfileProvider(
stateProvider,
encryptorProvider,
policyService,
);
const account = new BehaviorSubject(accounts[SomeUser]);
const singleAccount$ = account.asObservable();
const somePolicySubject = new BehaviorSubject([somePolicy]);
policyService.getAll$.mockReturnValueOnce(somePolicySubject.asObservable());
const emissions: GeneratorConstraints<SomeSettings>[] = [];
const sub = profileProvider
.constraints$(SomeProfile, { singleAccount$ })
.subscribe((policy) => emissions.push(policy));
// swap the active policy for an inactive policy
somePolicySubject.next([]);
await awaitAsync();
sub.unsubscribe();
const [someResult, anotherResult] = emissions;
expect(someResult.constraints.policyInEffect).toBeTruthy();
expect(anotherResult.constraints.policyInEffect).toBeFalsy();
});
it("errors when the user changes", async () => {
const profileProvider = new GeneratorProfileProvider(
stateProvider,
encryptorProvider,
policyService,
);
const account = new BehaviorSubject(accounts[SomeUser]);
const singleAccount$ = account.asObservable();
let error: any = null;
profileProvider
.constraints$(SomeProfile, { singleAccount$ })
.subscribe({ error: (e: unknown) => (error = e) });
// swapping the user triggers invalid user check
account.next(accounts[AnotherUser]);
await awaitAsync();
expect(error).toEqual({ expectedUserId: SomeUser, actualUserId: AnotherUser });
});
it("errors when the user errors", async () => {
const profileProvider = new GeneratorProfileProvider(
stateProvider,
encryptorProvider,
policyService,
);
const account = new BehaviorSubject(accounts[SomeUser]);
const singleAccount$ = account.asObservable();
const expectedError = { some: "error" };
let actualError: any = null;
profileProvider.constraints$(SomeProfile, { singleAccount$ }).subscribe({
error: (e: unknown) => {
actualError = e;
},
});
account.error(expectedError);
await awaitAsync();
expect(actualError).toEqual(expectedError);
});
it("completes when the user completes", async () => {
const profileProvider = new GeneratorProfileProvider(
stateProvider,
encryptorProvider,
policyService,
);
const account = new BehaviorSubject(accounts[SomeUser]);
const singleAccount$ = account.asObservable();
let completed = false;
profileProvider.constraints$(SomeProfile, { singleAccount$ }).subscribe({
complete: () => {
completed = true;
},
});
account.complete();
await awaitAsync();
expect(completed).toBeTruthy();
});
});
});

View File

@@ -0,0 +1,132 @@
import {
of,
distinctUntilChanged,
filter,
first,
map,
Observable,
ReplaySubject,
share,
switchMap,
takeUntil,
connect,
} from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { StateProvider } from "@bitwarden/common/platform/state";
import { LegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/legacy-encryptor-provider";
import { SingleAccountDependency } from "@bitwarden/common/tools/dependencies";
import { anyComplete, errorOnChange } from "@bitwarden/common/tools/rx";
import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject";
import { UserId } from "@bitwarden/common/types/guid";
import { ProfileContext, ProfileMetadata, CoreProfileMetadata } from "../metadata/profile-metadata";
import { GeneratorConstraints } from "../types/generator-constraints";
const OPTIONS_FRAME_SIZE = 512;
/** Surfaces contextual information to credential generators */
export class GeneratorProfileProvider {
/** Instantiates the context provider
* @param stateProvider stores the settings
* @param encryptorProvider protects the user's settings
* @param policyService settings constraint lookups
* @param accountService user email address lookups
*/
constructor(
private readonly stateProvider: StateProvider,
private readonly encryptorProvider: LegacyEncryptorProvider,
private readonly policyService: PolicyService,
) {}
/** Get a subject bound to a specific user's settings for the provided profile.
* @param profile determines which profile's settings are loaded
* @param dependencies.singleUserId$ identifies the user to which the settings are bound
* @returns an observable that emits the subject once `dependencies.singleUserId$` becomes
* available and then completes.
* @remarks the subject tracks and enforces policy on the settings it contains.
* It completes when `dependencies.singleUserId$` competes or the user's encryption key
* becomes unavailable.
*/
settings$$<Settings extends object>(
profile: Readonly<CoreProfileMetadata<Settings>>,
dependencies: SingleAccountDependency,
): Observable<UserStateSubject<Settings>> {
const singleUserId$ = dependencies.singleAccount$.pipe(
filter((account) => !!account),
map(({ id }) => id),
distinctUntilChanged(),
share({
connector() {
return new ReplaySubject<UserId>(1);
},
}),
);
const singleUserEncryptor$ = this.encryptorProvider.userEncryptor$(OPTIONS_FRAME_SIZE, {
singleUserId$,
});
const constraints$ = this.constraints$(profile, dependencies);
const settings$ = singleUserId$.pipe(
map((userId) => {
const subject = new UserStateSubject(
profile.storage,
(key) => this.stateProvider.getUser(userId, key),
{ constraints$, singleUserEncryptor$ },
);
return subject;
}),
first(),
);
return settings$;
}
/** Get the policy constraints for the provided profile
* @param dependencies.singleAccount$ constraints are loaded from this account.
* If the account's email is verified, it is passed to the constraints
* @returns an observable that emits the policy once `dependencies.userId$`
* and the policy become available.
*/
constraints$<Settings>(
profile: Readonly<ProfileMetadata<Settings>>,
dependencies: SingleAccountDependency,
): Observable<GeneratorConstraints<Settings>> {
const constraints$ = dependencies.singleAccount$.pipe(
errorOnChange(
({ id }) => id,
(expectedUserId, actualUserId) => ({ expectedUserId, actualUserId }),
),
distinctUntilChanged((prev, next) => {
return prev.email === next.email && prev.emailVerified === next.emailVerified;
}),
connect((account$) =>
account$.pipe(
switchMap((account) => {
const policies$ = profile.constraints.type
? this.policyService.getAll$(profile.constraints.type, account.id)
: of<Policy[]>([]);
const context: ProfileContext<Settings> = {
defaultConstraints: profile.constraints.default,
};
if (account.emailVerified) {
context.email = account.email;
}
const constraints$ = policies$.pipe(
map((policies) => profile.constraints.create(policies, context)),
);
return constraints$;
}),
// complete policy emissions otherwise `switchMap` holds `constraints$`
// open indefinitely
takeUntil(anyComplete(account$)),
),
),
);
return constraints$;
}
}