From 5912292680ad3fc3da5455eab133a774fa1a2727 Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Mon, 3 Nov 2025 09:58:17 -0500 Subject: [PATCH] [PM-26374] Remove emergency access when auto confirm is enabled (#17020) * add router guard * use real values, jsdoc * fix route guard logic, hide UI nav item * fix race condition * refactor guard from org feature to policy * update copy --- .../organizations/guards/org-policy.guard.ts | 70 +++++++++++++++++++ .../app/layouts/user-layout.component.html | 10 +-- .../src/app/layouts/user-layout.component.ts | 32 ++++++++- apps/web/src/app/oss-routing.module.ts | 4 ++ apps/web/src/locales/en/messages.json | 3 + .../organization.service.abstraction.ts | 18 ++++- 6 files changed, 130 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/app/admin-console/organizations/guards/org-policy.guard.ts diff --git a/apps/web/src/app/admin-console/organizations/guards/org-policy.guard.ts b/apps/web/src/app/admin-console/organizations/guards/org-policy.guard.ts new file mode 100644 index 0000000000..5964601fbe --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/guards/org-policy.guard.ts @@ -0,0 +1,70 @@ +import { inject } from "@angular/core"; +import { CanActivateFn, Router } from "@angular/router"; +import { firstValueFrom, Observable, switchMap, tap } from "rxjs"; + +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SyncService } from "@bitwarden/common/platform/sync"; +import { ToastService } from "@bitwarden/components"; +import { UserId } from "@bitwarden/user-core"; + +/** + * This guard is intended to prevent members of an organization from accessing + * routes based on compliance with organization + * policies. e.g Emergency access, which is a non-organization + * feature is restricted by the Auto Confirm policy. + */ +export function organizationPolicyGuard( + featureCallback: ( + userId: UserId, + configService: ConfigService, + policyService: PolicyService, + ) => Observable, +): CanActivateFn { + return async () => { + const router = inject(Router); + const toastService = inject(ToastService); + const i18nService = inject(I18nService); + const accountService = inject(AccountService); + const policyService = inject(PolicyService); + const configService = inject(ConfigService); + const syncService = inject(SyncService); + + const synced = await firstValueFrom( + accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => syncService.lastSync$(userId)), + ), + ); + + if (synced == null) { + await syncService.fullSync(false); + } + + const compliant = await firstValueFrom( + accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => featureCallback(userId, configService, policyService)), + tap((compliant) => { + if (typeof compliant !== "boolean") { + throw new Error("Feature callback must return a boolean."); + } + }), + ), + ); + + if (!compliant) { + toastService.showToast({ + variant: "error", + message: i18nService.t("noPageAccess"), + }); + + return router.createUrlTree(["/"]); + } + + return compliant; + }; +} diff --git a/apps/web/src/app/layouts/user-layout.component.html b/apps/web/src/app/layouts/user-layout.component.html index 530d4caca0..23f22d263c 100644 --- a/apps/web/src/app/layouts/user-layout.component.html +++ b/apps/web/src/app/layouts/user-layout.component.html @@ -20,10 +20,12 @@ *ngIf="showSubscription$ | async" > - + @if (showEmergencyAccess()) { + + } diff --git a/apps/web/src/app/layouts/user-layout.component.ts b/apps/web/src/app/layouts/user-layout.component.ts index 9642803ef3..52e5b65a2e 100644 --- a/apps/web/src/app/layouts/user-layout.component.ts +++ b/apps/web/src/app/layouts/user-layout.component.ts @@ -1,14 +1,20 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { CommonModule } from "@angular/common"; -import { Component, OnInit } from "@angular/core"; +import { Component, OnInit, Signal } from "@angular/core"; +import { toSignal } from "@angular/core/rxjs-interop"; import { RouterModule } from "@angular/router"; -import { Observable, switchMap } from "rxjs"; +import { combineLatest, map, Observable, switchMap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { PasswordManagerLogo } from "@bitwarden/assets/svg"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { SyncService } from "@bitwarden/common/platform/sync"; import { IconModule } from "@bitwarden/components"; @@ -32,6 +38,7 @@ import { WebLayoutModule } from "./web-layout.module"; }) export class UserLayoutComponent implements OnInit { protected readonly logo = PasswordManagerLogo; + protected readonly showEmergencyAccess: Signal; protected hasFamilySponsorshipAvailable$: Observable; protected showSponsoredFamilies$: Observable; protected showSubscription$: Observable; @@ -40,12 +47,33 @@ export class UserLayoutComponent implements OnInit { private syncService: SyncService, private billingAccountProfileStateService: BillingAccountProfileStateService, private accountService: AccountService, + private policyService: PolicyService, + private configService: ConfigService, ) { this.showSubscription$ = this.accountService.activeAccount$.pipe( switchMap((account) => this.billingAccountProfileStateService.canViewSubscription$(account.id), ), ); + + this.showEmergencyAccess = toSignal( + combineLatest([ + this.configService.getFeatureFlag$(FeatureFlag.AutoConfirm), + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.policyService.policyAppliesToUser$(PolicyType.AutoConfirm, userId), + ), + ), + ]).pipe( + map(([enabled, policyAppliesToUser]) => { + if (!enabled || !policyAppliesToUser) { + return true; + } + return false; + }), + ), + ); } async ngOnInit() { diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 319adb1d8c..8e2d770f1e 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -47,11 +47,13 @@ import { TwoFactorAuthGuard, NewDeviceVerificationComponent, } from "@bitwarden/auth/angular"; +import { canAccessEmergencyAccess } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/components"; import { LockComponent } from "@bitwarden/key-management-ui"; import { flagEnabled, Flags } from "../utils/flags"; +import { organizationPolicyGuard } from "./admin-console/organizations/guards/org-policy.guard"; import { VerifyRecoverDeleteOrgComponent } from "./admin-console/organizations/manage/verify-recover-delete-org.component"; import { AcceptFamilySponsorshipComponent } from "./admin-console/organizations/sponsorships/accept-family-sponsorship.component"; import { FamiliesForEnterpriseSetupComponent } from "./admin-console/organizations/sponsorships/families-for-enterprise-setup.component"; @@ -687,11 +689,13 @@ const routes: Routes = [ { path: "", component: EmergencyAccessComponent, + canActivate: [organizationPolicyGuard(canAccessEmergencyAccess)], data: { titleId: "emergencyAccess" } satisfies RouteDataProperties, }, { path: ":id", component: EmergencyAccessViewComponent, + canActivate: [organizationPolicyGuard(canAccessEmergencyAccess)], data: { titleId: "emergencyAccess" } satisfies RouteDataProperties, }, ], diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 49fc4c61a1..6c52016d84 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -7326,6 +7326,9 @@ "accessDenied": { "message": "Access denied. You do not have permission to view this page." }, + "noPageAccess": { + "message": "You do not have access to this page" + }, "masterPassword": { "message": "Master password" }, diff --git a/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts b/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts index 58d6d9efef..363b82c507 100644 --- a/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts @@ -1,8 +1,13 @@ -import { map, Observable } from "rxjs"; +import { combineLatest, map, Observable } from "rxjs"; + +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { UserId } from "../../../types/guid"; +import { PolicyType } from "../../enums"; import { OrganizationData } from "../../models/data/organization.data"; import { Organization } from "../../models/domain/organization"; +import { PolicyService } from "../policy/policy.service.abstraction"; export function canAccessVaultTab(org: Organization): boolean { return org.canViewAllCollections; @@ -51,6 +56,17 @@ export function canAccessOrgAdmin(org: Organization): boolean { ); } +export function canAccessEmergencyAccess( + userId: UserId, + configService: ConfigService, + policyService: PolicyService, +) { + return combineLatest([ + configService.getFeatureFlag$(FeatureFlag.AutoConfirm), + policyService.policiesByType$(PolicyType.AutoConfirm, userId), + ]).pipe(map(([enabled, policies]) => !enabled || !policies.some((p) => p.enabled))); +} + /** * @deprecated Please use the general `getById` custom rxjs operator instead. */