mirror of
https://github.com/bitwarden/browser.git
synced 2025-03-12 13:39:14 +01:00
[PM-16790] introduce extension service (#13590)
This commit is contained in:
parent
6f4a1ea37f
commit
9761588a2a
@ -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");
|
||||
|
136
libs/common/src/tools/extension/extension.service.spec.ts
Normal file
136
libs/common/src/tools/extension/extension.service.spec.ts
Normal file
@ -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<Account>(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<ExtensionRegistry>();
|
||||
|
||||
const SomeProfileMetadata = {
|
||||
type: "extension",
|
||||
site: Site.forwarder,
|
||||
storage: {
|
||||
key: "someProfile",
|
||||
options: {
|
||||
deserializer: (value) => value as TestType,
|
||||
clearOn: [],
|
||||
},
|
||||
} as ExtensionStorageKey<TestType>,
|
||||
} satisfies ExtensionProfileMetadata<TestType, "forwarder">;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
63
libs/common/src/tools/extension/extension.service.ts
Normal file
63
libs/common/src/tools/extension/extension.service.ts
Normal file
@ -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<Settings extends object, Site extends SiteId>(
|
||||
profile: ExtensionProfileMetadata<Settings, Site>,
|
||||
vendor: VendorId,
|
||||
dependencies: BoundDependency<"account", Account>,
|
||||
): UserStateSubject<Settings> {
|
||||
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);
|
||||
}
|
||||
}
|
@ -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<Options> = Omit<
|
||||
ObjectKey<Options>,
|
||||
"target" | "state" | "format" | "classifier"
|
||||
>;
|
||||
|
||||
/** Extension profiles encapsulate data storage 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;
|
||||
|
||||
/** 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<Options>;
|
||||
};
|
||||
|
54
libs/common/src/tools/extension/util.spec.ts
Normal file
54
libs/common/src/tools/extension/util.spec.ts
Normal file
@ -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<object, "forwarder"> = 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);
|
||||
});
|
||||
});
|
36
libs/common/src/tools/extension/util.ts
Normal file
36
libs/common/src/tools/extension/util.ts
Normal file
@ -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<Settings extends object, Site extends SiteId>(
|
||||
profile: ExtensionProfileMetadata<Settings, Site>,
|
||||
extension: ExtensionMetadata,
|
||||
) {
|
||||
// FIXME: eliminate this cast
|
||||
const classifier = new PrivateClassifier<Settings>() as Classifier<
|
||||
Settings,
|
||||
Record<string, never>,
|
||||
Settings
|
||||
>;
|
||||
|
||||
const result: ObjectKey<Settings> = {
|
||||
// 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;
|
||||
}
|
@ -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<string, "IntegrationId">;
|
||||
|
@ -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",
|
||||
|
@ -18,6 +18,7 @@ export class DefaultSemanticLogger<Context extends object> implements SemanticLo
|
||||
constructor(
|
||||
private logger: LogService,
|
||||
context: Jsonify<Context>,
|
||||
private now = () => Date.now(),
|
||||
) {
|
||||
this.context = context && typeof context === "object" ? context : {};
|
||||
}
|
||||
@ -53,6 +54,7 @@ export class DefaultSemanticLogger<Context extends object> implements SemanticLo
|
||||
message,
|
||||
content: content ?? undefined,
|
||||
level: stringifyLevel(level),
|
||||
"@timestamp": this.now(),
|
||||
};
|
||||
|
||||
if (typeof content === "string" && !message) {
|
||||
|
@ -17,7 +17,7 @@ export class PrivateClassifier<Data> implements Classifier<Data, Record<string,
|
||||
}
|
||||
const secret = picked as Jsonify<Data>;
|
||||
|
||||
return { disclosed: null, secret };
|
||||
return { disclosed: {}, secret };
|
||||
}
|
||||
|
||||
declassify(_disclosed: Jsonify<Record<keyof Data, never>>, secret: Jsonify<Data>) {
|
||||
|
16
libs/common/src/tools/providers.ts
Normal file
16
libs/common/src/tools/providers.ts
Normal file
@ -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;
|
||||
};
|
@ -16,7 +16,7 @@ export class PublicClassifier<Data> implements Classifier<Data, Data, Record<str
|
||||
}
|
||||
const disclosed = picked as Jsonify<Data>;
|
||||
|
||||
return { disclosed, secret: null };
|
||||
return { disclosed, secret: "" };
|
||||
}
|
||||
|
||||
declassify(disclosed: Jsonify<Data>, _secret: Jsonify<Record<keyof Data, never>>) {
|
||||
|
27
libs/common/src/tools/state/classified-format.spec.ts
Normal file
27
libs/common/src/tools/state/classified-format.spec.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
@ -21,5 +21,12 @@ export type ClassifiedFormat<Id, Disclosed> = {
|
||||
export function isClassifiedFormat<Id, Disclosed>(
|
||||
value: any,
|
||||
): value is ClassifiedFormat<Id, Disclosed> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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<CredentialType, ReadonlyArray<CredentialAlgorithm>> = 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";
|
||||
|
@ -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<Options> = {
|
||||
* 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;
|
||||
|
||||
export type ForwarderProfileMetadata<Options> = ExtensionProfileMetadata<Options, "forwarder"> & {
|
||||
constraints: ProfileConstraints<Options>;
|
||||
};
|
||||
|
||||
@ -77,4 +71,4 @@ export type ExtensionProfileMetadata<Options, Site extends SiteId> = {
|
||||
*/
|
||||
export type ProfileMetadata<Options> =
|
||||
| CoreProfileMetadata<Options>
|
||||
| ExtensionProfileMetadata<Options, "forwarder">;
|
||||
| ForwarderProfileMetadata<Options>;
|
||||
|
@ -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<object> = {
|
||||
target: "object",
|
||||
key: "arbitrary",
|
||||
state: GENERATOR_DISK,
|
||||
classifier: new PrivateClassifier(),
|
||||
format: "classified",
|
||||
options: { clearOn: [], deserializer: (value) => value },
|
||||
};
|
||||
|
||||
function createConstraints(policies: Policy[], context: ProfileContext<object>) {
|
||||
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<object> = {
|
||||
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<object> = {
|
||||
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<object> = {
|
||||
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<object> = {
|
||||
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<object> = {
|
||||
type: "extension",
|
||||
site: "not-a-forwarder" as any,
|
||||
storage: SomeStorage,
|
||||
constraints: {
|
||||
default: {},
|
||||
create: () => null,
|
||||
create: createConstraints,
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -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<Options>(
|
||||
/** Returns true when the input describes a forwarder extension profile. */
|
||||
export function isForwarderProfile<Options>(
|
||||
value: ProfileMetadata<Options>,
|
||||
): value is ExtensionProfileMetadata<Options, "forwarder"> {
|
||||
): value is ForwarderProfileMetadata<Options> {
|
||||
return value.type === "extension" && value.site === "forwarder";
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user