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:
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
80
libs/tools/generator/core/src/metadata/profile-metadata.ts
Normal file
80
libs/tools/generator/core/src/metadata/profile-metadata.ts
Normal 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">;
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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$;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user