diff --git a/libs/common/src/tools/dependencies.ts b/libs/common/src/tools/dependencies.ts index cdae45bc94a..7d246eac240 100644 --- a/libs/common/src/tools/dependencies.ts +++ b/libs/common/src/tools/dependencies.ts @@ -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; }; +/** 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; +}; + /** A pattern for types that emit values exclusively when the dependency * emits a message. * diff --git a/libs/common/src/tools/extension/extension.service.ts b/libs/common/src/tools/extension/extension.service.ts index 1c4363f85d9..a4f3988c98d 100644 --- a/libs/common/src/tools/extension/extension.service.ts +++ b/libs/common/src/tools/extension/extension.service.ts @@ -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, ) {} diff --git a/libs/common/src/tools/state/user-state-subject.ts b/libs/common/src/tools/state/user-state-subject.ts index d978d0211a4..54831f4c78c 100644 --- a/libs/common/src/tools/state/user-state-subject.ts +++ b/libs/common/src/tools/state/user-state-subject.ts @@ -96,6 +96,7 @@ export class UserStateSubject< */ constructor( private key: UserKeyDefinition | ObjectKey, + // FIXME: `getState` should initialize using a state provider getState: (key: UserKeyDefinition) => SingleUserState, private context: UserStateSubjectDependencies, ) { @@ -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]; diff --git a/libs/tools/generator/core/src/metadata/data.ts b/libs/tools/generator/core/src/metadata/data.ts index 1c8ae59fad1..b1bc2d4ecf4 100644 --- a/libs/tools/generator/core/src/metadata/data.ts +++ b/libs/tools/generator/core/src/metadata/data.ts @@ -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. */ diff --git a/libs/tools/generator/core/src/metadata/email/catchall.ts b/libs/tools/generator/core/src/metadata/email/catchall.ts index 4cec027e841..900a78546c6 100644 --- a/libs/tools/generator/core/src/metadata/email/catchall.ts +++ b/libs/tools/generator/core/src/metadata/email/catchall.ts @@ -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 = deepFreeze({ +const catchall: GeneratorMetadata = deepFreeze({ id: Algorithm.catchall, category: Type.email, i18nKeys: { @@ -36,9 +33,9 @@ const catchall: GeneratorMetadata = 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 = 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; diff --git a/libs/tools/generator/core/src/metadata/email/forwarder-integration.ts b/libs/tools/generator/core/src/metadata/email/forwarder-integration.ts index 4621aaf2bac..bda20af41e5 100644 --- a/libs/tools/generator/core/src/metadata/email/forwarder-integration.ts +++ b/libs/tools/generator/core/src/metadata/email/forwarder-integration.ts @@ -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( }, options: { constraints: configuration.forwarder.settingsConstraints, - [Purpose.account]: { + [Profile.account]: { storage: configuration.forwarder.local.settings, policy: { type: PolicyType.PasswordGenerator, diff --git a/libs/tools/generator/core/src/metadata/email/plus-address.ts b/libs/tools/generator/core/src/metadata/email/plus-address.ts index 0fcc735c069..f5020de57d9 100644 --- a/libs/tools/generator/core/src/metadata/email/plus-address.ts +++ b/libs/tools/generator/core/src/metadata/email/plus-address.ts @@ -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 = deepFreeze({ +const plusAddress: GeneratorMetadata = deepFreeze({ id: Algorithm.plusAddress, category: Type.email, i18nKeys: { @@ -36,9 +33,9 @@ const plusAddress: GeneratorMetadata = 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 = 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; diff --git a/libs/tools/generator/core/src/metadata/generator-metadata.ts b/libs/tools/generator/core/src/metadata/generator-metadata.ts index 1cc8aa42df9..9296d30430e 100644 --- a/libs/tools/generator/core/src/metadata/generator-metadata.ts +++ b/libs/tools/generator/core/src/metadata/generator-metadata.ts @@ -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 = AlgorithmMetadata & { +export type GeneratorMetadata = AlgorithmMetadata & { /** An algorithm that generates credentials when ran. */ engine: { /** Factory for the generator @@ -24,45 +20,10 @@ export type GeneratorMetadata = AlgorithmMetadata & { }; /** Defines parameters for credential generation */ - options: { - /** global constraints; these apply to *all* generators */ - constraints: Constraints; - - /** account-local generator options */ - [Purpose.account]: { - /** plaintext import buffer */ - import?: ObjectKey, Options> & { format: "plain" }; - - /** persistent storage location */ - storage: ObjectKey; - - /** 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; + [K in keyof typeof Profile]?: ProfileMetadata; }; }; diff --git a/libs/tools/generator/core/src/metadata/password/eff-word-list.ts b/libs/tools/generator/core/src/metadata/password/eff-word-list.ts index 38ad2bcea28..e4809989957 100644 --- a/libs/tools/generator/core/src/metadata/password/eff-word-list.ts +++ b/libs/tools/generator/core/src/metadata/password/eff-word-list.ts @@ -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 = { +const passphrase: GeneratorMetadata = { id: Algorithm.passphrase, category: Type.password, i18nKeys: { @@ -34,16 +33,9 @@ const passphrase: GeneratorMetadata, - 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; diff --git a/libs/tools/generator/core/src/metadata/password/random-password.ts b/libs/tools/generator/core/src/metadata/password/random-password.ts index bfb4bf60e2b..53c399c80c7 100644 --- a/libs/tools/generator/core/src/metadata/password/random-password.ts +++ b/libs/tools/generator/core/src/metadata/password/random-password.ts @@ -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 = deepFreeze({ +const password: GeneratorMetadata = deepFreeze({ id: Algorithm.password, category: Type.password, i18nKeys: { @@ -34,23 +33,9 @@ const password: GeneratorMetadata = { + /** The email address for the current user; + * `undefined` when no email is available. + */ + email?: string; + + /** Default application limits for the profile */ + defaultConstraints: Constraints; +}; + +type ProfileConstraints = { + /** 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; + + /** 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) => GeneratorConstraints; +}; + +/** 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 = { + /** distinguishes profile metadata types */ + type: "core"; + + /** plaintext import buffer */ + import?: ObjectKey, Options> & { format: "plain" }; + + /** persistent storage location */ + storage: ObjectKey; + + /** policy enforced when saving the options */ + constraints: ProfileConstraints; +}; + +/** 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 = { + /** distinguishes profile metadata types */ + type: "extension"; + + /** The extension site described by this metadata */ + site: Site; + + constraints: ProfileConstraints; +}; + +/** Generator profiles partition generator operations + * according to where they're used within the password + * manager + */ +export type ProfileMetadata = + | CoreProfileMetadata + | ExtensionProfileMetadata; diff --git a/libs/tools/generator/core/src/metadata/type.ts b/libs/tools/generator/core/src/metadata/type.ts index bc4fcfbe076..82177da6903 100644 --- a/libs/tools/generator/core/src/metadata/type.ts +++ b/libs/tools/generator/core/src/metadata/type.ts @@ -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]; diff --git a/libs/tools/generator/core/src/metadata/username/eff-word-list.ts b/libs/tools/generator/core/src/metadata/username/eff-word-list.ts index 631ac073eba..4aef57a7154 100644 --- a/libs/tools/generator/core/src/metadata/username/eff-word-list.ts +++ b/libs/tools/generator/core/src/metadata/username/eff-word-list.ts @@ -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 = deepFreeze({ +const effWordList: GeneratorMetadata = deepFreeze({ id: Algorithm.username, category: Type.username, i18nKeys: { @@ -35,9 +32,9 @@ const effWordList: GeneratorMetadata = 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 = d clearOn: ["logout"], }, }, - policy: { - type: PolicyType.PasswordGenerator, - disabledValue: {}, + constraints: { + default: {}, + create(_policies, _context) { + return new IdentityConstraint(); + }, }, }, }, - policy: { - combine(_acc: NoPolicy, _policy: Policy) { - return {}; - }, - toConstraints(_policy: NoPolicy) { - return new IdentityConstraint(); - }, - }, }); export default effWordList; diff --git a/libs/tools/generator/core/src/metadata/util.ts b/libs/tools/generator/core/src/metadata/util.ts index 9d18baa7406..3228ca15a2a 100644 --- a/libs/tools/generator/core/src/metadata/util.ts +++ b/libs/tools/generator/core/src/metadata/util.ts @@ -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( + value: ProfileMetadata, +): value is CoreProfileMetadata { + return value.type === "core"; +} + +/** Returns true when the input describes a forwarder extension profile. */ +export function isForwarderProfile( + value: ProfileMetadata, +): value is ExtensionProfileMetadata { + return value.type === "extension"; +} diff --git a/libs/tools/generator/core/src/services/credential-generator.service.spec.ts b/libs/tools/generator/core/src/services/credential-generator.service.spec.ts index 160da706937..e05302f0613 100644 --- a/libs/tools/generator/core/src/services/credential-generator.service.spec.ts +++ b/libs/tools/generator/core/src/services/credential-generator.service.spec.ts @@ -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[] = []; - 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[] = []; - 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(); - }); - }); }); diff --git a/libs/tools/generator/core/src/services/generator-profile-provider.spec.ts b/libs/tools/generator/core/src/services/generator-profile-provider.spec.ts new file mode 100644 index 00000000000..ac2b855b602 --- /dev/null +++ b/libs/tools/generator/core/src/services/generator-profile-provider.spec.ts @@ -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 = { + [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(); +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(); +const encryptorProvider = mock(); + +// settings storage location +const SettingsKey = new UserKeyDefinition(GENERATOR_DISK, "SomeSettings", { + deserializer: (value) => value, + clearOn: [], +}); + +// fake the configuration +const SomeProfile: CoreProfileMetadata = { + 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; + }, + } satisfies GeneratorConstraints; + } else { + return { + constraints: { + policyInEffect: false, + }, + adjust(state: SomeSettings) { + return state; + }, + fix(state: SomeSettings) { + return state; + }, + } satisfies GeneratorConstraints; + } + }), + }, +}; + +const NoPolicyProfile: CoreProfileMetadata = { + 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(null); + const singleAccount$ = singleAccount.asObservable(); + const profileProvider = new GeneratorProfileProvider( + stateProvider, + encryptorProvider, + policyService, + ); + + let result: UserStateSubject = 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 = { + 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 = { + 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[] = []; + 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(); + }); + }); +}); diff --git a/libs/tools/generator/core/src/services/generator-profile-provider.ts b/libs/tools/generator/core/src/services/generator-profile-provider.ts new file mode 100644 index 00000000000..1640dbdf245 --- /dev/null +++ b/libs/tools/generator/core/src/services/generator-profile-provider.ts @@ -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$$( + profile: Readonly>, + dependencies: SingleAccountDependency, + ): Observable> { + const singleUserId$ = dependencies.singleAccount$.pipe( + filter((account) => !!account), + map(({ id }) => id), + distinctUntilChanged(), + share({ + connector() { + return new ReplaySubject(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$( + profile: Readonly>, + dependencies: SingleAccountDependency, + ): Observable> { + 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([]); + const context: ProfileContext = { + 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$; + } +}