diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index 84d5b81ee7..58fb3f1825 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -144,6 +144,7 @@ export const SM_ONBOARDING_DISK = new StateDefinition("smOnboarding", "disk", { // Tools +export const EXTENSION_DISK = new StateDefinition("extension", "disk"); export const GENERATOR_DISK = new StateDefinition("generator", "disk"); export const GENERATOR_MEMORY = new StateDefinition("generator", "memory"); export const BROWSER_SEND_MEMORY = new StateDefinition("sendBrowser", "memory"); diff --git a/libs/common/src/tools/extension/extension.service.spec.ts b/libs/common/src/tools/extension/extension.service.spec.ts new file mode 100644 index 0000000000..dad5684b52 --- /dev/null +++ b/libs/common/src/tools/extension/extension.service.spec.ts @@ -0,0 +1,136 @@ +import { mock } from "jest-mock-extended"; +import { BehaviorSubject, firstValueFrom } from "rxjs"; + +import { FakeAccountService, FakeStateProvider, awaitAsync } from "../../../spec"; +import { Account } from "../../auth/abstractions/account.service"; +import { EXTENSION_DISK, UserKeyDefinition } from "../../platform/state"; +import { UserId } from "../../types/guid"; +import { LegacyEncryptorProvider } from "../cryptography/legacy-encryptor-provider"; +import { UserEncryptor } from "../cryptography/user-encryptor.abstraction"; +import { disabledSemanticLoggerProvider } from "../log"; +import { UserStateSubjectDependencyProvider } from "../state/user-state-subject-dependency-provider"; + +import { Site } from "./data"; +import { ExtensionRegistry } from "./extension-registry.abstraction"; +import { ExtensionSite } from "./extension-site"; +import { ExtensionService } from "./extension.service"; +import { ExtensionMetadata, ExtensionProfileMetadata, ExtensionStorageKey } from "./type"; +import { Vendor } from "./vendor/data"; +import { SimpleLogin } from "./vendor/simplelogin"; + +const SomeUser = "some user" as UserId; +const SomeAccount = { + id: SomeUser, + email: "someone@example.com", + emailVerified: true, + name: "Someone", +}; +const SomeAccount$ = new BehaviorSubject(SomeAccount); + +type TestType = { foo: string }; + +const SomeEncryptor: UserEncryptor = { + userId: SomeUser, + + encrypt(secret) { + const tmp: any = secret; + return Promise.resolve({ foo: `encrypt(${tmp.foo})` } as any); + }, + + decrypt(secret) { + const tmp: any = JSON.parse(secret.encryptedString!); + return Promise.resolve({ foo: `decrypt(${tmp.foo})` } as any); + }, +}; + +const SomeAccountService = new FakeAccountService({ + [SomeUser]: SomeAccount, +}); + +const SomeStateProvider = new FakeStateProvider(SomeAccountService); + +const SomeProvider = { + encryptor: { + userEncryptor$: () => { + return new BehaviorSubject({ encryptor: SomeEncryptor, userId: SomeUser }).asObservable(); + }, + organizationEncryptor$() { + throw new Error("`organizationEncryptor$` should never be invoked."); + }, + } as LegacyEncryptorProvider, + state: SomeStateProvider, + log: disabledSemanticLoggerProvider, +} as UserStateSubjectDependencyProvider; + +const SomeExtension: ExtensionMetadata = { + site: { id: "forwarder", availableFields: [] }, + product: { vendor: SimpleLogin }, + host: { + selfHost: "maybe", + baseUrl: "https://www.example.com/", + authentication: true, + }, + requestedFields: [], +}; + +const SomeRegistry = mock(); + +const SomeProfileMetadata = { + type: "extension", + site: Site.forwarder, + storage: { + key: "someProfile", + options: { + deserializer: (value) => value as TestType, + clearOn: [], + }, + } as ExtensionStorageKey, +} satisfies ExtensionProfileMetadata; + +describe("ExtensionService", () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe("settings", () => { + it("writes to the user's state", async () => { + const extension = new ExtensionService(SomeRegistry, SomeProvider); + SomeRegistry.extension.mockReturnValue(SomeExtension); + const subject = extension.settings(SomeProfileMetadata, Vendor.simplelogin, { + account$: SomeAccount$, + }); + + subject.next({ foo: "next value" }); + await awaitAsync(); + + // if the write succeeded, then the storage location should contain an object; + // the precise value isn't tested to avoid coupling the test to the storage format + const expectedKey = new UserKeyDefinition( + EXTENSION_DISK, + "forwarder.simplelogin.someProfile", + SomeProfileMetadata.storage.options, + ); + const result = await firstValueFrom(SomeStateProvider.getUserState$(expectedKey, SomeUser)); + expect(result).toBeTruthy(); + }); + + it("panics when the extension metadata isn't available", async () => { + const extension = new ExtensionService(SomeRegistry, SomeProvider); + expect(() => + extension.settings(SomeProfileMetadata, Vendor.bitwarden, { account$: SomeAccount$ }), + ).toThrow("extension not defined"); + }); + }); + + describe("site", () => { + it("returns an extension site", () => { + const expected = new ExtensionSite(SomeExtension.site, new Map()); + SomeRegistry.build.mockReturnValueOnce(expected); + const extension = new ExtensionService(SomeRegistry, SomeProvider); + + const site = extension.site(Site.forwarder); + + expect(site).toEqual(expected); + }); + }); +}); diff --git a/libs/common/src/tools/extension/extension.service.ts b/libs/common/src/tools/extension/extension.service.ts new file mode 100644 index 0000000000..c4e6887b82 --- /dev/null +++ b/libs/common/src/tools/extension/extension.service.ts @@ -0,0 +1,63 @@ +import { shareReplay } from "rxjs"; + +import { Account } from "../../auth/abstractions/account.service"; +import { BoundDependency } from "../dependencies"; +import { SemanticLogger } from "../log"; +import { UserStateSubject } from "../state/user-state-subject"; +import { UserStateSubjectDependencyProvider } from "../state/user-state-subject-dependency-provider"; + +import { ExtensionRegistry } from "./extension-registry.abstraction"; +import { ExtensionProfileMetadata, SiteId, VendorId } from "./type"; +import { toObjectKey } from "./util"; + +/** Provides configuration and storage support for Bitwarden client extensions. + * These extensions integrate 3rd party services into Bitwarden. + */ +export class ExtensionService { + /** Instantiate the extension service. + * @param registry provides runtime status for extension sites + * @param providers provide persistent data + */ + constructor( + private registry: ExtensionRegistry, + private readonly providers: UserStateSubjectDependencyProvider, + ) { + this.log = providers.log({ + type: "ExtensionService", + }); + } + + private log: SemanticLogger; + + /** Get a subject bound to a user's extension settings + * @param profile the site's extension profile + * @param vendor the vendor integrated at the extension site + * @param dependencies.account$ the account to which the settings are bound + * @returns a subject bound to the requested user's generator settings + */ + settings( + profile: ExtensionProfileMetadata, + vendor: VendorId, + dependencies: BoundDependency<"account", Account>, + ): UserStateSubject { + const metadata = this.registry.extension(profile.site, vendor); + if (!metadata) { + this.log.panic({ site: profile.site as string, vendor }, "extension not defined"); + } + + const key = toObjectKey(profile, metadata); + const account$ = dependencies.account$.pipe(shareReplay({ bufferSize: 1, refCount: true })); + // FIXME: load and apply constraints + const subject = new UserStateSubject(key, this.providers, { account$ }); + + return subject; + } + + /** Look up extension metadata for a site + * @param site defines the site to retrieve. + * @returns the extensions available at the site. + */ + site(site: SiteId) { + return this.registry.build(site); + } +} diff --git a/libs/common/src/tools/extension/type.ts b/libs/common/src/tools/extension/type.ts index f37d4ff8e5..26135e4142 100644 --- a/libs/common/src/tools/extension/type.ts +++ b/libs/common/src/tools/extension/type.ts @@ -1,5 +1,7 @@ import { Opaque } from "type-fest"; +import { ObjectKey } from "../state/object-key"; + import { Site, Field, Permission } from "./data"; /** well-known name for a feature extensible through an extension. */ @@ -17,6 +19,11 @@ export type ExtensionId = { site: SiteId; vendor: VendorId }; /** Permission levels for metadata. */ export type ExtensionPermission = keyof typeof Permission; +/** The preferred vendor to use at each site. */ +export type ExtensionPreferences = { + [key in SiteId]?: { vendor: VendorId; updated: Date }; +}; + /** The capabilities and descriptive content for an extension */ export type SiteMetadata = { /** Uniquely identifies the extension site. */ @@ -107,3 +114,29 @@ export type ExtensionSet = */ all: true; }; + +/** A key for storing JavaScript objects (`{ an: "example" }`) + * in the extension profile system. + * @remarks The omitted keys are filled by the extension service. + */ +export type ExtensionStorageKey = Omit< + ObjectKey, + "target" | "state" | "format" | "classifier" +>; + +/** Extension profiles encapsulate data storage using the extension system. + */ +export type ExtensionProfileMetadata = { + /** distinguishes profile metadata types */ + type: "extension"; + + /** The extension site described by this metadata */ + site: Site; + + /** persistent storage location; `storage.key` is used to construct + * the extension key in the format `${extension.site}.${extension.vendor}.${storage.key}`, + * where `extension.`-prefixed fields are read from extension metadata. Extension + * settings always use the "classified" format and keep all fields private. + */ + storage: ExtensionStorageKey; +}; diff --git a/libs/common/src/tools/extension/util.spec.ts b/libs/common/src/tools/extension/util.spec.ts new file mode 100644 index 0000000000..f6f3341a98 --- /dev/null +++ b/libs/common/src/tools/extension/util.spec.ts @@ -0,0 +1,54 @@ +import { EXTENSION_DISK } from "../../platform/state"; +import { PrivateClassifier } from "../private-classifier"; +import { deepFreeze } from "../util"; + +import { Site } from "./data"; +import { ExtensionMetadata, ExtensionProfileMetadata } from "./type"; +import { toObjectKey } from "./util"; +import { Bitwarden } from "./vendor/bitwarden"; + +const ExampleProfile: ExtensionProfileMetadata = deepFreeze({ + type: "extension", + site: "forwarder", + storage: { + key: "example", + options: { + clearOn: [], + deserializer: (value) => value as any, + }, + initial: {}, + frame: 1, + }, +}); + +const ExampleMetadata: ExtensionMetadata = { + site: { id: Site.forwarder, availableFields: [] }, + product: { vendor: Bitwarden }, + host: { authentication: true, selfHost: "maybe", baseUrl: "http://example.com" }, + requestedFields: [], +}; + +describe("toObjectKey", () => { + it("sets static fields", () => { + const result = toObjectKey(ExampleProfile, ExampleMetadata); + + expect(result.target).toEqual("object"); + expect(result.format).toEqual("classified"); + expect(result.state).toBe(EXTENSION_DISK); + expect(result.classifier).toBeInstanceOf(PrivateClassifier); + }); + + it("creates a dynamic object key", () => { + const result = toObjectKey(ExampleProfile, ExampleMetadata); + + expect(result.key).toEqual("forwarder.bitwarden.example"); + }); + + it("copies the profile storage metadata", () => { + const result = toObjectKey(ExampleProfile, ExampleMetadata); + + expect(result.frame).toEqual(ExampleProfile.storage.frame); + expect(result.options).toBe(ExampleProfile.storage.options); + expect(result.initial).toBe(ExampleProfile.storage.initial); + }); +}); diff --git a/libs/common/src/tools/extension/util.ts b/libs/common/src/tools/extension/util.ts new file mode 100644 index 0000000000..f700e84c49 --- /dev/null +++ b/libs/common/src/tools/extension/util.ts @@ -0,0 +1,36 @@ +import { EXTENSION_DISK } from "../../platform/state"; +import { PrivateClassifier } from "../private-classifier"; +import { Classifier } from "../state/classifier"; +import { ObjectKey } from "../state/object-key"; + +import { ExtensionMetadata, ExtensionProfileMetadata, SiteId } from "./type"; + +/** Create an object key from an extension instance and a site profile. + * @param profile the extension profile to bind + * @param extension the extension metadata to bind + */ +export function toObjectKey( + profile: ExtensionProfileMetadata, + extension: ExtensionMetadata, +) { + // FIXME: eliminate this cast + const classifier = new PrivateClassifier() as Classifier< + Settings, + Record, + Settings + >; + + const result: ObjectKey = { + // copy storage to retain extensibility + ...profile.storage, + + // fields controlled by the extension system override those in the profile + target: "object", + key: `${extension.site.id}.${extension.product.vendor.id}.${profile.storage.key}`, + state: EXTENSION_DISK, + classifier, + format: "classified", + }; + + return result; +} diff --git a/libs/common/src/tools/integration/integration-id.ts b/libs/common/src/tools/integration/integration-id.ts index a15db143ee..a3d83aba46 100644 --- a/libs/common/src/tools/integration/integration-id.ts +++ b/libs/common/src/tools/integration/integration-id.ts @@ -10,4 +10,4 @@ export const IntegrationIds = [ ] as const; /** Identifies a vendor integrated into bitwarden */ -export type IntegrationId = Opaque<(typeof IntegrationIds)[number], "IntegrationId">; +export type IntegrationId = Opaque; diff --git a/libs/common/src/tools/log/default-semantic-logger.spec.ts b/libs/common/src/tools/log/default-semantic-logger.spec.ts index 853056f1bb..7f608fb40e 100644 --- a/libs/common/src/tools/log/default-semantic-logger.spec.ts +++ b/libs/common/src/tools/log/default-semantic-logger.spec.ts @@ -14,33 +14,36 @@ describe("DefaultSemanticLogger", () => { describe("debug", () => { it("writes structural log messages to console.log", () => { - const log = new DefaultSemanticLogger(logger, {}); + const log = new DefaultSemanticLogger(logger, {}, () => 0); log.debug("this is a debug message"); expect(logger.write).toHaveBeenCalledWith(LogLevelType.Debug, { + "@timestamp": 0, message: "this is a debug message", level: "debug", }); }); it("writes structural content to console.log", () => { - const log = new DefaultSemanticLogger(logger, {}); + const log = new DefaultSemanticLogger(logger, {}, () => 0); log.debug({ example: "this is content" }); expect(logger.write).toHaveBeenCalledWith(LogLevelType.Debug, { + "@timestamp": 0, content: { example: "this is content" }, level: "debug", }); }); it("writes structural content to console.log with a message", () => { - const log = new DefaultSemanticLogger(logger, {}); + const log = new DefaultSemanticLogger(logger, {}, () => 0); log.info({ example: "this is content" }, "this is a message"); expect(logger.write).toHaveBeenCalledWith(LogLevelType.Info, { + "@timestamp": 0, content: { example: "this is content" }, message: "this is a message", level: "information", @@ -50,33 +53,36 @@ describe("DefaultSemanticLogger", () => { describe("info", () => { it("writes structural log messages to console.log", () => { - const log = new DefaultSemanticLogger(logger, {}); + const log = new DefaultSemanticLogger(logger, {}, () => 0); log.info("this is an info message"); expect(logger.write).toHaveBeenCalledWith(LogLevelType.Info, { + "@timestamp": 0, message: "this is an info message", level: "information", }); }); it("writes structural content to console.log", () => { - const log = new DefaultSemanticLogger(logger, {}); + const log = new DefaultSemanticLogger(logger, {}, () => 0); log.info({ example: "this is content" }); expect(logger.write).toHaveBeenCalledWith(LogLevelType.Info, { + "@timestamp": 0, content: { example: "this is content" }, level: "information", }); }); it("writes structural content to console.log with a message", () => { - const log = new DefaultSemanticLogger(logger, {}); + const log = new DefaultSemanticLogger(logger, {}, () => 0); log.info({ example: "this is content" }, "this is a message"); expect(logger.write).toHaveBeenCalledWith(LogLevelType.Info, { + "@timestamp": 0, content: { example: "this is content" }, message: "this is a message", level: "information", @@ -86,33 +92,36 @@ describe("DefaultSemanticLogger", () => { describe("warn", () => { it("writes structural log messages to console.warn", () => { - const log = new DefaultSemanticLogger(logger, {}); + const log = new DefaultSemanticLogger(logger, {}, () => 0); log.warn("this is a warning message"); expect(logger.write).toHaveBeenCalledWith(LogLevelType.Warning, { + "@timestamp": 0, message: "this is a warning message", level: "warning", }); }); it("writes structural content to console.warn", () => { - const log = new DefaultSemanticLogger(logger, {}); + const log = new DefaultSemanticLogger(logger, {}, () => 0); log.warn({ example: "this is content" }); expect(logger.write).toHaveBeenCalledWith(LogLevelType.Warning, { + "@timestamp": 0, content: { example: "this is content" }, level: "warning", }); }); it("writes structural content to console.warn with a message", () => { - const log = new DefaultSemanticLogger(logger, {}); + const log = new DefaultSemanticLogger(logger, {}, () => 0); log.warn({ example: "this is content" }, "this is a message"); expect(logger.write).toHaveBeenCalledWith(LogLevelType.Warning, { + "@timestamp": 0, content: { example: "this is content" }, message: "this is a message", level: "warning", @@ -122,33 +131,36 @@ describe("DefaultSemanticLogger", () => { describe("error", () => { it("writes structural log messages to console.error", () => { - const log = new DefaultSemanticLogger(logger, {}); + const log = new DefaultSemanticLogger(logger, {}, () => 0); log.error("this is an error message"); expect(logger.write).toHaveBeenCalledWith(LogLevelType.Error, { + "@timestamp": 0, message: "this is an error message", level: "error", }); }); it("writes structural content to console.error", () => { - const log = new DefaultSemanticLogger(logger, {}); + const log = new DefaultSemanticLogger(logger, {}, () => 0); log.error({ example: "this is content" }); expect(logger.write).toHaveBeenCalledWith(LogLevelType.Error, { + "@timestamp": 0, content: { example: "this is content" }, level: "error", }); }); it("writes structural content to console.error with a message", () => { - const log = new DefaultSemanticLogger(logger, {}); + const log = new DefaultSemanticLogger(logger, {}, () => 0); log.error({ example: "this is content" }, "this is a message"); expect(logger.write).toHaveBeenCalledWith(LogLevelType.Error, { + "@timestamp": 0, content: { example: "this is content" }, message: "this is a message", level: "error", @@ -158,24 +170,26 @@ describe("DefaultSemanticLogger", () => { describe("panic", () => { it("writes structural log messages to console.error before throwing the message", () => { - const log = new DefaultSemanticLogger(logger, {}); + const log = new DefaultSemanticLogger(logger, {}, () => 0); expect(() => log.panic("this is an error message")).toThrow("this is an error message"); expect(logger.write).toHaveBeenCalledWith(LogLevelType.Error, { + "@timestamp": 0, message: "this is an error message", level: "error", }); }); it("writes structural log messages to console.error with a message before throwing the message", () => { - const log = new DefaultSemanticLogger(logger, {}); + const log = new DefaultSemanticLogger(logger, {}, () => 0); expect(() => log.panic({ example: "this is content" }, "this is an error message")).toThrow( "this is an error message", ); expect(logger.write).toHaveBeenCalledWith(LogLevelType.Error, { + "@timestamp": 0, content: { example: "this is content" }, message: "this is an error message", level: "error", @@ -183,13 +197,14 @@ describe("DefaultSemanticLogger", () => { }); it("writes structural log messages to console.error with a content before throwing the message", () => { - const log = new DefaultSemanticLogger(logger, {}); + const log = new DefaultSemanticLogger(logger, {}, () => 0); expect(() => log.panic("this is content", "this is an error message")).toThrow( "this is an error message", ); expect(logger.write).toHaveBeenCalledWith(LogLevelType.Error, { + "@timestamp": 0, content: "this is content", message: "this is an error message", level: "error", diff --git a/libs/common/src/tools/log/default-semantic-logger.ts b/libs/common/src/tools/log/default-semantic-logger.ts index 90788e7031..eb1ecbe36c 100644 --- a/libs/common/src/tools/log/default-semantic-logger.ts +++ b/libs/common/src/tools/log/default-semantic-logger.ts @@ -18,6 +18,7 @@ export class DefaultSemanticLogger implements SemanticLo constructor( private logger: LogService, context: Jsonify, + private now = () => Date.now(), ) { this.context = context && typeof context === "object" ? context : {}; } @@ -53,6 +54,7 @@ export class DefaultSemanticLogger implements SemanticLo message, content: content ?? undefined, level: stringifyLevel(level), + "@timestamp": this.now(), }; if (typeof content === "string" && !message) { diff --git a/libs/common/src/tools/private-classifier.ts b/libs/common/src/tools/private-classifier.ts index 58244ae990..e2406d314c 100644 --- a/libs/common/src/tools/private-classifier.ts +++ b/libs/common/src/tools/private-classifier.ts @@ -17,7 +17,7 @@ export class PrivateClassifier implements Classifier; - return { disclosed: null, secret }; + return { disclosed: {}, secret }; } declassify(_disclosed: Jsonify>, secret: Jsonify) { diff --git a/libs/common/src/tools/providers.ts b/libs/common/src/tools/providers.ts new file mode 100644 index 0000000000..a22a22addc --- /dev/null +++ b/libs/common/src/tools/providers.ts @@ -0,0 +1,16 @@ +import { PolicyService } from "../admin-console/abstractions/policy/policy.service.abstraction"; + +import { ExtensionService } from "./extension/extension.service"; +import { LogProvider } from "./log"; + +/** Provides access to commonly-used cross-cutting services. */ +export type SystemServiceProvider = { + /** Policy configured by the administrative console */ + readonly policy: PolicyService; + + /** Client extension metadata and profile access */ + readonly extension: ExtensionService; + + /** Event monitoring and diagnostic interfaces */ + readonly log: LogProvider; +}; diff --git a/libs/common/src/tools/public-classifier.ts b/libs/common/src/tools/public-classifier.ts index e036ebd1c4..136bee555a 100644 --- a/libs/common/src/tools/public-classifier.ts +++ b/libs/common/src/tools/public-classifier.ts @@ -16,7 +16,7 @@ export class PublicClassifier implements Classifier; - return { disclosed, secret: null }; + return { disclosed, secret: "" }; } declassify(disclosed: Jsonify, _secret: Jsonify>) { diff --git a/libs/common/src/tools/state/classified-format.spec.ts b/libs/common/src/tools/state/classified-format.spec.ts new file mode 100644 index 0000000000..77d39ba4ca --- /dev/null +++ b/libs/common/src/tools/state/classified-format.spec.ts @@ -0,0 +1,27 @@ +import { isClassifiedFormat } from "./classified-format"; + +describe("isClassifiedFormat", () => { + it("returns `false` when the argument is `null`", () => { + expect(isClassifiedFormat(null)).toEqual(false); + }); + + it.each([ + [{ id: true, secret: "" }], + [{ secret: "", disclosed: {} }], + [{ id: true, disclosed: {} }], + ])("returns `false` when the argument is missing a required member (=%p).", (value) => { + expect(isClassifiedFormat(value)).toEqual(false); + }); + + it("returns `false` when 'secret' is not a string", () => { + expect(isClassifiedFormat({ id: true, secret: false, disclosed: {} })).toEqual(false); + }); + + it("returns `false` when 'disclosed' is not an object", () => { + expect(isClassifiedFormat({ id: true, secret: "", disclosed: false })).toEqual(false); + }); + + it("returns `true` when the argument has a `secret`, `disclosed`, and `id`.", () => { + expect(isClassifiedFormat({ id: true, secret: "", disclosed: {} })).toEqual(true); + }); +}); diff --git a/libs/common/src/tools/state/classified-format.ts b/libs/common/src/tools/state/classified-format.ts index 26aca0197c..ea738dad58 100644 --- a/libs/common/src/tools/state/classified-format.ts +++ b/libs/common/src/tools/state/classified-format.ts @@ -21,5 +21,12 @@ export type ClassifiedFormat = { export function isClassifiedFormat( value: any, ): value is ClassifiedFormat { - return "id" in value && "secret" in value && "disclosed" in value; + return ( + !!value && + "id" in value && + "secret" in value && + "disclosed" in value && + typeof value.secret === "string" && + typeof value.disclosed === "object" + ); } diff --git a/libs/common/src/tools/state/user-state-subject.ts b/libs/common/src/tools/state/user-state-subject.ts index b643b64215..dd88ec2fb2 100644 --- a/libs/common/src/tools/state/user-state-subject.ts +++ b/libs/common/src/tools/state/user-state-subject.ts @@ -523,6 +523,7 @@ export class UserStateSubject< private onError(value: any) { if (!this.isDisposed) { + this.log.debug(value, "forwarding error to subscribers"); this.output.error(value); } diff --git a/libs/tools/generator/core/src/metadata/index.ts b/libs/tools/generator/core/src/metadata/index.ts index 2aef138c0e..79806fd1bc 100644 --- a/libs/tools/generator/core/src/metadata/index.ts +++ b/libs/tools/generator/core/src/metadata/index.ts @@ -1,10 +1,13 @@ import { AlgorithmsByType as ABT } from "./data"; import { CredentialType, CredentialAlgorithm } from "./type"; +// `CredentialAlgorithm` is defined in terms of `ABT`; supplying +// type information in the barrel file breaks a circular dependency. +/** Credential generation algorithms grouped by purpose. */ export const AlgorithmsByType: Record> = ABT; export { Profile, Type } from "./data"; export { GeneratorMetadata } from "./generator-metadata"; export { ProfileContext, CoreProfileMetadata, ProfileMetadata } from "./profile-metadata"; export { GeneratorProfile, CredentialAlgorithm, CredentialType } from "./type"; -export { isForwarderProfile, isForwarderExtensionId } from "./util"; +export { isForwarderProfile, toVendorId, isForwarderExtensionId } from "./util"; diff --git a/libs/tools/generator/core/src/metadata/profile-metadata.ts b/libs/tools/generator/core/src/metadata/profile-metadata.ts index 4ac9139f63..7c25f40186 100644 --- a/libs/tools/generator/core/src/metadata/profile-metadata.ts +++ b/libs/tools/generator/core/src/metadata/profile-metadata.ts @@ -1,6 +1,6 @@ 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"; +import { ExtensionProfileMetadata } from "@bitwarden/common/tools/extension/type"; import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; import { Constraints } from "@bitwarden/common/tools/types"; @@ -61,13 +61,7 @@ export type CoreProfileMetadata = { * 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; - +export type ForwarderProfileMetadata = ExtensionProfileMetadata & { constraints: ProfileConstraints; }; @@ -77,4 +71,4 @@ export type ExtensionProfileMetadata = { */ export type ProfileMetadata = | CoreProfileMetadata - | ExtensionProfileMetadata; + | ForwarderProfileMetadata; diff --git a/libs/tools/generator/core/src/metadata/util.spec.ts b/libs/tools/generator/core/src/metadata/util.spec.ts index 2283699140..fc88c7a1f5 100644 --- a/libs/tools/generator/core/src/metadata/util.spec.ts +++ b/libs/tools/generator/core/src/metadata/util.spec.ts @@ -1,7 +1,12 @@ +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { GENERATOR_DISK } from "@bitwarden/common/platform/state"; import { VendorId } from "@bitwarden/common/tools/extension"; +import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier"; +import { IdentityConstraint } from "@bitwarden/common/tools/state/identity-state-constraint"; +import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; import { Algorithm, AlgorithmsByType } from "./data"; -import { ProfileMetadata } from "./profile-metadata"; +import { ProfileContext, ProfileMetadata } from "./profile-metadata"; import { isPasswordAlgorithm, isUsernameAlgorithm, @@ -12,6 +17,19 @@ import { isForwarderProfile, } from "./util"; +const SomeStorage: ObjectKey = { + target: "object", + key: "arbitrary", + state: GENERATOR_DISK, + classifier: new PrivateClassifier(), + format: "classified", + options: { clearOn: [], deserializer: (value) => value }, +}; + +function createConstraints(policies: Policy[], context: ProfileContext) { + return new IdentityConstraint(); +} + describe("credential generator metadata utility functions", () => { describe("isPasswordAlgorithm", () => { it("returns `true` when the algorithm is a password algorithm", () => { @@ -151,10 +169,10 @@ describe("credential generator metadata utility functions", () => { it("returns `true` when the profile's type is `core`", () => { const profile: ProfileMetadata = { type: "core", - storage: null, + storage: SomeStorage, constraints: { default: {}, - create: () => null, + create: createConstraints, }, }; @@ -165,9 +183,10 @@ describe("credential generator metadata utility functions", () => { const profile: ProfileMetadata = { type: "extension", site: "forwarder", + storage: SomeStorage, constraints: { default: {}, - create: () => null, + create: createConstraints, }, }; @@ -179,10 +198,10 @@ describe("credential generator metadata utility functions", () => { it("returns `false` when the profile's type is `core`", () => { const profile: ProfileMetadata = { type: "core", - storage: null, + storage: SomeStorage, constraints: { default: {}, - create: () => null, + create: createConstraints, }, }; @@ -193,9 +212,10 @@ describe("credential generator metadata utility functions", () => { const profile: ProfileMetadata = { type: "extension", site: "forwarder", + storage: SomeStorage, constraints: { default: {}, - create: () => null, + create: createConstraints, }, }; @@ -206,9 +226,10 @@ describe("credential generator metadata utility functions", () => { const profile: ProfileMetadata = { type: "extension", site: "not-a-forwarder" as any, + storage: SomeStorage, constraints: { default: {}, - create: () => null, + create: createConstraints, }, }; diff --git a/libs/tools/generator/core/src/metadata/util.ts b/libs/tools/generator/core/src/metadata/util.ts index e85061720a..86b2742e86 100644 --- a/libs/tools/generator/core/src/metadata/util.ts +++ b/libs/tools/generator/core/src/metadata/util.ts @@ -1,5 +1,7 @@ +import { VendorId } from "@bitwarden/common/tools/extension"; + import { AlgorithmsByType } from "./data"; -import { CoreProfileMetadata, ExtensionProfileMetadata, ProfileMetadata } from "./profile-metadata"; +import { CoreProfileMetadata, ForwarderProfileMetadata, ProfileMetadata } from "./profile-metadata"; import { CredentialAlgorithm, EmailAlgorithm, @@ -29,6 +31,17 @@ export function isForwarderExtensionId( return algorithm && typeof algorithm === "object" && "forwarder" in algorithm; } +/** Extract a `VendorId` from a `CredentialAlgorithm`. + * @param algorithm the algorithm containing the vendor id + * @returns the vendor id if the algorithm identifies a forwarder extension. + * Otherwise, undefined. + */ +export function toVendorId(algorithm: CredentialAlgorithm): VendorId | undefined { + if (isForwarderExtensionId(algorithm)) { + return algorithm.forwarder as VendorId; + } +} + /** Returns true when the input algorithm is an email algorithm. */ export function isEmailAlgorithm(algorithm: CredentialAlgorithm): algorithm is EmailAlgorithm { return AlgorithmsByType.email.includes(algorithm as any) || isForwarderExtensionId(algorithm); @@ -55,6 +68,6 @@ export function isCoreProfile( /** Returns true when the input describes a forwarder extension profile. */ export function isForwarderProfile( value: ProfileMetadata, -): value is ExtensionProfileMetadata { +): value is ForwarderProfileMetadata { return value.type === "extension" && value.site === "forwarder"; }