diff --git a/libs/common/src/admin-console/abstractions/organization/vnext.organization.service.ts b/libs/common/src/admin-console/abstractions/organization/vnext.organization.service.ts new file mode 100644 index 0000000000..45c00e3fd4 --- /dev/null +++ b/libs/common/src/admin-console/abstractions/organization/vnext.organization.service.ts @@ -0,0 +1,124 @@ +import { map, Observable } from "rxjs"; + +import { I18nService } from "../../../platform/abstractions/i18n.service"; +import { Utils } from "../../../platform/misc/utils"; +import { UserId } from "../../../types/guid"; +import { OrganizationData } from "../../models/data/organization.data"; +import { Organization } from "../../models/domain/organization"; + +export function canAccessVaultTab(org: Organization): boolean { + return org.canViewAllCollections; +} + +export function canAccessSettingsTab(org: Organization): boolean { + return ( + org.isOwner || + org.canManagePolicies || + org.canManageSso || + org.canManageScim || + org.canAccessImportExport || + org.canManageDeviceApprovals + ); +} + +export function canAccessMembersTab(org: Organization): boolean { + return org.canManageUsers || org.canManageUsersPassword; +} + +export function canAccessGroupsTab(org: Organization): boolean { + return org.canManageGroups; +} + +export function canAccessReportingTab(org: Organization): boolean { + return org.canAccessReports || org.canAccessEventLogs; +} + +export function canAccessBillingTab(org: Organization): boolean { + return org.isOwner; +} + +export function canAccessOrgAdmin(org: Organization): boolean { + // Admin console can only be accessed by Owners for disabled organizations + if (!org.enabled && !org.isOwner) { + return false; + } + return ( + canAccessMembersTab(org) || + canAccessGroupsTab(org) || + canAccessReportingTab(org) || + canAccessBillingTab(org) || + canAccessSettingsTab(org) || + canAccessVaultTab(org) + ); +} + +export function getOrganizationById(id: string) { + return map((orgs) => orgs.find((o) => o.id === id)); +} + +export function canAccessAdmin(i18nService: I18nService) { + return map((orgs) => + orgs.filter(canAccessOrgAdmin).sort(Utils.getSortFunction(i18nService, "name")), + ); +} + +export function canAccessImport(i18nService: I18nService) { + return map((orgs) => + orgs + .filter((org) => org.canAccessImportExport || org.canCreateNewCollections) + .sort(Utils.getSortFunction(i18nService, "name")), + ); +} + +/** + * Publishes an observable stream of organizations. This service is meant to + * be used widely across Bitwarden as the primary way of fetching organizations. + * Risky operations like updates are isolated to the + * internal extension `InternalOrganizationServiceAbstraction`. + */ +export abstract class vNextOrganizationService { + /** + * Publishes state for all organizations under the specified user. + * @returns An observable list of organizations + */ + organizations$: (userId: UserId) => Observable; + + // @todo Clean these up. Continuing to expand them is not recommended. + // @see https://bitwarden.atlassian.net/browse/AC-2252 + memberOrganizations$: (userId: UserId) => Observable; + /** + * Emits true if the user can create or manage a Free Bitwarden Families sponsorship. + */ + canManageSponsorships$: (userId: UserId) => Observable; + /** + * Emits true if any of the user's organizations have a Free Bitwarden Families sponsorship available. + */ + familySponsorshipAvailable$: (userId: UserId) => Observable; + hasOrganizations: (userId: UserId) => Observable; +} + +/** + * Big scary buttons that **update** organization state. These should only be + * called from within admin-console scoped code. Extends the base + * `OrganizationService` for easy access to `get` calls. + * @internal + */ +export abstract class vNextInternalOrganizationServiceAbstraction extends vNextOrganizationService { + /** + * Replaces state for the provided organization, or creates it if not found. + * @param organization The organization state being saved. + * @param userId The userId to replace state for. + */ + upsert: (OrganizationData: OrganizationData, userId: UserId) => Promise; + + /** + * Replaces state for the entire registered organization list for the specified user. + * You probably don't want this unless you're calling from a full sync + * operation or a logout. See `upsert` for creating & updating a single + * organization in the state. + * @param organizations A complete list of all organization state for the provided + * user. + * @param userId The userId to replace state for. + */ + replace: (organizations: { [id: string]: OrganizationData }, userId: UserId) => Promise; +} diff --git a/libs/common/src/admin-console/services/organization/default-vnext-organization.service.spec.ts b/libs/common/src/admin-console/services/organization/default-vnext-organization.service.spec.ts new file mode 100644 index 0000000000..9e2ea3a459 --- /dev/null +++ b/libs/common/src/admin-console/services/organization/default-vnext-organization.service.spec.ts @@ -0,0 +1,204 @@ +import { firstValueFrom } from "rxjs"; + +import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec"; +import { Utils } from "../../../platform/misc/utils"; +import { OrganizationId, UserId } from "../../../types/guid"; +import { OrganizationData } from "../../models/data/organization.data"; +import { Organization } from "../../models/domain/organization"; + +import { DefaultvNextOrganizationService } from "./default-vnext-organization.service"; +import { ORGANIZATIONS } from "./vnext-organization.state"; + +describe("OrganizationService", () => { + let organizationService: DefaultvNextOrganizationService; + + const fakeUserId = Utils.newGuid() as UserId; + let fakeStateProvider: FakeStateProvider; + + /** + * It is easier to read arrays than records in code, but we store a record + * in state. This helper methods lets us build organization arrays in tests + * and easily map them to records before storing them in state. + */ + function arrayToRecord(input: OrganizationData[]): Record { + if (input == null) { + return undefined; + } + return Object.fromEntries(input?.map((i) => [i.id, i])); + } + + /** + * There are a few assertions in this spec that check for array equality + * but want to ignore a specific index that _should_ be different. This + * function takes two arrays, and an index. It checks for equality of the + * arrays, but splices out the specified index from both arrays first. + */ + function expectIsEqualExceptForIndex(x: any[], y: any[], indexToExclude: number) { + // Clone the arrays to avoid modifying the reference values + const a = [...x]; + const b = [...y]; + delete a[indexToExclude]; + delete b[indexToExclude]; + expect(a).toEqual(b); + } + + /** + * Builds a simple mock `OrganizationData[]` array that can be used in tests + * to populate state. + * @param count The number of organizations to populate the list with. The + * function returns undefined if this is less than 1. The default value is 1. + * @param suffix A string to append to data fields on each organization. + * This defaults to the index of the organization in the list. + * @returns an `OrganizationData[]` array that can be used to populate + * stateProvider. + */ + function buildMockOrganizations(count = 1, suffix?: string): OrganizationData[] { + if (count < 1) { + return undefined; + } + + function buildMockOrganization(id: OrganizationId, name: string, identifier: string) { + const data = new OrganizationData({} as any, {} as any); + data.id = id; + data.name = name; + data.identifier = identifier; + + return data; + } + + const mockOrganizations = []; + for (let i = 0; i < count; i++) { + const s = suffix ? suffix + i.toString() : i.toString(); + mockOrganizations.push( + buildMockOrganization(("org" + s) as OrganizationId, "org" + s, "orgIdentifier" + s), + ); + } + + return mockOrganizations; + } + + const setOrganizationsState = (organizationData: OrganizationData[] | null) => + fakeStateProvider.setUserState( + ORGANIZATIONS, + organizationData == null ? null : arrayToRecord(organizationData), + fakeUserId, + ); + + beforeEach(async () => { + fakeStateProvider = new FakeStateProvider(mockAccountServiceWith(fakeUserId)); + organizationService = new DefaultvNextOrganizationService(fakeStateProvider); + }); + + describe("canManageSponsorships", () => { + it("can because one is available", async () => { + const mockData: OrganizationData[] = buildMockOrganizations(1); + mockData[0].familySponsorshipAvailable = true; + await setOrganizationsState(mockData); + const result = await firstValueFrom(organizationService.canManageSponsorships$(fakeUserId)); + expect(result).toBe(true); + }); + + it("can because one is used", async () => { + const mockData: OrganizationData[] = buildMockOrganizations(1); + mockData[0].familySponsorshipFriendlyName = "Something"; + await setOrganizationsState(mockData); + const result = await firstValueFrom(organizationService.canManageSponsorships$(fakeUserId)); + expect(result).toBe(true); + }); + + it("can not because one isn't available or taken", async () => { + const mockData: OrganizationData[] = buildMockOrganizations(1); + mockData[0].familySponsorshipFriendlyName = null; + await setOrganizationsState(mockData); + const result = await firstValueFrom(organizationService.canManageSponsorships$(fakeUserId)); + expect(result).toBe(false); + }); + }); + + describe("organizations$", () => { + describe("null checking behavior", () => { + it("publishes an empty array if organizations in state = undefined", async () => { + const mockData: OrganizationData[] = undefined; + await setOrganizationsState(mockData); + const result = await firstValueFrom(organizationService.organizations$(fakeUserId)); + expect(result).toEqual([]); + }); + + it("publishes an empty array if organizations in state = null", async () => { + const mockData: OrganizationData[] = null; + await setOrganizationsState(mockData); + const result = await firstValueFrom(organizationService.organizations$(fakeUserId)); + expect(result).toEqual([]); + }); + + it("publishes an empty array if organizations in state = []", async () => { + const mockData: OrganizationData[] = []; + await setOrganizationsState(mockData); + const result = await firstValueFrom(organizationService.organizations$(fakeUserId)); + expect(result).toEqual([]); + }); + + it("returns state for a user", async () => { + const mockData = buildMockOrganizations(10); + await setOrganizationsState(mockData); + const result = await firstValueFrom(organizationService.organizations$(fakeUserId)); + expect(result).toEqual(mockData); + }); + }); + }); + + describe("upsert()", () => { + it("can create the organization list if necassary", async () => { + // Notice that no default state is provided in this test, so the list in + // `stateProvider` will be null when the `upsert` method is called. + const mockData = buildMockOrganizations(); + await organizationService.upsert(mockData[0], fakeUserId); + const result = await firstValueFrom(organizationService.organizations$(fakeUserId)); + expect(result).toEqual(mockData.map((x) => new Organization(x))); + }); + + it("updates an organization that already exists in state", async () => { + const mockData = buildMockOrganizations(10); + await setOrganizationsState(mockData); + const indexToUpdate = 5; + const anUpdatedOrganization = { + ...buildMockOrganizations(1, "UPDATED").pop(), + id: mockData[indexToUpdate].id, + }; + await organizationService.upsert(anUpdatedOrganization, fakeUserId); + const result = await firstValueFrom(organizationService.organizations$(fakeUserId)); + expect(result[indexToUpdate]).not.toEqual(new Organization(mockData[indexToUpdate])); + expect(result[indexToUpdate].id).toEqual(new Organization(mockData[indexToUpdate]).id); + expectIsEqualExceptForIndex( + result, + mockData.map((x) => new Organization(x)), + indexToUpdate, + ); + }); + }); + + describe("replace()", () => { + it("replaces the entire organization list in state", async () => { + const originalData = buildMockOrganizations(10); + await setOrganizationsState(originalData); + + const newData = buildMockOrganizations(10, "newData"); + await organizationService.replace(arrayToRecord(newData), fakeUserId); + + const result = await firstValueFrom(organizationService.organizations$(fakeUserId)); + + expect(result).toEqual(newData); + expect(result).not.toEqual(originalData); + }); + + // This is more or less a test for logouts + it("can replace state with null", async () => { + const originalData = buildMockOrganizations(2); + await setOrganizationsState(originalData); + await organizationService.replace(null, fakeUserId); + const result = await firstValueFrom(organizationService.organizations$(fakeUserId)); + expect(result).toEqual([]); + expect(result).not.toEqual(originalData); + }); + }); +}); diff --git a/libs/common/src/admin-console/services/organization/default-vnext-organization.service.ts b/libs/common/src/admin-console/services/organization/default-vnext-organization.service.ts new file mode 100644 index 0000000000..6ea634b2ec --- /dev/null +++ b/libs/common/src/admin-console/services/organization/default-vnext-organization.service.ts @@ -0,0 +1,101 @@ +import { map, Observable } from "rxjs"; + +import { StateProvider } from "../../../platform/state"; +import { UserId } from "../../../types/guid"; +import { vNextInternalOrganizationServiceAbstraction } from "../../abstractions/organization/vnext.organization.service"; +import { OrganizationData } from "../../models/data/organization.data"; +import { Organization } from "../../models/domain/organization"; + +import { ORGANIZATIONS } from "./vnext-organization.state"; + +/** + * Filter out organizations from an observable that __do not__ offer a + * families-for-enterprise sponsorship to members. + * @returns a function that can be used in `Observable` pipes, + * like `organizationService.organizations$` + */ +function mapToExcludeOrganizationsWithoutFamilySponsorshipSupport() { + return map((orgs) => orgs.filter((o) => o.canManageSponsorships)); +} + +/** + * Filter out organizations from an observable that the organization user + * __is not__ a direct member of. This will exclude organizations only + * accessible as a provider. + * @returns a function that can be used in `Observable` pipes, + * like `organizationService.organizations$` + */ +function mapToExcludeProviderOrganizations() { + return map((orgs) => orgs.filter((o) => o.isMember)); +} + +/** + * Map an observable stream of organizations down to a boolean indicating + * if any organizations exist (`orgs.length > 0`). + * @returns a function that can be used in `Observable` pipes, + * like `organizationService.organizations$` + */ +function mapToBooleanHasAnyOrganizations() { + return map((orgs) => orgs.length > 0); +} + +export class DefaultvNextOrganizationService + implements vNextInternalOrganizationServiceAbstraction +{ + memberOrganizations$(userId: UserId): Observable { + return this.organizations$(userId).pipe(mapToExcludeProviderOrganizations()); + } + + constructor(private stateProvider: StateProvider) {} + + canManageSponsorships$(userId: UserId) { + return this.organizations$(userId).pipe( + mapToExcludeOrganizationsWithoutFamilySponsorshipSupport(), + mapToBooleanHasAnyOrganizations(), + ); + } + + familySponsorshipAvailable$(userId: UserId) { + return this.organizations$(userId).pipe( + map((orgs) => orgs.some((o) => o.familySponsorshipAvailable)), + ); + } + + hasOrganizations(userId: UserId): Observable { + return this.organizations$(userId).pipe(mapToBooleanHasAnyOrganizations()); + } + + async upsert(organization: OrganizationData, userId: UserId): Promise { + await this.organizationState(userId).update((existingOrganizations) => { + const organizations = existingOrganizations ?? {}; + organizations[organization.id] = organization; + return organizations; + }); + } + + async replace(organizations: { [id: string]: OrganizationData }, userId: UserId): Promise { + await this.organizationState(userId).update(() => organizations); + } + + organizations$(userId: UserId): Observable { + return this.organizationState(userId).state$.pipe(this.mapOrganizationRecordToArray()); + } + + private organizationState(userId: UserId) { + return this.stateProvider.getUser(userId, ORGANIZATIONS); + } + + /** + * Accepts a record of `OrganizationData`, which is how we store the + * organization list as a JSON object on disk, to an array of + * `Organization`, which is how the data is published to callers of the + * service. + * @returns a function that can be used to pipe organization data from + * stored state to an exposed object easily consumable by others. + */ + private mapOrganizationRecordToArray() { + return map, Organization[]>((orgs) => + Object.values(orgs ?? {})?.map((o) => new Organization(o)), + ); + } +} diff --git a/libs/common/src/admin-console/services/organization/vnext-organization.state.ts b/libs/common/src/admin-console/services/organization/vnext-organization.state.ts new file mode 100644 index 0000000000..48e09d6d07 --- /dev/null +++ b/libs/common/src/admin-console/services/organization/vnext-organization.state.ts @@ -0,0 +1,20 @@ +import { Jsonify } from "type-fest"; + +import { ORGANIZATIONS_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; + +import { OrganizationData } from "../../models/data/organization.data"; + +/** + * The `KeyDefinition` for accessing organization lists in application state. + * @todo Ideally this wouldn't require a `fromJSON()` call, but `OrganizationData` + * has some properties that contain functions. This should probably get + * cleaned up. + */ +export const ORGANIZATIONS = UserKeyDefinition.record( + ORGANIZATIONS_DISK, + "organizations", + { + deserializer: (obj: Jsonify) => OrganizationData.fromJSON(obj), + clearOn: ["logout"], + }, +);