mirror of
https://github.com/bitwarden/browser
synced 2026-02-10 05:30:01 +00:00
collect events from cipher form
This commit is contained in:
@@ -1,5 +1,18 @@
|
||||
import { Primitive } from "type-fest";
|
||||
|
||||
export type EcsEventType =
|
||||
| "access"
|
||||
| "admin"
|
||||
| "allowed"
|
||||
| "creation"
|
||||
| "deletion"
|
||||
| "denied"
|
||||
| "end"
|
||||
| "error"
|
||||
| "info"
|
||||
| "start"
|
||||
| "user";
|
||||
|
||||
/** Elastic Common Schema log format - core fields.
|
||||
*/
|
||||
export interface EcsFormat {
|
||||
@@ -18,18 +31,7 @@ export interface EcsFormat {
|
||||
event: {
|
||||
kind?: "alert" | "enrichment" | "event" | "metric" | "state";
|
||||
category?: "api" | "authentication" | "iam" | "process" | "session";
|
||||
type?:
|
||||
| "access"
|
||||
| "admin"
|
||||
| "allowed"
|
||||
| "creation"
|
||||
| "deletion"
|
||||
| "denied"
|
||||
| "end"
|
||||
| "error"
|
||||
| "info"
|
||||
| "start"
|
||||
| "user";
|
||||
type?: EcsEventType;
|
||||
outcome?: "failure" | "success" | "unknown";
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { EcsFormat } from "./core";
|
||||
export { EcsFormat, EcsEventType } from "./core";
|
||||
export { ErrorFormat } from "./error";
|
||||
export { EventFormat } from "./event";
|
||||
export { LogFormat } from "./log";
|
||||
|
||||
@@ -3,8 +3,10 @@ import { EcsFormat } from "./core";
|
||||
export type ServiceFormat = EcsFormat & {
|
||||
/** documents the program providing the log */
|
||||
service: {
|
||||
/** Which kind of client is it? */
|
||||
name: "android" | "cli" | "desktop" | "extension" | "ios" | "web";
|
||||
/** Which kind of client is it?
|
||||
* @remarks this contains the output of `BrowserPlatformUtilsService.getDeviceString()` in practice.
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/** identifies the service as a type of client device */
|
||||
type: "client";
|
||||
@@ -18,6 +20,6 @@ export type ServiceFormat = EcsFormat & {
|
||||
environment: "production" | "testing" | "development" | "local";
|
||||
|
||||
/** the unique identifier(s) for this client installation */
|
||||
version: "2025.3.1-innovation-sprint";
|
||||
version: string;
|
||||
};
|
||||
};
|
||||
|
||||
113
libs/common/src/tools/log/logger.ts
Normal file
113
libs/common/src/tools/log/logger.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { BehaviorSubject, SubjectLike, filter, first, from, map, zip } from "rxjs";
|
||||
import { Primitive } from "type-fest";
|
||||
|
||||
import { Account, AccountService } from "../../auth/abstractions/account.service";
|
||||
import { AppIdService } from "../../platform/abstractions/app-id.service";
|
||||
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
|
||||
import { UserActionEvent } from "../achievements/types";
|
||||
|
||||
import { ServiceFormat, UserFormat, EcsEventType } from "./ecs-format";
|
||||
import { SemanticLogger } from "./semantic-logger.abstraction";
|
||||
|
||||
export abstract class UserEventLogProvider {
|
||||
abstract create: (account: Account) => UserEventLogger;
|
||||
}
|
||||
|
||||
type BaselineType = Omit<ServiceFormat & UserFormat, "@timestamp">;
|
||||
|
||||
type EventInfo = {
|
||||
action: string;
|
||||
labels?: Record<string, Primitive>;
|
||||
tags?: Array<string>;
|
||||
};
|
||||
|
||||
export class UserEventLogger {
|
||||
constructor(
|
||||
idService: AppIdService,
|
||||
utilService: PlatformUtilsService,
|
||||
accountService: AccountService,
|
||||
private now: () => number,
|
||||
private log: SemanticLogger,
|
||||
private events$: SubjectLike<UserActionEvent>,
|
||||
) {
|
||||
zip(
|
||||
from(idService.getAppId()),
|
||||
from(utilService.getApplicationVersion()),
|
||||
accountService.activeAccount$.pipe(
|
||||
filter((account) => !!account),
|
||||
first(),
|
||||
),
|
||||
)
|
||||
.pipe(
|
||||
map(
|
||||
([appId, version, account]) =>
|
||||
({
|
||||
event: {
|
||||
kind: "event",
|
||||
category: "session",
|
||||
},
|
||||
service: {
|
||||
name: utilService.getDeviceString(),
|
||||
type: "client",
|
||||
node: {
|
||||
name: appId,
|
||||
},
|
||||
environment: "local",
|
||||
version,
|
||||
},
|
||||
user: {
|
||||
// `account` verified not-null via `filter`
|
||||
id: account!.id,
|
||||
email: (account!.emailVerified && account!.email) || undefined,
|
||||
},
|
||||
}) satisfies BaselineType,
|
||||
),
|
||||
)
|
||||
.subscribe((next) => this.baseline$.next(next));
|
||||
}
|
||||
|
||||
private readonly baseline$ = new BehaviorSubject<BaselineType | null>(null);
|
||||
|
||||
creation(event: EventInfo) {
|
||||
this.collect("creation", event);
|
||||
}
|
||||
|
||||
deletion(event: EventInfo) {
|
||||
this.collect("deletion", event);
|
||||
}
|
||||
|
||||
info(event: EventInfo) {
|
||||
this.collect("info", event);
|
||||
}
|
||||
|
||||
access(event: EventInfo) {
|
||||
this.collect("access", event);
|
||||
}
|
||||
|
||||
private collect(type: EcsEventType, info: EventInfo) {
|
||||
const { value: baseline } = this.baseline$;
|
||||
|
||||
if (!baseline) {
|
||||
// TODO: buffer logs and stream them when `baseline$` becomes available.
|
||||
this.log.error("baseline log not available; dropping user event");
|
||||
return;
|
||||
}
|
||||
|
||||
const event = structuredClone(this.baseline$.value) as UserActionEvent;
|
||||
event["@timestamp"] = this.now();
|
||||
|
||||
event.event.type = type;
|
||||
event.action = info.action;
|
||||
event.tags = info.tags && info.tags.filter((t) => !!t);
|
||||
|
||||
if (info.labels) {
|
||||
const entries = Object.keys(info.labels)
|
||||
.filter((k) => !!info.labels![k])
|
||||
.map((k) => [k, info.labels![k]] as const);
|
||||
const labels = Object.fromEntries(entries);
|
||||
event.labels = labels;
|
||||
}
|
||||
|
||||
this.events$.next(event);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { PolicyService } from "../admin-console/abstractions/policy/policy.servi
|
||||
|
||||
import { ExtensionService } from "./extension/extension.service";
|
||||
import { LogProvider } from "./log";
|
||||
import { UserEventLogProvider } from "./log/logger";
|
||||
|
||||
/** Provides access to commonly-used cross-cutting services. */
|
||||
export type SystemServiceProvider = {
|
||||
@@ -13,4 +14,6 @@ export type SystemServiceProvider = {
|
||||
|
||||
/** Event monitoring and diagnostic interfaces */
|
||||
readonly log: LogProvider;
|
||||
|
||||
readonly event: UserEventLogProvider;
|
||||
};
|
||||
|
||||
@@ -6,7 +6,9 @@ import { firstValueFrom } from "rxjs";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { UserEventLogProvider } from "@bitwarden/common/tools/log/logger";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
@@ -22,6 +24,7 @@ export class DefaultCipherFormService implements CipherFormService {
|
||||
private cipherService: CipherService = inject(CipherService);
|
||||
private accountService: AccountService = inject(AccountService);
|
||||
private apiService: ApiService = inject(ApiService);
|
||||
private system = inject(UserEventLogProvider);
|
||||
|
||||
async decryptCipher(cipher: Cipher): Promise<CipherView> {
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
@@ -32,6 +35,7 @@ export class DefaultCipherFormService implements CipherFormService {
|
||||
|
||||
async saveCipher(cipher: CipherView, config: CipherFormConfig): Promise<CipherView> {
|
||||
// Passing the original cipher is important here as it is responsible for appending to password history
|
||||
const activeUser = await firstValueFrom(this.accountService.activeAccount$);
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
const encryptedCipher = await this.cipherService.encrypt(
|
||||
cipher,
|
||||
@@ -40,12 +44,22 @@ export class DefaultCipherFormService implements CipherFormService {
|
||||
null,
|
||||
config.originalCipher ?? null,
|
||||
);
|
||||
const event = this.system.create(activeUser);
|
||||
const labels = {
|
||||
"vault-item-type": CipherType[cipher.type],
|
||||
"vault-item-uri-quantity": cipher.type === CipherType.Login ? cipher.login.uris.length : null,
|
||||
};
|
||||
const tags = [
|
||||
cipher.attachments.length > 0 ? "with-attachment" : null,
|
||||
cipher.folderId ? "with-folder" : null,
|
||||
];
|
||||
|
||||
let savedCipher: Cipher;
|
||||
|
||||
// Creating a new cipher
|
||||
if (cipher.id == null) {
|
||||
savedCipher = await this.cipherService.createWithServer(encryptedCipher, config.admin);
|
||||
event.creation({ action: "vault-item-added", labels, tags });
|
||||
return await savedCipher.decrypt(
|
||||
await this.cipherService.getKeyForCipherKeyDecryption(savedCipher, activeUserId),
|
||||
);
|
||||
@@ -68,10 +82,15 @@ export class DefaultCipherFormService implements CipherFormService {
|
||||
cipher.collectionIds,
|
||||
activeUserId,
|
||||
);
|
||||
event.info({ action: "vault-item-shared-with-organization", labels, tags });
|
||||
|
||||
// If the collectionIds are the same, update the cipher normally
|
||||
} else if (isSetEqual(originalCollectionIds, newCollectionIds)) {
|
||||
savedCipher = await this.cipherService.updateWithServer(encryptedCipher, config.admin);
|
||||
event.info({ action: "vault-item-updated", labels, tags });
|
||||
} else {
|
||||
tags.push("collection");
|
||||
|
||||
// Updating a cipher with collection changes is not supported with a single request currently
|
||||
// First update the cipher with the original collectionIds
|
||||
encryptedCipher.collectionIds = config.originalCipher.collectionIds;
|
||||
@@ -92,12 +111,16 @@ export class DefaultCipherFormService implements CipherFormService {
|
||||
activeUserId,
|
||||
);
|
||||
}
|
||||
|
||||
event.deletion({ action: "vault-item-moved", labels, tags });
|
||||
}
|
||||
|
||||
// Its possible the cipher was made no longer available due to collection assignment changes
|
||||
// e.g. The cipher was moved to a collection that the user no longer has access to
|
||||
if (savedCipher == null) {
|
||||
return null;
|
||||
} else {
|
||||
event.creation({ action: "vault-item-moved", labels, tags });
|
||||
}
|
||||
|
||||
return await savedCipher.decrypt(
|
||||
|
||||
Reference in New Issue
Block a user