1
0
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:
✨ Audrey ✨
2025-03-18 18:13:18 -04:00
parent ae4f0a7ee4
commit b4eaf3348c
6 changed files with 159 additions and 16 deletions

View File

@@ -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";
};
}

View File

@@ -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";

View File

@@ -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;
};
};

View 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);
}
}

View File

@@ -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;
};

View File

@@ -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(