diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index 997974e0581..2a687085e29 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -1,9 +1,9 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { - CollectionRequest, CollectionAccessDetailsResponse, CollectionDetailsResponse, + CollectionRequest, CollectionResponse, } from "@bitwarden/admin-console/common"; @@ -137,7 +137,7 @@ import { OptionalCipherResponse } from "../vault/models/response/optional-cipher */ export abstract class ApiService { send: ( - method: "GET" | "POST" | "PUT" | "DELETE", + method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", path: string, body: any, authed: boolean, diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index 1ed5227cb13..5cd64c46764 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -188,3 +188,4 @@ export const NEW_DEVICE_VERIFICATION_NOTICE = new StateDefinition( }, ); export const VAULT_APPEARANCE = new StateDefinition("vaultAppearance", "disk"); +export const SECURITY_TASKS_DISK = new StateDefinition("securityTasks", "disk"); diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index dc0a8d61f64..14aa69428d7 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -3,9 +3,9 @@ import { firstValueFrom } from "rxjs"; import { - CollectionRequest, CollectionAccessDetailsResponse, CollectionDetailsResponse, + CollectionRequest, CollectionResponse, } from "@bitwarden/admin-console/common"; import { LogoutReason } from "@bitwarden/auth/common"; @@ -1835,7 +1835,7 @@ export class ApiService implements ApiServiceAbstraction { } async send( - method: "GET" | "POST" | "PUT" | "DELETE", + method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", path: string, body: any, authed: boolean, @@ -1875,7 +1875,7 @@ export class ApiService implements ApiServiceAbstraction { return responseJson; } else if (hasResponse && response.status === 200 && responseIsCsv) { return await response.text(); - } else if (response.status !== 200) { + } else if (response.status !== 200 && response.status !== 204) { const error = await this.handleError(response, false, authed); return Promise.reject(error); } diff --git a/libs/common/src/types/guid.ts b/libs/common/src/types/guid.ts index 59dbe28f907..5ad498c115a 100644 --- a/libs/common/src/types/guid.ts +++ b/libs/common/src/types/guid.ts @@ -10,3 +10,4 @@ export type PolicyId = Opaque; export type CipherId = Opaque; export type SendId = Opaque; export type IndexedEntityId = Opaque; +export type SecurityTaskId = Opaque; diff --git a/libs/vault/src/index.ts b/libs/vault/src/index.ts index c9a719934ac..23143fa2309 100644 --- a/libs/vault/src/index.ts +++ b/libs/vault/src/index.ts @@ -19,3 +19,5 @@ export { NewDeviceVerificationNoticePageTwoComponent } from "./components/new-de export { DecryptionFailureDialogComponent } from "./components/decryption-failure-dialog/decryption-failure-dialog.component"; export * as VaultIcons from "./icons"; + +export * from "./tasks"; diff --git a/libs/vault/src/tasks/abstractions/task.service.ts b/libs/vault/src/tasks/abstractions/task.service.ts new file mode 100644 index 00000000000..3d6ae72900f --- /dev/null +++ b/libs/vault/src/tasks/abstractions/task.service.ts @@ -0,0 +1,45 @@ +import { Observable } from "rxjs"; + +import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid"; +import { SecurityTask } from "@bitwarden/vault"; + +export abstract class TaskService { + /** + * Observable indicating if tasks are enabled for a given user. + * + * @remarks Internally, this checks the user's organization details to determine if tasks are enabled. + * @param userId + */ + abstract tasksEnabled$(userId: UserId): Observable; + + /** + * Observable of all tasks for a given user. + * @param userId + */ + abstract tasks$(userId: UserId): Observable; + + /** + * Observable of pending tasks for a given user. + * @param userId + */ + abstract pendingTasks$(userId: UserId): Observable; + + /** + * Retrieves tasks from the API for a given user and updates the local state. + * @param userId + */ + abstract refreshTasks(userId: UserId): Promise; + + /** + * Clears all the tasks from state for the given user. + * @param userId + */ + abstract clear(userId: UserId): Promise; + + /** + * Marks a task as complete in local state and updates the server. + * @param taskId - The ID of the task to mark as complete. + * @param userId - The user who is completing the task. + */ + abstract markAsComplete(taskId: SecurityTaskId, userId: UserId): Promise; +} diff --git a/libs/vault/src/tasks/enums/index.ts b/libs/vault/src/tasks/enums/index.ts new file mode 100644 index 00000000000..c1a5e81d877 --- /dev/null +++ b/libs/vault/src/tasks/enums/index.ts @@ -0,0 +1,2 @@ +export * from "./security-task-status.enum"; +export * from "./security-task-type.enum"; diff --git a/libs/vault/src/tasks/enums/security-task-status.enum.ts b/libs/vault/src/tasks/enums/security-task-status.enum.ts new file mode 100644 index 00000000000..1c6e7decc20 --- /dev/null +++ b/libs/vault/src/tasks/enums/security-task-status.enum.ts @@ -0,0 +1,11 @@ +export enum SecurityTaskStatus { + /** + * Default status for newly created tasks that have not been completed. + */ + Pending = 0, + + /** + * Status when a task is considered complete and has no remaining actions + */ + Completed = 1, +} diff --git a/libs/vault/src/tasks/enums/security-task-type.enum.ts b/libs/vault/src/tasks/enums/security-task-type.enum.ts new file mode 100644 index 00000000000..264cd88696b --- /dev/null +++ b/libs/vault/src/tasks/enums/security-task-type.enum.ts @@ -0,0 +1,6 @@ +export enum SecurityTaskType { + /** + * Task to update a cipher's password that was found to be at-risk by an administrator + */ + UpdateAtRiskCredential = 0, +} diff --git a/libs/vault/src/tasks/index.ts b/libs/vault/src/tasks/index.ts new file mode 100644 index 00000000000..a0339c6d8f4 --- /dev/null +++ b/libs/vault/src/tasks/index.ts @@ -0,0 +1,5 @@ +export * from "./enums"; +export * from "./models"; + +export * from "./abstractions/task.service"; +export * from "./services/default-task.service"; diff --git a/libs/vault/src/tasks/models/index.ts b/libs/vault/src/tasks/models/index.ts new file mode 100644 index 00000000000..7e31c136629 --- /dev/null +++ b/libs/vault/src/tasks/models/index.ts @@ -0,0 +1 @@ +export * from "./security-task"; diff --git a/libs/vault/src/tasks/models/security-task.data.ts b/libs/vault/src/tasks/models/security-task.data.ts new file mode 100644 index 00000000000..e2d9cc76c0a --- /dev/null +++ b/libs/vault/src/tasks/models/security-task.data.ts @@ -0,0 +1,34 @@ +import { Jsonify } from "type-fest"; + +import { CipherId, OrganizationId, SecurityTaskId } from "@bitwarden/common/types/guid"; + +import { SecurityTaskStatus, SecurityTaskType } from "../enums"; + +import { SecurityTaskResponse } from "./security-task.response"; + +export class SecurityTaskData { + id: SecurityTaskId; + organizationId: OrganizationId; + cipherId: CipherId | undefined; + type: SecurityTaskType; + status: SecurityTaskStatus; + creationDate: Date; + revisionDate: Date; + + constructor(response: SecurityTaskResponse) { + this.id = response.id; + this.organizationId = response.organizationId; + this.cipherId = response.cipherId; + this.type = response.type; + this.status = response.status; + this.creationDate = response.creationDate; + this.revisionDate = response.revisionDate; + } + + static fromJSON(obj: Jsonify) { + return Object.assign(new SecurityTaskData({} as SecurityTaskResponse), obj, { + creationDate: new Date(obj.creationDate), + revisionDate: new Date(obj.revisionDate), + }); + } +} diff --git a/libs/vault/src/tasks/models/security-task.response.ts b/libs/vault/src/tasks/models/security-task.response.ts new file mode 100644 index 00000000000..2a335eb5d2f --- /dev/null +++ b/libs/vault/src/tasks/models/security-task.response.ts @@ -0,0 +1,28 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; +import { CipherId, OrganizationId, SecurityTaskId } from "@bitwarden/common/types/guid"; + +import { SecurityTaskStatus, SecurityTaskType } from "../enums"; + +export class SecurityTaskResponse extends BaseResponse { + id: SecurityTaskId; + organizationId: OrganizationId; + /** + * Optional cipherId for tasks that are related to a cipher. + */ + cipherId: CipherId | undefined; + type: SecurityTaskType; + status: SecurityTaskStatus; + creationDate: Date; + revisionDate: Date; + + constructor(response: any) { + super(response); + this.id = this.getResponseProperty("Id"); + this.organizationId = this.getResponseProperty("OrganizationId"); + this.cipherId = this.getResponseProperty("CipherId") || undefined; + this.type = this.getResponseProperty("Type"); + this.status = this.getResponseProperty("Status"); + this.creationDate = this.getResponseProperty("CreationDate"); + this.revisionDate = this.getResponseProperty("RevisionDate"); + } +} diff --git a/libs/vault/src/tasks/models/security-task.ts b/libs/vault/src/tasks/models/security-task.ts new file mode 100644 index 00000000000..42adb951945 --- /dev/null +++ b/libs/vault/src/tasks/models/security-task.ts @@ -0,0 +1,28 @@ +import { CipherId, OrganizationId, SecurityTaskId } from "@bitwarden/common/types/guid"; + +import { SecurityTaskStatus, SecurityTaskType } from "../enums"; + +import { SecurityTaskData } from "./security-task.data"; + +export class SecurityTask { + id: SecurityTaskId; + organizationId: OrganizationId; + /** + * Optional cipherId for tasks that are related to a cipher. + */ + cipherId: CipherId | undefined; + type: SecurityTaskType; + status: SecurityTaskStatus; + creationDate: Date; + revisionDate: Date; + + constructor(obj: SecurityTaskData) { + this.id = obj.id; + this.organizationId = obj.organizationId; + this.cipherId = obj.cipherId; + this.type = obj.type; + this.status = obj.status; + this.creationDate = obj.creationDate; + this.revisionDate = obj.revisionDate; + } +} diff --git a/libs/vault/src/tasks/services/default-task.service.spec.ts b/libs/vault/src/tasks/services/default-task.service.spec.ts new file mode 100644 index 00000000000..850d0bcc2b8 --- /dev/null +++ b/libs/vault/src/tasks/services/default-task.service.spec.ts @@ -0,0 +1,261 @@ +import { TestBed } from "@angular/core/testing"; +import { BehaviorSubject, firstValueFrom } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { StateProvider } from "@bitwarden/common/platform/state"; +import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid"; +import { DefaultTaskService, SecurityTaskStatus } from "@bitwarden/vault"; + +import { FakeStateProvider, mockAccountServiceWith } from "../../../../common/spec"; +import { SecurityTaskData } from "../models/security-task.data"; +import { SecurityTaskResponse } from "../models/security-task.response"; +import { SECURITY_TASKS } from "../state/security-task.state"; + +describe("Default task service", () => { + let fakeStateProvider: FakeStateProvider; + + const mockApiSend = jest.fn(); + const mockGetAllOrgs$ = jest.fn(); + + let testBed: TestBed; + + beforeEach(async () => { + mockApiSend.mockClear(); + mockGetAllOrgs$.mockClear(); + + fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId)); + testBed = TestBed.configureTestingModule({ + imports: [], + providers: [ + DefaultTaskService, + { + provide: StateProvider, + useValue: fakeStateProvider, + }, + { + provide: ApiService, + useValue: { + send: mockApiSend, + }, + }, + { + provide: OrganizationService, + useValue: { + getAll$: mockGetAllOrgs$, + }, + }, + ], + }); + }); + + describe("tasksEnabled$", () => { + it("should emit true if any organization uses risk insights", async () => { + mockGetAllOrgs$.mockReturnValue( + new BehaviorSubject([ + { + useRiskInsights: false, + }, + { + useRiskInsights: true, + }, + ] as Organization[]), + ); + + const { tasksEnabled$ } = testBed.inject(DefaultTaskService); + + const result = await firstValueFrom(tasksEnabled$("user-id" as UserId)); + + expect(result).toBe(true); + }); + + it("should emit false if no organization uses risk insights", async () => { + mockGetAllOrgs$.mockReturnValue( + new BehaviorSubject([ + { + useRiskInsights: false, + }, + { + useRiskInsights: false, + }, + ] as Organization[]), + ); + + const { tasksEnabled$ } = testBed.inject(DefaultTaskService); + + const result = await firstValueFrom(tasksEnabled$("user-id" as UserId)); + + expect(result).toBe(false); + }); + }); + + describe("tasks$", () => { + it("should fetch tasks from the API when the state is null", async () => { + mockApiSend.mockResolvedValue({ + data: [ + { + id: "task-id", + }, + ] as SecurityTaskResponse[], + }); + + fakeStateProvider.singleUser.mockFor("user-id" as UserId, SECURITY_TASKS, null); + + const { tasks$ } = testBed.inject(DefaultTaskService); + + const result = await firstValueFrom(tasks$("user-id" as UserId)); + + expect(result.length).toBe(1); + expect(mockApiSend).toHaveBeenCalledWith("GET", "/tasks", null, true, true); + }); + + it("should use the tasks from state when not null", async () => { + fakeStateProvider.singleUser.mockFor("user-id" as UserId, SECURITY_TASKS, [ + { + id: "task-id" as SecurityTaskId, + } as SecurityTaskData, + ]); + + const { tasks$ } = testBed.inject(DefaultTaskService); + + const result = await firstValueFrom(tasks$("user-id" as UserId)); + + expect(result.length).toBe(1); + expect(mockApiSend).not.toHaveBeenCalled(); + }); + + it("should share the same observable for the same user", async () => { + const { tasks$ } = testBed.inject(DefaultTaskService); + + const first = tasks$("user-id" as UserId); + const second = tasks$("user-id" as UserId); + + expect(first).toBe(second); + }); + }); + + describe("pendingTasks$", () => { + it("should filter tasks to only pending tasks", async () => { + fakeStateProvider.singleUser.mockFor("user-id" as UserId, SECURITY_TASKS, [ + { + id: "completed-task-id" as SecurityTaskId, + status: SecurityTaskStatus.Completed, + }, + { + id: "pending-task-id" as SecurityTaskId, + status: SecurityTaskStatus.Pending, + }, + ] as SecurityTaskData[]); + + const { pendingTasks$ } = testBed.inject(DefaultTaskService); + + const result = await firstValueFrom(pendingTasks$("user-id" as UserId)); + + expect(result.length).toBe(1); + expect(result[0].id).toBe("pending-task-id" as SecurityTaskId); + }); + }); + + describe("refreshTasks()", () => { + it("should fetch tasks from the API", async () => { + mockApiSend.mockResolvedValue({ + data: [ + { + id: "task-id", + }, + ] as SecurityTaskResponse[], + }); + + const service = testBed.inject(DefaultTaskService); + + await service.refreshTasks("user-id" as UserId); + + expect(mockApiSend).toHaveBeenCalledWith("GET", "/tasks", null, true, true); + }); + + it("should update the local state with refreshed tasks", async () => { + mockApiSend.mockResolvedValue({ + data: [ + { + id: "task-id", + }, + ] as SecurityTaskResponse[], + }); + + const mock = fakeStateProvider.singleUser.mockFor("user-id" as UserId, SECURITY_TASKS, null); + + const service = testBed.inject(DefaultTaskService); + + await service.refreshTasks("user-id" as UserId); + + expect(mock.nextMock).toHaveBeenCalledWith([ + { + id: "task-id" as SecurityTaskId, + } as SecurityTaskData, + ]); + }); + }); + + describe("clear()", () => { + it("should clear the local state for the user", async () => { + const mock = fakeStateProvider.singleUser.mockFor("user-id" as UserId, SECURITY_TASKS, [ + { + id: "task-id" as SecurityTaskId, + } as SecurityTaskData, + ]); + + const service = testBed.inject(DefaultTaskService); + + await service.clear("user-id" as UserId); + + expect(mock.nextMock).toHaveBeenCalledWith([]); + }); + }); + + describe("markAsComplete()", () => { + it("should send an API request to mark the task as complete", async () => { + const service = testBed.inject(DefaultTaskService); + + await service.markAsComplete("task-id" as SecurityTaskId, "user-id" as UserId); + + expect(mockApiSend).toHaveBeenCalledWith( + "PATCH", + "/tasks/task-id/complete", + null, + true, + false, + ); + }); + + it("should refresh all tasks for the user after marking the task as complete", async () => { + mockApiSend + .mockResolvedValueOnce(null) // Mark as complete + .mockResolvedValueOnce({ + // Refresh tasks + data: [ + { + id: "new-task-id", + }, + ] as SecurityTaskResponse[], + }); + + const mockState = fakeStateProvider.singleUser.mockFor("user-id" as UserId, SECURITY_TASKS, [ + { + id: "old-task-id" as SecurityTaskId, + } as SecurityTaskData, + ]); + + const service = testBed.inject(DefaultTaskService); + + await service.markAsComplete("task-id" as SecurityTaskId, "user-id" as UserId); + + expect(mockApiSend).toHaveBeenCalledWith("GET", "/tasks", null, true, true); + expect(mockState.nextMock).toHaveBeenCalledWith([ + { + id: "new-task-id", + } as SecurityTaskData, + ]); + }); + }); +}); diff --git a/libs/vault/src/tasks/services/default-task.service.ts b/libs/vault/src/tasks/services/default-task.service.ts new file mode 100644 index 00000000000..2fc4ba3a937 --- /dev/null +++ b/libs/vault/src/tasks/services/default-task.service.ts @@ -0,0 +1,93 @@ +import { Injectable } from "@angular/core"; +import { map, switchMap } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; +import { StateProvider } from "@bitwarden/common/platform/state"; +import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid"; +import { SecurityTask, SecurityTaskStatus, TaskService } from "@bitwarden/vault"; + +import { filterOutNullish, perUserCache$ } from "../../utils/observable-utilities"; +import { SecurityTaskData } from "../models/security-task.data"; +import { SecurityTaskResponse } from "../models/security-task.response"; +import { SECURITY_TASKS } from "../state/security-task.state"; + +@Injectable() +export class DefaultTaskService implements TaskService { + constructor( + private stateProvider: StateProvider, + private apiService: ApiService, + private organizationService: OrganizationService, + ) {} + + tasksEnabled$ = perUserCache$((userId) => { + return this.organizationService + .getAll$(userId) + .pipe(map((orgs) => orgs.some((o) => o.useRiskInsights))); + }); + + tasks$ = perUserCache$((userId) => { + return this.taskState(userId).state$.pipe( + switchMap(async (tasks) => { + if (tasks == null) { + await this.fetchTasksFromApi(userId); + } + return tasks; + }), + filterOutNullish(), + map((tasks) => tasks.map((t) => new SecurityTask(t))), + ); + }); + + pendingTasks$ = perUserCache$((userId) => { + return this.tasks$(userId).pipe( + map((tasks) => tasks.filter((t) => t.status === SecurityTaskStatus.Pending)), + ); + }); + + async refreshTasks(userId: UserId): Promise { + await this.fetchTasksFromApi(userId); + } + + async clear(userId: UserId): Promise { + await this.updateTaskState(userId, []); + } + + async markAsComplete(taskId: SecurityTaskId, userId: UserId): Promise { + await this.apiService.send("PATCH", `/tasks/${taskId}/complete`, null, true, false); + await this.refreshTasks(userId); + } + + /** + * Fetches the tasks from the API and updates the local state + * @param userId + * @private + */ + private async fetchTasksFromApi(userId: UserId): Promise { + const r = await this.apiService.send("GET", "/tasks", null, true, true); + const response = new ListResponse(r, SecurityTaskResponse); + + const taskData = response.data.map((t) => new SecurityTaskData(t)); + await this.updateTaskState(userId, taskData); + } + + /** + * Returns the local state for the tasks + * @param userId + * @private + */ + private taskState(userId: UserId) { + return this.stateProvider.getUser(userId, SECURITY_TASKS); + } + + /** + * Updates the local state with the provided tasks and returns the updated state + * @param userId + * @param tasks + * @private + */ + private updateTaskState(userId: UserId, tasks: SecurityTaskData[]): Promise { + return this.taskState(userId).update(() => tasks); + } +} diff --git a/libs/vault/src/tasks/state/security-task.state.ts b/libs/vault/src/tasks/state/security-task.state.ts new file mode 100644 index 00000000000..b86a891f008 --- /dev/null +++ b/libs/vault/src/tasks/state/security-task.state.ts @@ -0,0 +1,14 @@ +import { Jsonify } from "type-fest"; + +import { SECURITY_TASKS_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; + +import { SecurityTaskData } from "../models/security-task.data"; + +export const SECURITY_TASKS = UserKeyDefinition.array( + SECURITY_TASKS_DISK, + "securityTasks", + { + deserializer: (task: Jsonify) => SecurityTaskData.fromJSON(task), + clearOn: ["logout", "lock"], + }, +); diff --git a/libs/vault/src/utils/observable-utilities.ts b/libs/vault/src/utils/observable-utilities.ts new file mode 100644 index 00000000000..bb559c600d3 --- /dev/null +++ b/libs/vault/src/utils/observable-utilities.ts @@ -0,0 +1,37 @@ +import { filter, Observable, OperatorFunction, shareReplay } from "rxjs"; + +import { UserId } from "@bitwarden/common/types/guid"; + +/** + * Builds an observable once per userId and caches it for future requests. + * The built observables are shared among subscribers with a replay buffer size of 1. + * @param create - A function that creates an observable for a given userId. + */ +export function perUserCache$( + create: (userId: UserId) => Observable, +): (userId: UserId) => Observable { + const cache = new Map>(); + return (userId: UserId) => { + let observable = cache.get(userId); + if (!observable) { + observable = create(userId).pipe(shareReplay({ bufferSize: 1, refCount: false })); + cache.set(userId, observable); + } + return observable; + }; +} + +/** + * Strongly typed observable operator that filters out null/undefined values and adjusts the return type to + * be non-nullable. + * + * @example + * ```ts + * const source$ = of(1, null, 2, undefined, 3); + * source$.pipe(filterOutNullish()).subscribe(console.log); + * // Output: 1, 2, 3 + * ``` + */ +export function filterOutNullish(): OperatorFunction { + return filter((v): v is T => v != null); +}