Migrate OrganizationService to StateProvider (#7895)

This commit is contained in:
Addison Beck 2024-03-18 11:58:33 -05:00 committed by GitHub
parent 087d174194
commit c7abdb9879
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 855 additions and 380 deletions

View File

@ -1,4 +1,5 @@
import { OrganizationService as AbstractOrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationService } from "@bitwarden/common/admin-console/services/organization/organization.service";
import {
FactoryOptions,
@ -6,11 +7,7 @@ import {
factory,
} from "../../../platform/background/service-factories/factory-options";
import { stateProviderFactory } from "../../../platform/background/service-factories/state-provider.factory";
import {
stateServiceFactory,
StateServiceInitOptions,
} from "../../../platform/background/service-factories/state-service.factory";
import { BrowserOrganizationService } from "../../services/browser-organization.service";
import { StateServiceInitOptions } from "../../../platform/background/service-factories/state-service.factory";
type OrganizationServiceFactoryOptions = FactoryOptions;
@ -25,10 +22,6 @@ export function organizationServiceFactory(
cache,
"organizationService",
opts,
async () =>
new BrowserOrganizationService(
await stateServiceFactory(cache, opts),
await stateProviderFactory(cache, opts),
),
async () => new OrganizationService(await stateProviderFactory(cache, opts)),
);
}

View File

@ -1,12 +0,0 @@
import { BehaviorSubject } from "rxjs";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { OrganizationService } from "@bitwarden/common/admin-console/services/organization/organization.service";
import { browserSession, sessionSync } from "../../platform/decorators/session-sync-observable";
@browserSession
export class BrowserOrganizationService extends OrganizationService {
@sessionSync({ initializer: Organization.fromJSON, initializeAs: "array" })
protected _organizations: BehaviorSubject<Organization[]>;
}

View File

@ -20,6 +20,7 @@ import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { InternalPolicyService as InternalPolicyServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { ProviderService as ProviderServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { OrganizationService } from "@bitwarden/common/admin-console/services/organization/organization.service";
import { PolicyApiService } from "@bitwarden/common/admin-console/services/policy/policy-api.service";
import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service";
import { ProviderService } from "@bitwarden/common/admin-console/services/provider.service";
@ -182,7 +183,6 @@ import {
VaultExportServiceAbstraction,
} from "@bitwarden/vault-export-core";
import { BrowserOrganizationService } from "../admin-console/services/browser-organization.service";
import ContextMenusBackground from "../autofill/background/context-menus.background";
import NotificationBackground from "../autofill/background/notification.background";
import OverlayBackground from "../autofill/background/overlay.background";
@ -502,10 +502,7 @@ export default class MainBackground {
this.stateProvider,
);
this.syncNotifierService = new SyncNotifierService();
this.organizationService = new BrowserOrganizationService(
this.stateService,
this.stateProvider,
);
this.organizationService = new OrganizationService(this.stateProvider);
this.policyService = new PolicyService(this.stateProvider, this.organizationService);
this.autofillSettingsService = new AutofillSettingsService(
this.stateProvider,

View File

@ -98,7 +98,6 @@ import { DialogService } from "@bitwarden/components";
import { ImportServiceAbstraction } from "@bitwarden/importer/core";
import { VaultExportServiceAbstraction } from "@bitwarden/vault-export-core";
import { BrowserOrganizationService } from "../../admin-console/services/browser-organization.service";
import { UnauthGuardService } from "../../auth/popup/services";
import { AutofillService } from "../../autofill/services/abstractions/autofill.service";
import MainBackground from "../../background/main.background";
@ -398,13 +397,6 @@ function getBgService<T>(service: keyof MainBackground) {
useFactory: getBgService<NotificationsService>("notificationsService"),
deps: [],
},
{
provide: OrganizationService,
useFactory: (stateService: StateServiceAbstraction, stateProvider: StateProvider) => {
return new BrowserOrganizationService(stateService, stateProvider);
},
deps: [StateServiceAbstraction, StateProvider],
},
{
provide: VaultFilterService,
useClass: VaultFilterService,

View File

@ -275,7 +275,7 @@ export class CurrentTabComponent implements OnInit, OnDestroy {
const dontShowIdentities = !(await firstValueFrom(
this.vaultSettingsService.showIdentitiesCurrentTab$,
));
this.showOrganizations = this.organizationService.hasOrganizations();
this.showOrganizations = await this.organizationService.hasOrganizations();
if (!dontShowCards) {
otherTypes.push(CipherType.Card);
}

View File

@ -74,7 +74,7 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnIn
async ngOnInit() {
this.searchTypeSearch = !this.platformUtilsService.isSafari();
this.showOrganizations = this.organizationService.hasOrganizations();
this.showOrganizations = await this.organizationService.hasOrganizations();
this.vaultFilter = this.vaultFilterService.getVaultFilter();
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.queryParams.pipe(first()).subscribe(async (params) => {

View File

@ -410,7 +410,7 @@ export class Main {
this.providerService = new ProviderService(this.stateProvider);
this.organizationService = new OrganizationService(this.stateService, this.stateProvider);
this.organizationService = new OrganizationService(this.stateProvider);
this.organizationUserService = new OrganizationUserServiceImplementation(this.apiService);

View File

@ -22,6 +22,7 @@ import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { SettingsService } from "@bitwarden/common/abstractions/settings.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
@ -153,6 +154,7 @@ export class AppComponent implements OnInit, OnDestroy {
private biometricStateService: BiometricStateService,
private stateEventRunnerService: StateEventRunnerService,
private providerService: ProviderService,
private organizationService: InternalOrganizationServiceAbstraction,
) {}
ngOnInit() {

View File

@ -17,7 +17,7 @@ export class IsPaidOrgGuard implements CanActivate {
) {}
async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
const org = this.organizationService.get(route.params.organizationId);
const org = await this.organizationService.get(route.params.organizationId);
if (org == null) {
return this.router.createUrlTree(["/"]);

View File

@ -66,7 +66,7 @@ describe("Organization Permissions Guard", () => {
it("permits navigation if no permissions are specified", async () => {
const org = orgFactory();
organizationService.get.calledWith(org.id).mockReturnValue(org);
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const actual = await organizationPermissionsGuard.canActivate(route, state);
@ -81,7 +81,7 @@ describe("Organization Permissions Guard", () => {
};
const org = orgFactory();
organizationService.get.calledWith(org.id).mockReturnValue(org);
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const actual = await organizationPermissionsGuard.canActivate(route, state);
@ -104,7 +104,7 @@ describe("Organization Permissions Guard", () => {
});
const org = orgFactory();
organizationService.get.calledWith(org.id).mockReturnValue(org);
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const actual = await organizationPermissionsGuard.canActivate(route, state);
@ -124,7 +124,7 @@ describe("Organization Permissions Guard", () => {
}),
});
const org = orgFactory();
organizationService.get.calledWith(org.id).mockReturnValue(org);
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const actual = await organizationPermissionsGuard.canActivate(route, state);
@ -141,7 +141,7 @@ describe("Organization Permissions Guard", () => {
type: OrganizationUserType.Admin,
enabled: false,
});
organizationService.get.calledWith(org.id).mockReturnValue(org);
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const actual = await organizationPermissionsGuard.canActivate(route, state);
@ -153,7 +153,7 @@ describe("Organization Permissions Guard", () => {
type: OrganizationUserType.Owner,
enabled: false,
});
organizationService.get.calledWith(org.id).mockReturnValue(org);
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const actual = await organizationPermissionsGuard.canActivate(route, state);

View File

@ -28,7 +28,7 @@ export class OrganizationPermissionsGuard implements CanActivate {
await this.syncService.fullSync(false);
}
const org = this.organizationService.get(route.params.organizationId);
const org = await this.organizationService.get(route.params.organizationId);
if (org == null) {
return this.router.createUrlTree(["/"]);
}

View File

@ -16,7 +16,7 @@ export class OrganizationRedirectGuard implements CanActivate {
) {}
async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
const org = this.organizationService.get(route.params.organizationId);
const org = await this.organizationService.get(route.params.organizationId);
const customRedirect = route.data?.autoRedirectCallback;
if (customRedirect) {

View File

@ -143,7 +143,7 @@ export class PeopleComponent
async ngOnInit() {
const organization$ = this.route.params.pipe(
map((params) => this.organizationService.get(params.organizationId)),
concatMap((params) => this.organizationService.get$(params.organizationId)),
shareReplay({ refCount: true, bufferSize: 1 }),
);

View File

@ -1,6 +1,6 @@
import { Component, OnInit } from "@angular/core";
import { ActivatedRoute, NavigationEnd, Router } from "@angular/router";
import { filter, map, Observable, startWith } from "rxjs";
import { filter, map, Observable, startWith, concatMap } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
@ -28,7 +28,7 @@ export class ReportsHomeComponent implements OnInit {
);
this.reports$ = this.route.params.pipe(
map((params) => this.organizationService.get(params.organizationId)),
concatMap((params) => this.organizationService.get$(params.organizationId)),
map((org) => this.buildReports(org.isFreeOrg)),
);
}

View File

@ -1,6 +1,6 @@
import { Component } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { concatMap, takeUntil } from "rxjs";
import { concatMap, takeUntil, map } from "rxjs";
import { tap } from "rxjs/operators";
import { ModalService } from "@bitwarden/angular/services/modal.service";
@ -42,9 +42,14 @@ export class TwoFactorSetupComponent extends BaseTwoFactorSetupComponent {
async ngOnInit() {
this.route.params
.pipe(
tap((params) => {
this.organizationId = params.organizationId;
this.organization = this.organizationService.get(this.organizationId);
concatMap((params) =>
this.organizationService
.get$(params.organizationId)
.pipe(map((organization) => ({ params, organization }))),
),
tap(async (mapResponse) => {
this.organizationId = mapResponse.params.organizationId;
this.organization = mapResponse.organization;
}),
concatMap(async () => await super.ngOnInit()),
takeUntil(this.destroy$),

View File

@ -11,7 +11,7 @@ import { NotificationsService } from "@bitwarden/common/abstractions/notificatio
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { SettingsService } from "@bitwarden/common/abstractions/settings.service";
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
@ -92,7 +92,7 @@ export class AppComponent implements OnDestroy, OnInit {
private biometricStateService: BiometricStateService,
private stateEventRunnerService: StateEventRunnerService,
private paymentMethodWarningService: PaymentMethodWarningService,
private organizationService: OrganizationService,
private organizationService: InternalOrganizationServiceAbstraction,
) {}
ngOnInit() {

View File

@ -150,7 +150,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
async ngOnInit() {
if (this.organizationId) {
this.organization = this.organizationService.get(this.organizationId);
this.organization = await this.organizationService.get(this.organizationId);
this.billing = await this.organizationApiService.getBilling(this.organizationId);
this.sub = await this.organizationApiService.getSubscription(this.organizationId);
}

View File

@ -94,7 +94,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
return;
}
this.loading = true;
this.userOrg = this.organizationService.get(this.organizationId);
this.userOrg = await this.organizationService.get(this.organizationId);
if (this.userOrg.canViewSubscription) {
this.sub = await this.organizationApiService.getSubscription(this.organizationId);
this.lineItems = this.sub?.subscription?.items;

View File

@ -110,7 +110,7 @@ export class OrganizationSubscriptionSelfhostComponent implements OnInit, OnDest
return;
}
this.loading = true;
this.userOrg = this.organizationService.get(this.organizationId);
this.userOrg = await this.organizationService.get(this.organizationId);
if (this.userOrg.canViewSubscription) {
const subscriptionResponse = await this.organizationApiService.getSubscription(
this.organizationId,

View File

@ -139,9 +139,9 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
}
async loadOrg(orgId: string, collectionIds: string[]) {
const organization$ = of(this.organizationService.get(orgId)).pipe(
shareReplay({ refCount: true, bufferSize: 1 }),
);
const organization$ = this.organizationService
.get$(orgId)
.pipe(shareReplay({ refCount: true, bufferSize: 1 }));
const groups$ = organization$.pipe(
switchMap((organization) => {
if (!organization.useGroups) {

View File

@ -518,7 +518,7 @@ export class VaultComponent implements OnInit, OnDestroy {
this.messagingService.send("premiumRequired");
return;
} else if (cipher.organizationId != null) {
const org = this.organizationService.get(cipher.organizationId);
const org = await this.organizationService.get(cipher.organizationId);
if (org != null && (org.maxStorageGb == null || org.maxStorageGb === 0)) {
this.messagingService.send("upgradeOrganization", {
organizationId: cipher.organizationId,
@ -697,7 +697,7 @@ export class VaultComponent implements OnInit, OnDestroy {
}
async deleteCollection(collection: CollectionView): Promise<void> {
const organization = this.organizationService.get(collection.organizationId);
const organization = await this.organizationService.get(collection.organizationId);
if (!collection.canDelete(organization)) {
this.platformUtilsService.showToast(
"error",

View File

@ -16,7 +16,7 @@ export const organizationEnabledGuard: CanActivateFn = async (route: ActivatedRo
await syncService.fullSync(false);
}
const org = orgService.get(route.params.organizationId);
const org = await orgService.get(route.params.organizationId);
if (org == null || !org.canAccessSecretsManager) {
return createUrlTreeFromSnapshot(route, ["/"]);
}

View File

@ -14,7 +14,7 @@ export class NavigationComponent {
protected readonly logo = SecretsManagerLogo;
protected orgFilter = (org: Organization) => org.canAccessSecretsManager;
protected isAdmin$ = this.route.params.pipe(
map((params) => this.organizationService.get(params.organizationId)?.isAdmin),
map(async (params) => (await this.organizationService.get(params.organizationId))?.isAdmin),
);
constructor(

View File

@ -12,6 +12,7 @@ import {
take,
share,
firstValueFrom,
concatMap,
} from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
@ -105,7 +106,7 @@ export class OverviewComponent implements OnInit, OnDestroy {
orgId$
.pipe(
map((orgId) => this.organizationService.get(orgId)),
concatMap(async (orgId) => await this.organizationService.get(orgId)),
takeUntil(this.destroy$),
)
.subscribe((org) => {

View File

@ -63,7 +63,9 @@ export class ProjectSecretsComponent {
switchMap(async ([_, params]) => {
this.organizationId = params.organizationId;
this.projectId = params.projectId;
this.organizationEnabled = this.organizationService.get(params.organizationId)?.enabled;
this.organizationEnabled = (
await this.organizationService.get(params.organizationId)
)?.enabled;
return await this.getSecretsByProject();
}),
);

View File

@ -10,6 +10,8 @@ import {
Subject,
switchMap,
takeUntil,
map,
concatMap,
} from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
@ -70,11 +72,18 @@ export class ProjectComponent implements OnInit, OnDestroy {
}),
);
this.route.params.pipe(takeUntil(this.destroy$)).subscribe((params) => {
this.organizationId = params.organizationId;
this.projectId = params.projectId;
this.organizationEnabled = this.organizationService.get(params.organizationId)?.enabled;
});
const projectId$ = this.route.params.pipe(map((p) => p.projectId));
const organization$ = this.route.params.pipe(
concatMap((params) => this.organizationService.get$(params.organizationId)),
);
combineLatest([projectId$, organization$])
.pipe(takeUntil(this.destroy$))
.subscribe(([projectId, organization]) => {
this.organizationId = organization.id;
this.projectId = projectId;
this.organizationEnabled = organization.enabled;
});
}
ngOnDestroy(): void {

View File

@ -51,7 +51,9 @@ export class ProjectsComponent implements OnInit {
]).pipe(
switchMap(async ([params]) => {
this.organizationId = params.organizationId;
this.organizationEnabled = this.organizationService.get(params.organizationId)?.enabled;
this.organizationEnabled = (
await this.organizationService.get(params.organizationId)
)?.enabled;
return await this.getProjects();
}),

View File

@ -87,7 +87,7 @@ export class SecretDialogComponent implements OnInit {
this.formGroup.get("project").setValue(this.data.projectId);
}
if (this.organizationService.get(this.data.organizationId)?.isAdmin) {
if ((await this.organizationService.get(this.data.organizationId))?.isAdmin) {
this.formGroup.get("project").removeValidators(Validators.required);
this.formGroup.get("project").updateValueAndValidity();
}

View File

@ -47,7 +47,9 @@ export class SecretsComponent implements OnInit {
combineLatestWith(this.route.params),
switchMap(async ([_, params]) => {
this.organizationId = params.organizationId;
this.organizationEnabled = this.organizationService.get(params.organizationId)?.enabled;
this.organizationEnabled = (
await this.organizationService.get(params.organizationId)
)?.enabled;
return await this.getSecrets();
}),

View File

@ -46,7 +46,9 @@ export class ServiceAccountsComponent implements OnInit {
]).pipe(
switchMap(async ([params]) => {
this.organizationId = params.organizationId;
this.organizationEnabled = this.organizationService.get(params.organizationId)?.enabled;
this.organizationEnabled = (
await this.organizationService.get(params.organizationId)
)?.enabled;
return await this.getServiceAccounts();
}),

View File

@ -26,7 +26,7 @@ describe("AccessPolicySelectorService", () => {
describe("showAccessRemovalWarning", () => {
it("returns false when current user is admin", async () => {
const org = orgFactory();
organizationService.get.calledWith(org.id).mockReturnValue(org);
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const selectedPolicyValues: ApItemValueType[] = [];
@ -38,7 +38,7 @@ describe("AccessPolicySelectorService", () => {
it("returns false when current user is owner", async () => {
const org = orgFactory();
org.type = OrganizationUserType.Owner;
organizationService.get.calledWith(org.id).mockReturnValue(org);
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const selectedPolicyValues: ApItemValueType[] = [];
@ -49,7 +49,7 @@ describe("AccessPolicySelectorService", () => {
it("returns true when current user isn't owner/admin and all policies are removed", async () => {
const org = setupUserOrg();
organizationService.get.calledWith(org.id).mockReturnValue(org);
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const selectedPolicyValues: ApItemValueType[] = [];
@ -60,7 +60,7 @@ describe("AccessPolicySelectorService", () => {
it("returns true when current user isn't owner/admin and user policy is set to canRead", async () => {
const org = setupUserOrg();
organizationService.get.calledWith(org.id).mockReturnValue(org);
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const selectedPolicyValues: ApItemValueType[] = [];
selectedPolicyValues.push(
@ -77,7 +77,7 @@ describe("AccessPolicySelectorService", () => {
it("returns false when current user isn't owner/admin and user policy is set to canReadWrite", async () => {
const org = setupUserOrg();
organizationService.get.calledWith(org.id).mockReturnValue(org);
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const selectedPolicyValues: ApItemValueType[] = [
createApItemValueType({
@ -93,7 +93,7 @@ describe("AccessPolicySelectorService", () => {
it("returns true when current user isn't owner/admin and a group Read policy is submitted that the user is a member of", async () => {
const org = setupUserOrg();
organizationService.get.calledWith(org.id).mockReturnValue(org);
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const selectedPolicyValues: ApItemValueType[] = [
createApItemValueType({
@ -111,7 +111,7 @@ describe("AccessPolicySelectorService", () => {
it("returns false when current user isn't owner/admin and a group ReadWrite policy is submitted that the user is a member of", async () => {
const org = setupUserOrg();
organizationService.get.calledWith(org.id).mockReturnValue(org);
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const selectedPolicyValues: ApItemValueType[] = [
createApItemValueType({
@ -129,7 +129,7 @@ describe("AccessPolicySelectorService", () => {
it("returns true when current user isn't owner/admin and a group ReadWrite policy is submitted that the user is not a member of", async () => {
const org = setupUserOrg();
organizationService.get.calledWith(org.id).mockReturnValue(org);
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const selectedPolicyValues: ApItemValueType[] = [
createApItemValueType({
@ -147,7 +147,7 @@ describe("AccessPolicySelectorService", () => {
it("returns false when current user isn't owner/admin, user policy is set to CanRead, and user is in read write group", async () => {
const org = setupUserOrg();
organizationService.get.calledWith(org.id).mockReturnValue(org);
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const selectedPolicyValues: ApItemValueType[] = [
createApItemValueType({
@ -169,7 +169,7 @@ describe("AccessPolicySelectorService", () => {
it("returns true when current user isn't owner/admin, user policy is set to CanRead, and user is not in ReadWrite group", async () => {
const org = setupUserOrg();
organizationService.get.calledWith(org.id).mockReturnValue(org);
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const selectedPolicyValues: ApItemValueType[] = [
createApItemValueType({
@ -191,7 +191,7 @@ describe("AccessPolicySelectorService", () => {
it("returns true when current user isn't owner/admin, user policy is set to CanRead, and user is in Read group", async () => {
const org = setupUserOrg();
organizationService.get.calledWith(org.id).mockReturnValue(org);
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const selectedPolicyValues: ApItemValueType[] = [
createApItemValueType({

View File

@ -17,7 +17,7 @@ export class AccessPolicySelectorService {
organizationId: string,
selectedPoliciesValues: ApItemValueType[],
): Promise<boolean> {
const organization = this.organizationService.get(organizationId);
const organization = await this.organizationService.get(organizationId);
if (organization.isOwner || organization.isAdmin) {
return false;
}

View File

@ -1,6 +1,6 @@
import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { Subject, takeUntil } from "rxjs";
import { Subject, takeUntil, concatMap, map } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { DialogService } from "@bitwarden/components";
@ -34,10 +34,19 @@ export class NewMenuComponent implements OnInit, OnDestroy {
) {}
ngOnInit() {
this.route.params.pipe(takeUntil(this.destroy$)).subscribe((params: any) => {
this.organizationId = params.organizationId;
this.organizationEnabled = this.organizationService.get(params.organizationId)?.enabled;
});
this.route.params
.pipe(
concatMap((params) =>
this.organizationService
.get$(params.organizationId)
.pipe(map((organization) => ({ params, organization }))),
),
takeUntil(this.destroy$),
)
.subscribe((mapResult) => {
this.organizationId = mapResult?.params?.organizationId;
this.organizationEnabled = mapResult?.organization?.enabled;
});
}
ngOnDestroy(): void {

View File

@ -1,6 +1,6 @@
import { Component } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { map } from "rxjs";
import { map, concatMap } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Icon, Icons } from "@bitwarden/components";
@ -16,6 +16,7 @@ export class OrgSuspendedComponent {
protected NoAccess: Icon = Icons.NoAccess;
protected organizationName$ = this.route.params.pipe(
map((params) => this.organizationService.get(params.organizationId)?.name),
concatMap((params) => this.organizationService.get$(params.organizationId)),
map((org) => org?.name),
);
}

View File

@ -778,7 +778,7 @@ const typesafeProviders: Array<SafeProvider> = [
safeProvider({
provide: InternalOrganizationServiceAbstraction,
useClass: OrganizationService,
deps: [StateServiceAbstraction, StateProvider],
deps: [StateProvider],
}),
safeProvider({
provide: OrganizationServiceAbstraction,

View File

@ -34,7 +34,7 @@ export class ExportScopeCalloutComponent implements OnInit {
) {}
async ngOnInit(): Promise<void> {
if (!this.organizationService.hasOrganizations()) {
if (!(await this.organizationService.hasOrganizations())) {
return;
}
@ -48,7 +48,7 @@ export class ExportScopeCalloutComponent implements OnInit {
? {
title: "exportingOrganizationVaultTitle",
description: "exportingOrganizationVaultDesc",
scopeIdentifier: this.organizationService.get(organizationId).name,
scopeIdentifier: (await this.organizationService.get(organizationId)).name,
}
: {
title: "exportingPersonalVaultTitle",

View File

@ -2,6 +2,7 @@ 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";
@ -86,34 +87,67 @@ export function canAccessImport(i18nService: I18nService) {
/**
* Returns `true` if a user is a member of an organization (rather than only being a ProviderUser)
* @deprecated Use organizationService.memberOrganizations$ instead
* @deprecated Use organizationService.organizations$ with a filter instead
*/
export function isMember(org: Organization): boolean {
return org.isMember;
}
/**
* 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 OrganizationService {
/**
* Publishes state for all organizations under the active user.
* @returns An observable list of organizations
*/
organizations$: Observable<Organization[]>;
/**
* Organizations that the user is a member of (excludes organizations that they only have access to via a provider)
*/
// @todo Clean these up. Continuing to expand them is not recommended.
// @see https://bitwarden.atlassian.net/browse/AC-2252
memberOrganizations$: Observable<Organization[]>;
get$: (id: string) => Observable<Organization | undefined>;
get: (id: string) => Organization;
getByIdentifier: (identifier: string) => Organization;
getAll: (userId?: string) => Promise<Organization[]>;
/**
* @deprecated For the CLI only
* @param id id of the organization
* @deprecated This is currently only used in the CLI, and should not be
* used in any new calls. Use get$ instead for the time being, and we'll be
* removing this method soon. See Jira for details:
* https://bitwarden.atlassian.net/browse/AC-2252.
*/
getFromState: (id: string) => Promise<Organization>;
canManageSponsorships: () => Promise<boolean>;
hasOrganizations: () => boolean;
hasOrganizations: () => Promise<boolean>;
get$: (id: string) => Observable<Organization | undefined>;
get: (id: string) => Promise<Organization>;
getAll: (userId?: string) => Promise<Organization[]>;
//
}
/**
* 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 InternalOrganizationServiceAbstraction extends OrganizationService {
replace: (organizations: { [id: string]: OrganizationData }) => Promise<void>;
upsert: (OrganizationData: OrganizationData | OrganizationData[]) => Promise<void>;
/**
* 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. Defaults to the active
* user.
*/
upsert: (OrganizationData: OrganizationData) => Promise<void>;
/**
* Replaces state for the entire registered organization list for the active 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 active
* user.
* @param userId The userId to replace state for. Defaults to the active
* user.
*/
replace: (organizations: { [id: string]: OrganizationData }, userId?: UserId) => Promise<void>;
}

View File

@ -320,6 +320,10 @@ export class Organization {
return !this.useTotp;
}
get canManageSponsorships() {
return this.familySponsorshipAvailable || this.familySponsorshipFriendlyName !== null;
}
static fromJSON(json: Jsonify<Organization>) {
if (json == null) {
return null;

View File

@ -1,114 +1,142 @@
import { MockProxy, mock, any, mockClear } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { firstValueFrom } from "rxjs";
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
import { FakeActiveUserState } from "../../../../spec/fake-state";
import { StateService } from "../../../platform/abstractions/state.service";
import { Utils } from "../../../platform/misc/utils";
import { UserId } from "../../../types/guid";
import { OrganizationId, UserId } from "../../../types/guid";
import { OrganizationData } from "../../models/data/organization.data";
import { Organization } from "../../models/domain/organization";
import { OrganizationService, ORGANIZATIONS } from "./organization.service";
describe("Organization Service", () => {
describe("OrganizationService", () => {
let organizationService: OrganizationService;
let stateService: MockProxy<StateService>;
let activeAccount: BehaviorSubject<string>;
let activeAccountUnlocked: BehaviorSubject<boolean>;
const fakeUserId = Utils.newGuid() as UserId;
let fakeAccountService: FakeAccountService;
let fakeStateProvider: FakeStateProvider;
let fakeActiveUserState: FakeActiveUserState<Record<string, OrganizationData>>;
const mockUserId = Utils.newGuid() as UserId;
let accountService: FakeAccountService;
let stateProvider: FakeStateProvider;
let activeUserOrganizationsState: FakeActiveUserState<Record<string, OrganizationData>>;
const resetStateService = async (
customizeStateService: (stateService: MockProxy<StateService>) => void,
) => {
mockClear(stateService);
stateService = mock<StateService>();
stateService.activeAccount$ = activeAccount;
stateService.activeAccountUnlocked$ = activeAccountUnlocked;
customizeStateService(stateService);
organizationService = new OrganizationService(stateService, stateProvider);
await new Promise((r) => setTimeout(r, 50));
};
function prepareStateProvider(): void {
accountService = mockAccountServiceWith(mockUserId);
stateProvider = new FakeStateProvider(accountService);
/**
* 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<OrganizationId, OrganizationData> {
if (input == null) {
return undefined;
}
return Object.fromEntries(input?.map((i) => [i.id, i]));
}
function seedTestData(): void {
activeUserOrganizationsState = stateProvider.activeUser.getFake(ORGANIZATIONS);
activeUserOrganizationsState.nextState({ "1": organizationData("1", "Test Org") });
/**
* 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);
}
beforeEach(() => {
activeAccount = new BehaviorSubject("123");
activeAccountUnlocked = new BehaviorSubject(true);
/**
* 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;
}
stateService = mock<StateService>();
stateService.activeAccount$ = activeAccount;
stateService.activeAccountUnlocked$ = activeAccountUnlocked;
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;
stateService.getOrganizations.calledWith(any()).mockResolvedValue({
"1": organizationData("1", "Test Org"),
});
return data;
}
prepareStateProvider();
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),
);
}
organizationService = new OrganizationService(stateService, stateProvider);
return mockOrganizations;
}
seedTestData();
});
/**
* `OrganizationService` deals with multiple accounts at times. This helper
* function can be used to add a new non-active account to the test data.
* This function is **not** needed to handle creation of the first account,
* as that is handled by the `FakeAccountService` in `mockAccountServiceWith()`
* @returns The `UserId` of the newly created state account and the mock data
* created for them as an `Organization[]`.
*/
async function addNonActiveAccountToStateProvider(): Promise<[UserId, OrganizationData[]]> {
const nonActiveUserId = Utils.newGuid() as UserId;
afterEach(() => {
activeAccount.complete();
activeAccountUnlocked.complete();
const mockOrganizations = buildMockOrganizations(10);
const fakeNonActiveUserState = fakeStateProvider.singleUser.getFake(
nonActiveUserId,
ORGANIZATIONS,
);
fakeNonActiveUserState.nextState(arrayToRecord(mockOrganizations));
return [nonActiveUserId, mockOrganizations];
}
beforeEach(async () => {
fakeAccountService = mockAccountServiceWith(fakeUserId);
fakeStateProvider = new FakeStateProvider(fakeAccountService);
fakeActiveUserState = fakeStateProvider.activeUser.getFake(ORGANIZATIONS);
organizationService = new OrganizationService(fakeStateProvider);
});
it("getAll", async () => {
const mockData: OrganizationData[] = buildMockOrganizations(1);
fakeActiveUserState.nextState(arrayToRecord(mockData));
const orgs = await organizationService.getAll();
expect(orgs).toHaveLength(1);
const org = orgs[0];
expect(org).toEqual({
id: "1",
name: "Test Org",
identifier: "test",
});
expect(org).toEqual(new Organization(mockData[0]));
});
describe("canManageSponsorships", () => {
it("can because one is available", async () => {
await resetStateService((stateService) => {
stateService.getOrganizations.mockResolvedValue({
"1": { ...organizationData("1", "Org"), familySponsorshipAvailable: true },
});
});
const mockData: OrganizationData[] = buildMockOrganizations(1);
mockData[0].familySponsorshipAvailable = true;
fakeActiveUserState.nextState(arrayToRecord(mockData));
const result = await organizationService.canManageSponsorships();
expect(result).toBe(true);
});
it("can because one is used", async () => {
await resetStateService((stateService) => {
stateService.getOrganizations.mockResolvedValue({
"1": { ...organizationData("1", "Test Org"), familySponsorshipFriendlyName: "Something" },
});
});
const mockData: OrganizationData[] = buildMockOrganizations(1);
mockData[0].familySponsorshipFriendlyName = "Something";
fakeActiveUserState.nextState(arrayToRecord(mockData));
const result = await organizationService.canManageSponsorships();
expect(result).toBe(true);
});
it("can not because one isn't available or taken", async () => {
await resetStateService((stateService) => {
stateService.getOrganizations.mockResolvedValue({
"1": { ...organizationData("1", "Org"), familySponsorshipFriendlyName: null },
});
});
const mockData: OrganizationData[] = buildMockOrganizations(1);
mockData[0].familySponsorshipFriendlyName = null;
fakeActiveUserState.nextState(arrayToRecord(mockData));
const result = await organizationService.canManageSponsorships();
expect(result).toBe(false);
});
@ -116,81 +144,181 @@ describe("Organization Service", () => {
describe("get", () => {
it("exists", async () => {
const result = organizationService.get("1");
expect(result).toEqual({
id: "1",
name: "Test Org",
identifier: "test",
});
const mockData = buildMockOrganizations(1);
fakeActiveUserState.nextState(arrayToRecord(mockData));
const result = await organizationService.get(mockData[0].id);
expect(result).toEqual(new Organization(mockData[0]));
});
it("does not exist", async () => {
const result = organizationService.get("2");
const result = await organizationService.get("this-org-does-not-exist");
expect(result).toBe(undefined);
});
});
it("upsert", async () => {
await organizationService.upsert(organizationData("2", "Test 2"));
describe("organizations$", () => {
describe("null checking behavior", () => {
it("publishes an empty array if organizations in state = undefined", async () => {
const mockData: OrganizationData[] = undefined;
fakeActiveUserState.nextState(arrayToRecord(mockData));
const result = await firstValueFrom(organizationService.organizations$);
expect(result).toEqual([]);
});
expect(await firstValueFrom(organizationService.organizations$)).toEqual([
{
id: "1",
name: "Test Org",
identifier: "test",
},
{
id: "2",
name: "Test 2",
identifier: "test",
},
]);
});
it("publishes an empty array if organizations in state = null", async () => {
const mockData: OrganizationData[] = null;
fakeActiveUserState.nextState(arrayToRecord(mockData));
const result = await firstValueFrom(organizationService.organizations$);
expect(result).toEqual([]);
});
describe("getByIdentifier", () => {
it("exists", async () => {
const result = organizationService.getByIdentifier("test");
expect(result).toEqual({
id: "1",
name: "Test Org",
identifier: "test",
it("publishes an empty array if organizations in state = []", async () => {
const mockData: OrganizationData[] = [];
fakeActiveUserState.nextState(arrayToRecord(mockData));
const result = await firstValueFrom(organizationService.organizations$);
expect(result).toEqual([]);
});
});
it("does not exist", async () => {
const result = organizationService.getByIdentifier("blah");
describe("parameter handling & returns", () => {
it("publishes all organizations for the active user by default", async () => {
const mockData = buildMockOrganizations(10);
fakeActiveUserState.nextState(arrayToRecord(mockData));
const result = await firstValueFrom(organizationService.organizations$);
expect(result).toEqual(mockData);
});
expect(result).toBeUndefined();
it("can be used to publish the organizations of a non active user if requested", async () => {
const activeUserMockData = buildMockOrganizations(10, "activeUserState");
fakeActiveUserState.nextState(arrayToRecord(activeUserMockData));
const [nonActiveUserId, nonActiveUserMockOrganizations] =
await addNonActiveAccountToStateProvider();
// This can be updated to use
// `firstValueFrom(organizations$(nonActiveUserId)` once all the
// promise based methods are removed from `OrganizationService` and the
// main observable is refactored to accept a userId
const result = await organizationService.getAll(nonActiveUserId);
expect(result).toEqual(nonActiveUserMockOrganizations);
expect(result).not.toEqual(await firstValueFrom(organizationService.organizations$));
});
});
});
describe("delete", () => {
it("exists", async () => {
await organizationService.delete("1");
expect(stateService.getOrganizations).toHaveBeenCalledTimes(2);
expect(stateService.setOrganizations).toHaveBeenCalledTimes(1);
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]);
const result = await firstValueFrom(organizationService.organizations$);
expect(result).toEqual(mockData.map((x) => new Organization(x)));
});
it("does not exist", async () => {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
organizationService.delete("1");
it("updates an organization that already exists in state, defaulting to the active user", async () => {
const mockData = buildMockOrganizations(10);
fakeActiveUserState.nextState(arrayToRecord(mockData));
const indexToUpdate = 5;
const anUpdatedOrganization = {
...buildMockOrganizations(1, "UPDATED").pop(),
id: mockData[indexToUpdate].id,
};
await organizationService.upsert(anUpdatedOrganization);
const result = await firstValueFrom(organizationService.organizations$);
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,
);
});
expect(stateService.getOrganizations).toHaveBeenCalledTimes(2);
it("can also update an organization in state for a non-active user, if requested", async () => {
const activeUserMockData = buildMockOrganizations(10, "activeUserOrganizations");
fakeActiveUserState.nextState(arrayToRecord(activeUserMockData));
const [nonActiveUserId, nonActiveUserMockOrganizations] =
await addNonActiveAccountToStateProvider();
const indexToUpdate = 5;
const anUpdatedOrganization = {
...buildMockOrganizations(1, "UPDATED").pop(),
id: nonActiveUserMockOrganizations[indexToUpdate].id,
};
await organizationService.upsert(anUpdatedOrganization, nonActiveUserId);
// This can be updated to use
// `firstValueFrom(organizations$(nonActiveUserId)` once all the
// promise based methods are removed from `OrganizationService` and the
// main observable is refactored to accept a userId
const result = await organizationService.getAll(nonActiveUserId);
expect(result[indexToUpdate]).not.toEqual(
new Organization(nonActiveUserMockOrganizations[indexToUpdate]),
);
expect(result[indexToUpdate].id).toEqual(
new Organization(nonActiveUserMockOrganizations[indexToUpdate]).id,
);
expectIsEqualExceptForIndex(
result,
nonActiveUserMockOrganizations.map((x) => new Organization(x)),
indexToUpdate,
);
// Just to be safe, lets make sure the active user didn't get updated
// at all
const activeUserState = await firstValueFrom(organizationService.organizations$);
expect(activeUserState).toEqual(activeUserMockData.map((x) => new Organization(x)));
expect(activeUserState).not.toEqual(result);
});
});
function organizationData(id: string, name: string) {
const data = new OrganizationData({} as any, {} as any);
data.id = id;
data.name = name;
data.identifier = "test";
describe("replace()", () => {
it("replaces the entire organization list in state, defaulting to the active user", async () => {
const originalData = buildMockOrganizations(10);
fakeActiveUserState.nextState(arrayToRecord(originalData));
return data;
}
const newData = buildMockOrganizations(10, "newData");
await organizationService.replace(arrayToRecord(newData));
const result = await firstValueFrom(organizationService.organizations$);
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);
fakeActiveUserState.nextState(arrayToRecord(originalData));
await organizationService.replace(null);
const result = await firstValueFrom(organizationService.organizations$);
expect(result).toEqual([]);
expect(result).not.toEqual(originalData);
});
it("can also replace state for a non-active user, if requested", async () => {
const activeUserMockData = buildMockOrganizations(10, "activeUserOrganizations");
fakeActiveUserState.nextState(arrayToRecord(activeUserMockData));
const [nonActiveUserId, originalOrganizations] = await addNonActiveAccountToStateProvider();
const newData = buildMockOrganizations(10, "newData");
await organizationService.replace(arrayToRecord(newData), nonActiveUserId);
// This can be updated to use
// `firstValueFrom(organizations$(nonActiveUserId)` once all the
// promise based methods are removed from `OrganizationService` and the
// main observable is refactored to accept a userId
const result = await organizationService.getAll(nonActiveUserId);
expect(result).toEqual(newData);
expect(result).not.toEqual(originalOrganizations);
// Just to be safe, lets make sure the active user didn't get updated
// at all
const activeUserState = await firstValueFrom(organizationService.organizations$);
expect(activeUserState).toEqual(activeUserMockData.map((x) => new Organization(x)));
expect(activeUserState).not.toEqual(result);
});
});
});

View File

@ -1,111 +1,105 @@
import { BehaviorSubject, concatMap, map, Observable } from "rxjs";
import { map, Observable, firstValueFrom } from "rxjs";
import { Jsonify } from "type-fest";
import { StateService } from "../../../platform/abstractions/state.service";
import { KeyDefinition, ORGANIZATIONS_DISK, StateProvider } from "../../../platform/state";
import {
InternalOrganizationServiceAbstraction,
isMember,
} from "../../abstractions/organization/organization.service.abstraction";
import { ORGANIZATIONS_DISK, StateProvider, UserKeyDefinition } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { InternalOrganizationServiceAbstraction } from "../../abstractions/organization/organization.service.abstraction";
import { OrganizationData } from "../../models/data/organization.data";
import { Organization } from "../../models/domain/organization";
export const ORGANIZATIONS = KeyDefinition.record<OrganizationData>(
/**
* 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<OrganizationData>(
ORGANIZATIONS_DISK,
"organizations",
{
deserializer: (obj: Jsonify<OrganizationData>) => OrganizationData.fromJSON(obj),
clearOn: ["logout"],
},
);
/**
* 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<Organization[]>` pipes,
* like `organizationService.organizations$`
*/
function mapToExcludeOrganizationsWithoutFamilySponsorshipSupport() {
return map<Organization[], Organization[]>((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<Organization[]>` pipes,
* like `organizationService.organizations$`
*/
function mapToExcludeProviderOrganizations() {
return map<Organization[], Organization[]>((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<Organization[]>` pipes,
* like `organizationService.organizations$`
*/
function mapToBooleanHasAnyOrganizations() {
return map<Organization[], boolean>((orgs) => orgs.length > 0);
}
/**
* Map an observable stream of organizations down to a single organization.
* @param `organizationId` The ID of the organization you'd like to subscribe to
* @returns a function that can be used in `Observable<Organization[]>` pipes,
* like `organizationService.organizations$`
*/
function mapToSingleOrganization(organizationId: string) {
return map<Organization[], Organization>((orgs) => orgs?.find((o) => o.id === organizationId));
}
export class OrganizationService implements InternalOrganizationServiceAbstraction {
// marked for removal during AC-2009
protected _organizations = new BehaviorSubject<Organization[]>([]);
// marked for removal during AC-2009
organizations$ = this._organizations.asObservable();
// marked for removal during AC-2009
memberOrganizations$ = this.organizations$.pipe(map((orgs) => orgs.filter(isMember)));
organizations$ = this.getOrganizationsFromState$();
memberOrganizations$ = this.organizations$.pipe(mapToExcludeProviderOrganizations());
activeUserOrganizations$: Observable<Organization[]>;
activeUserMemberOrganizations$: Observable<Organization[]>;
constructor(
private stateService: StateService,
private stateProvider: StateProvider,
) {
this.activeUserOrganizations$ = this.stateProvider
.getActive(ORGANIZATIONS)
.state$.pipe(map((data) => Object.values(data).map((o) => new Organization(o))));
this.activeUserMemberOrganizations$ = this.activeUserOrganizations$.pipe(
map((orgs) => orgs.filter(isMember)),
);
this.stateService.activeAccountUnlocked$
.pipe(
concatMap(async (unlocked) => {
if (!unlocked) {
this._organizations.next([]);
return;
}
const data = await this.stateService.getOrganizations();
this.updateObservables(data);
}),
)
.subscribe();
}
constructor(private stateProvider: StateProvider) {}
get$(id: string): Observable<Organization | undefined> {
return this.organizations$.pipe(map((orgs) => orgs.find((o) => o.id === id)));
return this.organizations$.pipe(mapToSingleOrganization(id));
}
async getAll(userId?: string): Promise<Organization[]> {
const organizationsMap = await this.stateService.getOrganizations({ userId: userId });
return Object.values(organizationsMap || {}).map((o) => new Organization(o));
return await firstValueFrom(this.getOrganizationsFromState$(userId as UserId));
}
async canManageSponsorships(): Promise<boolean> {
const organizations = this._organizations.getValue();
return organizations.some(
(o) => o.familySponsorshipAvailable || o.familySponsorshipFriendlyName !== null,
return await firstValueFrom(
this.organizations$.pipe(
mapToExcludeOrganizationsWithoutFamilySponsorshipSupport(),
mapToBooleanHasAnyOrganizations(),
),
);
}
hasOrganizations(): boolean {
const organizations = this._organizations.getValue();
return organizations.length > 0;
async hasOrganizations(): Promise<boolean> {
return await firstValueFrom(this.organizations$.pipe(mapToBooleanHasAnyOrganizations()));
}
async upsert(organization: OrganizationData): Promise<void> {
let organizations = await this.stateService.getOrganizations();
if (organizations == null) {
organizations = {};
}
organizations[organization.id] = organization;
await this.replace(organizations);
async upsert(organization: OrganizationData, userId?: UserId): Promise<void> {
await this.stateFor(userId).update((existingOrganizations) => {
const organizations = existingOrganizations ?? {};
organizations[organization.id] = organization;
return organizations;
});
}
async delete(id: string): Promise<void> {
const organizations = await this.stateService.getOrganizations();
if (organizations == null) {
return;
}
if (organizations[id] == null) {
return;
}
delete organizations[id];
await this.replace(organizations);
}
get(id: string): Organization {
const organizations = this._organizations.getValue();
return organizations.find((organization) => organization.id === id);
async get(id: string): Promise<Organization> {
return await firstValueFrom(this.organizations$.pipe(mapToSingleOrganization(id)));
}
/**
@ -113,28 +107,46 @@ export class OrganizationService implements InternalOrganizationServiceAbstracti
* @param id id of the organization
*/
async getFromState(id: string): Promise<Organization> {
const organizationsMap = await this.stateService.getOrganizations();
const organization = organizationsMap[id];
if (organization == null) {
return null;
}
return new Organization(organization);
return await firstValueFrom(this.organizations$.pipe(mapToSingleOrganization(id)));
}
getByIdentifier(identifier: string): Organization {
const organizations = this._organizations.getValue();
return organizations.find((organization) => organization.identifier === identifier);
async replace(organizations: { [id: string]: OrganizationData }, userId?: UserId): Promise<void> {
await this.stateFor(userId).update(() => organizations);
}
async replace(organizations: { [id: string]: OrganizationData }) {
await this.stateService.setOrganizations(organizations);
this.updateObservables(organizations);
// Ideally this method would be renamed to organizations$() and the
// $organizations observable as it stands would be removed. This will
// require updates to callers, and so this method exists as a temporary
// workaround until we have time & a plan to update callers.
//
// It can be thought of as "organizations$ but with a userId option".
private getOrganizationsFromState$(userId?: UserId): Observable<Organization[] | undefined> {
return this.stateFor(userId).state$.pipe(this.mapOrganizationRecordToArray());
}
private updateObservables(organizationsMap: { [id: string]: OrganizationData }) {
const organizations = Object.values(organizationsMap || {}).map((o) => new Organization(o));
this._organizations.next(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<Record<string, OrganizationData>, Organization[]>((orgs) =>
Object.values(orgs ?? {})?.map((o) => new Organization(o)),
);
}
/**
* Fetches the organization list from on disk state for the specified user.
* @param userId the user ID to fetch the organization list for. Defaults to
* the currently active user.
* @returns an observable of organization state as it is stored on disk.
*/
private stateFor(userId?: UserId) {
return userId
? this.stateProvider.getUser(userId, ORGANIZATIONS)
: this.stateProvider.getActive(ORGANIZATIONS);
}
}

View File

@ -1,6 +1,5 @@
import { Observable } from "rxjs";
import { OrganizationData } from "../../admin-console/models/data/organization.data";
import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-req-storable";
import { ForceSetPasswordReason } from "../../auth/models/domain/force-set-password-reason";
import { KdfConfig } from "../../auth/models/domain/kdf-config";
@ -291,17 +290,6 @@ export abstract class StateService<T extends Account = Account> {
setOpenAtLogin: (value: boolean, options?: StorageOptions) => Promise<void>;
getOrganizationInvitation: (options?: StorageOptions) => Promise<any>;
setOrganizationInvitation: (value: any, options?: StorageOptions) => Promise<void>;
/**
* @deprecated Do not call this directly, use OrganizationService
*/
getOrganizations: (options?: StorageOptions) => Promise<{ [id: string]: OrganizationData }>;
/**
* @deprecated Do not call this directly, use OrganizationService
*/
setOrganizations: (
value: { [id: string]: OrganizationData },
options?: StorageOptions,
) => Promise<void>;
getPasswordGenerationOptions: (options?: StorageOptions) => Promise<PasswordGeneratorOptions>;
setPasswordGenerationOptions: (
value: PasswordGeneratorOptions,

View File

@ -1,6 +1,5 @@
import { Jsonify } from "type-fest";
import { OrganizationData } from "../../../admin-console/models/data/organization.data";
import { AdminAuthRequestStorable } from "../../../auth/models/domain/admin-auth-req-storable";
import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason";
import { KeyConnectorUserDecryptionOption } from "../../../auth/models/domain/user-decryption-options/key-connector-user-decryption-option";
@ -91,7 +90,6 @@ export class AccountData {
> = new EncryptionPair<GeneratedPasswordHistory[], GeneratedPasswordHistory[]>();
addEditCipherInfo?: AddEditCipherInfo;
eventCollection?: EventData[];
organizations?: { [id: string]: OrganizationData };
static fromJSON(obj: DeepJsonify<AccountData>): AccountData {
if (obj == null) {

View File

@ -1,7 +1,6 @@
import { BehaviorSubject, Observable, map } from "rxjs";
import { Jsonify, JsonValue } from "type-fest";
import { OrganizationData } from "../../admin-console/models/data/organization.data";
import { AccountService } from "../../auth/abstractions/account.service";
import { TokenService } from "../../auth/abstractions/token.service";
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
@ -1474,32 +1473,6 @@ export class StateService<
);
}
/**
* @deprecated Do not call this directly, use OrganizationService
*/
async getOrganizations(options?: StorageOptions): Promise<{ [id: string]: OrganizationData }> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
)?.data?.organizations;
}
/**
* @deprecated Do not call this directly, use OrganizationService
*/
async setOrganizations(
value: { [id: string]: OrganizationData },
options?: StorageOptions,
): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
account.data.organizations = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
}
async getPasswordGenerationOptions(options?: StorageOptions): Promise<PasswordGeneratorOptions> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()))

View File

@ -35,6 +35,7 @@ import { AvatarColorMigrator } from "./migrations/37-move-avatar-color-to-state-
import { TokenServiceStateProviderMigrator } from "./migrations/38-migrate-token-svc-to-state-provider";
import { MoveBillingAccountProfileMigrator } from "./migrations/39-move-billing-account-profile-to-state-providers";
import { RemoveEverBeenUnlockedMigrator } from "./migrations/4-remove-ever-been-unlocked";
import { OrganizationMigrator } from "./migrations/40-move-organization-state-to-state-provider";
import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys";
import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key";
import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account";
@ -43,7 +44,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting
import { MinVersionMigrator } from "./migrations/min-version";
export const MIN_VERSION = 3;
export const CURRENT_VERSION = 39;
export const CURRENT_VERSION = 40;
export type MinVersion = typeof MIN_VERSION;
export function createMigrationBuilder() {
@ -84,7 +85,8 @@ export function createMigrationBuilder() {
.with(VaultSettingsKeyMigrator, 35, 36)
.with(AvatarColorMigrator, 36, 37)
.with(TokenServiceStateProviderMigrator, 37, 38)
.with(MoveBillingAccountProfileMigrator, 38, CURRENT_VERSION);
.with(MoveBillingAccountProfileMigrator, 38, 39)
.with(OrganizationMigrator, 39, CURRENT_VERSION);
}
export async function currentVersion(

View File

@ -0,0 +1,183 @@
import { any, MockProxy } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { OrganizationMigrator } from "./40-move-organization-state-to-state-provider";
const testDate = new Date();
function exampleOrganization1() {
return JSON.stringify({
id: "id",
name: "name",
status: 0,
type: 0,
enabled: false,
usePolicies: false,
useGroups: false,
useDirectory: false,
useEvents: false,
useTotp: false,
use2fa: false,
useApi: false,
useSso: false,
useKeyConnector: false,
useScim: false,
useCustomPermissions: false,
useResetPassword: false,
useSecretsManager: false,
usePasswordManager: false,
useActivateAutofillPolicy: false,
selfHost: false,
usersGetPremium: false,
seats: 0,
maxCollections: 0,
ssoBound: false,
identifier: "identifier",
resetPasswordEnrolled: false,
userId: "userId",
hasPublicAndPrivateKeys: false,
providerId: "providerId",
providerName: "providerName",
isProviderUser: false,
isMember: false,
familySponsorshipFriendlyName: "fsfn",
familySponsorshipAvailable: false,
planProductType: 0,
keyConnectorEnabled: false,
keyConnectorUrl: "kcu",
accessSecretsManager: false,
limitCollectionCreationDeletion: false,
allowAdminAccessToAllCollectionItems: false,
flexibleCollections: false,
familySponsorshipLastSyncDate: testDate,
});
}
function exampleJSON() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2"],
"user-1": {
data: {
organizations: {
"organization-id-1": exampleOrganization1(),
"organization-id-2": {
// ...
},
},
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
data: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
function rollbackJSON() {
return {
"user_user-1_organizations_organizations": {
"organization-id-1": exampleOrganization1(),
"organization-id-2": {
// ...
},
},
"user_user-2_organizations_organizations": null as any,
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2"],
"user-1": {
data: {
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
data: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
describe("OrganizationMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: OrganizationMigrator;
const keyDefinitionLike = {
key: "organizations",
stateDefinition: {
name: "organizations",
},
};
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 40);
sut = new OrganizationMigrator(39, 40);
});
it("should remove organizations from all accounts", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("user-1", {
data: {
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
});
});
it("should set organizations value for each account", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("user-1", keyDefinitionLike, {
"organization-id-1": exampleOrganization1(),
"organization-id-2": {
// ...
},
});
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 40);
sut = new OrganizationMigrator(39, 40);
});
it.each(["user-1", "user-2"])("should null out new values", async (userId) => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null);
});
it("should add explicit value back to accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledWith("user-1", {
data: {
organizations: {
"organization-id-1": exampleOrganization1(),
"organization-id-2": {
// ...
},
},
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
});
});
it("should not try to restore values to missing accounts", async () => {
await sut.rollback(helper);
expect(helper.set).not.toHaveBeenCalledWith("user-3", any());
});
});
});

View File

@ -0,0 +1,148 @@
import { Jsonify } from "type-fest";
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
// Local declarations of `OrganizationData` and the types of it's properties.
// Duplicated to remain frozen in time when migration occurs.
enum OrganizationUserStatusType {
Invited = 0,
Accepted = 1,
Confirmed = 2,
Revoked = -1,
}
enum OrganizationUserType {
Owner = 0,
Admin = 1,
User = 2,
Manager = 3,
Custom = 4,
}
type PermissionsApi = {
accessEventLogs: boolean;
accessImportExport: boolean;
accessReports: boolean;
createNewCollections: boolean;
editAnyCollection: boolean;
deleteAnyCollection: boolean;
editAssignedCollections: boolean;
deleteAssignedCollections: boolean;
manageCiphers: boolean;
manageGroups: boolean;
manageSso: boolean;
managePolicies: boolean;
manageUsers: boolean;
manageResetPassword: boolean;
manageScim: boolean;
};
enum ProviderType {
Msp = 0,
Reseller = 1,
}
enum ProductType {
Free = 0,
Families = 1,
Teams = 2,
Enterprise = 3,
TeamsStarter = 4,
}
type OrganizationData = {
id: string;
name: string;
status: OrganizationUserStatusType;
type: OrganizationUserType;
enabled: boolean;
usePolicies: boolean;
useGroups: boolean;
useDirectory: boolean;
useEvents: boolean;
useTotp: boolean;
use2fa: boolean;
useApi: boolean;
useSso: boolean;
useKeyConnector: boolean;
useScim: boolean;
useCustomPermissions: boolean;
useResetPassword: boolean;
useSecretsManager: boolean;
usePasswordManager: boolean;
useActivateAutofillPolicy: boolean;
selfHost: boolean;
usersGetPremium: boolean;
seats: number;
maxCollections: number;
maxStorageGb?: number;
ssoBound: boolean;
identifier: string;
permissions: PermissionsApi;
resetPasswordEnrolled: boolean;
userId: string;
hasPublicAndPrivateKeys: boolean;
providerId: string;
providerName: string;
providerType?: ProviderType;
isProviderUser: boolean;
isMember: boolean;
familySponsorshipFriendlyName: string;
familySponsorshipAvailable: boolean;
planProductType: ProductType;
keyConnectorEnabled: boolean;
keyConnectorUrl: string;
familySponsorshipLastSyncDate?: Date;
familySponsorshipValidUntil?: Date;
familySponsorshipToDelete?: boolean;
accessSecretsManager: boolean;
limitCollectionCreationDeletion: boolean;
allowAdminAccessToAllCollectionItems: boolean;
flexibleCollections: boolean;
};
type ExpectedAccountType = {
data?: {
organizations?: Record<string, Jsonify<OrganizationData>>;
};
};
const USER_ORGANIZATIONS: KeyDefinitionLike = {
key: "organizations",
stateDefinition: {
name: "organizations",
},
};
export class OrganizationMigrator extends Migrator<39, 40> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const value = account?.data?.organizations;
if (value != null) {
await helper.setToUser(userId, USER_ORGANIZATIONS, value);
delete account.data.organizations;
await helper.set(userId, account);
}
}
await Promise.all(accounts.map(({ userId, account }) => migrateAccount(userId, account)));
}
async rollback(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const value = await helper.getFromUser(userId, USER_ORGANIZATIONS);
if (account) {
account.data = Object.assign(account.data ?? {}, {
organizations: value,
});
await helper.set(userId, account);
}
await helper.setToUser(userId, USER_ORGANIZATIONS, null);
}
await Promise.all(accounts.map(({ userId, account }) => rollbackAccount(userId, account)));
}
}

View File

@ -207,7 +207,7 @@ export class ImportComponent implements OnInit, OnDestroy {
this.setImportOptions();
await this.initializeOrganizations();
if (this.organizationId && this.canAccessImportExport(this.organizationId)) {
if (this.organizationId && (await this.canAccessImportExport(this.organizationId))) {
this.handleOrganizationImportInit();
} else {
this.handleImportInit();
@ -359,7 +359,7 @@ export class ImportComponent implements OnInit, OnDestroy {
importContents,
this.organizationId,
this.formGroup.controls.targetSelector.value,
this.canAccessImportExport(this.organizationId) && this._isFromAC,
(await this.canAccessImportExport(this.organizationId)) && this._isFromAC,
);
//No errors, display success message
@ -379,11 +379,11 @@ export class ImportComponent implements OnInit, OnDestroy {
}
}
private canAccessImportExport(organizationId?: string): boolean {
private async canAccessImportExport(organizationId?: string): Promise<boolean> {
if (!organizationId) {
return false;
}
return this.organizationService.get(this.organizationId)?.canAccessImportExport;
return (await this.organizationService.get(this.organizationId))?.canAccessImportExport;
}
getFormatInstructionTitle() {