1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-12 14:23:32 +00:00

feat(policies): PM-19311 Enforce URI Match Defaults organization policy (#16430)

* feat(policies): Add URI Match Default Policy enum

Signed-off-by: Ben Brooks <bbrooks@bitwarden.com>

* feat(policies): Add logic to read and set the default from policy data

Signed-off-by: Ben Brooks <bbrooks@bitwarden.com>

* In settings, set default, disable select and display hint

Signed-off-by: Ben Brooks <bbrooks@bitwarden.com>

* Move applyUriMatchPolicy to writeValue function

Signed-off-by: Ben Brooks <bbrooks@bitwarden.com>

* Remove code to disable individual options because we're disabling the entire select

Signed-off-by: Ben Brooks <bbrooks@bitwarden.com>

* WiP move resolved defaultUriMatch to Domain Settings Service

* Merge branch 'main' of github.com:bitwarden/clients into pm-19310-uri-match-policy

Signed-off-by: Ben Brooks <bbrooks@bitwarden.com>

* Merge branch 'main' of github.com:bitwarden/clients into pm-19310-uri-match-policy

Signed-off-by: Ben Brooks <bbrooks@bitwarden.com>

* Merge branch 'main' of github.com:bitwarden/clients into pm-19310-uri-match-policy

Signed-off-by: Ben Brooks <bbrooks@bitwarden.com>

* Merge branch 'main' of github.com:bitwarden/clients into pm-19310-uri-match-policy

Signed-off-by: Ben Brooks <bbrooks@bitwarden.com>

* Merge branch 'main' of github.com:bitwarden/clients into pm-19310-uri-match-policy

Signed-off-by: Ben Brooks <bbrooks@bitwarden.com>

* Merge branch 'main' of github.com:bitwarden/clients into pm-19310-uri-match-policy

Signed-off-by: Ben Brooks <bbrooks@bitwarden.com>

* Address local test failures related to null observables

Signed-off-by: Ben Brooks <bbrooks@bitwarden.com>

* add missing services

* Fix test to use new resolvedDefaultUriMatchStrategy$

Signed-off-by: Ben Brooks <bbrooks@bitwarden.com>

* Move definition of defaultMatchDetection$ out of constructor

Signed-off-by: Ben Brooks <bbrooks@bitwarden.com>

* Update cipher form story to use resolvedDefaultUriMatchStrategy

Signed-off-by: Ben Brooks <bbrooks@bitwarden.com>

* Merge branch 'pm-19310-uri-match-policy' of github.com:bitwarden/clients into pm-19310-uri-match-policy

Signed-off-by: Ben Brooks <bbrooks@bitwarden.com>

* Fix incomplete storybook mock in cipher form stories

Signed-off-by: Ben Brooks <bbrooks@bitwarden.com>

* Add I18n key description

Signed-off-by: Ben Brooks <bbrooks@bitwarden.com>

* Add comment regarding potential memory leak in domain settings service

Signed-off-by: Ben Brooks <bbrooks@bitwarden.com>

* Add explicit check for null policy data

Signed-off-by: Ben Brooks <bbrooks@bitwarden.com>

* Add explicit check for undefined policy data

Signed-off-by: Ben Brooks <bbrooks@bitwarden.com>

* Merge branch 'pm-19310-uri-match-policy' of github.com:bitwarden/clients into pm-19310-uri-match-policy

Signed-off-by: Ben Brooks <bbrooks@bitwarden.com>

* Add shareReplay to address potential memory leak

Signed-off-by: Ben Brooks <bbrooks@bitwarden.com>

* Merge branch 'pm-19310-uri-match-policy' of github.com:bitwarden/clients into pm-19310-uri-match-policy

Signed-off-by: Ben Brooks <bbrooks@bitwarden.com>

* Merge branch 'main' of github.com:bitwarden/clients into pm-19310-uri-match-policy

Signed-off-by: Ben Brooks <bbrooks@bitwarden.com>

* Remove outdated comment

Signed-off-by: Ben Brooks <bbrooks@bitwarden.com>

* Improve type safety/validation and null checks in DefaultDomainSettingsService

Signed-off-by: Ben Brooks <bbrooks@bitwarden.com>

---------

Signed-off-by: Ben Brooks <bbrooks@bitwarden.com>
Co-authored-by: Jonathan Prusik <jprusik@classynemesis.com>
This commit is contained in:
Ben Brooks
2025-10-17 07:58:17 -07:00
committed by GitHub
parent b8d55c4db1
commit 91a661a025
18 changed files with 139 additions and 20 deletions

View File

@@ -5714,5 +5714,9 @@
},
"confirmKeyConnectorDomain": {
"message": "Confirm Key Connector domain"
},
"settingDisabledByPolicy": {
"message": "This setting is disabled by your organization's policy.",
"description": "This hint text is displayed when a user setting is disabled due to an organization policy."
}
}

View File

@@ -1,6 +1,7 @@
import { mock, MockProxy, mockReset } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import {
@@ -105,6 +106,7 @@ describe("OverlayBackground", () => {
let platformUtilsService: MockProxy<BrowserPlatformUtilsService>;
let enablePasskeysMock$: BehaviorSubject<boolean>;
let vaultSettingsServiceMock: MockProxy<VaultSettingsService>;
const policyService = mock<PolicyService>();
let fido2ActiveRequestManager: Fido2ActiveRequestManager;
let selectedThemeMock$: BehaviorSubject<ThemeType>;
let inlineMenuFieldQualificationService: InlineMenuFieldQualificationService;
@@ -156,7 +158,11 @@ describe("OverlayBackground", () => {
fakeStateProvider = new FakeStateProvider(accountService);
showFaviconsMock$ = new BehaviorSubject(true);
neverDomainsMock$ = new BehaviorSubject({});
domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider);
domainSettingsService = new DefaultDomainSettingsService(
fakeStateProvider,
policyService,
accountService,
);
domainSettingsService.showFavicons$ = showFaviconsMock$;
domainSettingsService.neverDomains$ = neverDomainsMock$;
logService = mock<LogService>();

View File

@@ -265,6 +265,9 @@
[disabled]="option.disabled"
></bit-option>
</bit-select>
<bit-hint *ngIf="isDefaultUriMatchDisabledByPolicy">
{{ "settingDisabledByPolicy" | i18n }}
</bit-hint>
<bit-hint *ngIf="getMatchHints() as hints">
{{ hints[0] | i18n }}
<ng-container *ngIf="hints.length > 1">

View File

@@ -26,6 +26,7 @@ import {
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { NudgesService, NudgeType } from "@bitwarden/angular/vault";
import { SpotlightComponent } from "@bitwarden/angular/vault/components/spotlight/spotlight.component";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import {
@@ -140,6 +141,8 @@ export class AutofillComponent implements OnInit {
defaultUriMatch: new FormControl(),
});
protected isDefaultUriMatchDisabledByPolicy = false;
advancedOptionWarningMap: Partial<Record<UriMatchStrategySetting, string>>;
enableAutofillOnPageLoad: boolean = false;
enableInlineMenu: boolean = false;
@@ -174,6 +177,7 @@ export class AutofillComponent implements OnInit {
private accountService: AccountService,
private autofillBrowserSettingsService: AutofillBrowserSettingsService,
private restrictedItemTypesService: RestrictedItemTypesService,
private policyService: PolicyService,
) {
this.autofillOnPageLoadOptions = [
{ name: this.i18nService.t("autoFillOnPageLoadYes"), value: true },
@@ -302,7 +306,7 @@ export class AutofillComponent implements OnInit {
});
const defaultUriMatch = await firstValueFrom(
this.domainSettingsService.defaultUriMatchStrategy$,
this.domainSettingsService.resolvedDefaultUriMatchStrategy$,
);
this.defaultUriMatch = defaultUriMatch == null ? UriMatchStrategy.Domain : defaultUriMatch;
@@ -310,6 +314,8 @@ export class AutofillComponent implements OnInit {
emitEvent: false,
});
this.applyUriMatchPolicy();
this.additionalOptionsForm.controls.enableContextMenuItem.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((value) => {
@@ -524,6 +530,20 @@ export class AutofillComponent implements OnInit {
await this.updateDefaultBrowserAutofillDisabled();
};
private applyUriMatchPolicy() {
this.domainSettingsService.defaultUriMatchStrategyPolicy$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((value) => {
if (value !== null) {
this.isDefaultUriMatchDisabledByPolicy = true;
this.additionalOptionsForm.controls.defaultUriMatch.disable({ emitEvent: false });
} else {
this.isDefaultUriMatchDisabledByPolicy = false;
this.additionalOptionsForm.controls.defaultUriMatch.enable({ emitEvent: false });
}
});
}
private async handleAdvancedMatch(
previous: UriMatchStrategySetting | null,
current: UriMatchStrategySetting | null,

View File

@@ -1,6 +1,7 @@
import { mock, MockProxy, mockReset } from "jest-mock-extended";
import { BehaviorSubject, of, Subject } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { UserVerificationService } from "@bitwarden/common/auth/services/user-verification/user-verification.service";
@@ -86,6 +87,7 @@ describe("AutofillService", () => {
const totpService = mock<TotpService>();
const eventCollectionService = mock<EventCollectionService>();
const logService = mock<LogService>();
const policyService = mock<PolicyService>();
const userVerificationService = mock<UserVerificationService>();
const billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
const platformUtilsService = mock<PlatformUtilsService>();
@@ -138,7 +140,11 @@ describe("AutofillService", () => {
userNotificationsSettings,
messageListener,
);
domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider);
domainSettingsService = new DefaultDomainSettingsService(
fakeStateProvider,
policyService,
accountService,
);
domainSettingsService.equivalentDomains$ = of(mockEquivalentDomains);
jest.spyOn(BrowserApi, "tabSendMessage");
});

View File

@@ -400,7 +400,7 @@ export default class AutofillService implements AutofillServiceInterface {
* Gets the default URI match strategy setting from the domain settings service.
*/
async getDefaultUriMatchStrategy(): Promise<UriMatchStrategySetting> {
return await firstValueFrom(this.domainSettingsService.defaultUriMatchStrategy$);
return await firstValueFrom(this.domainSettingsService.resolvedDefaultUriMatchStrategy$);
}
/**

View File

@@ -923,7 +923,11 @@ export default class MainBackground {
this.userVerificationApiService = new UserVerificationApiService(this.apiService);
this.domainSettingsService = new DefaultDomainSettingsService(this.stateProvider);
this.domainSettingsService = new DefaultDomainSettingsService(
this.stateProvider,
this.policyService,
this.accountService,
);
this.themeStateService = new DefaultThemeStateService(this.globalStateProvider);

View File

@@ -1,6 +1,7 @@
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import {
DomainSettingsService,
DefaultDomainSettingsService,
@@ -57,10 +58,15 @@ describe("ScriptInjectorService", () => {
const accountService: FakeAccountService = mockAccountServiceWith(mockUserId);
const fakeStateProvider: FakeStateProvider = new FakeStateProvider(accountService);
let domainSettingsService: DomainSettingsService;
const policyService = mock<PolicyService>();
beforeEach(() => {
jest.spyOn(BrowserApi, "getTab").mockImplementation(async () => tabMock);
domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider);
domainSettingsService = new DefaultDomainSettingsService(
fakeStateProvider,
policyService,
accountService,
);
domainSettingsService.equivalentDomains$ = of(mockEquivalentDomains);
domainSettingsService.blockedInteractionsUris$ = of({});
scriptInjectorService = new BrowserScriptInjectorService(

View File

@@ -364,7 +364,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: DomainSettingsService,
useClass: DefaultDomainSettingsService,
deps: [StateProvider],
deps: [StateProvider, PolicyService, AccountService],
}),
safeProvider({
provide: AbstractStorageService,

View File

@@ -562,7 +562,11 @@ export class ServiceContainer {
this.authService,
);
this.domainSettingsService = new DefaultDomainSettingsService(this.stateProvider);
this.domainSettingsService = new DefaultDomainSettingsService(
this.stateProvider,
this.policyService,
this.accountService,
);
this.fileUploadService = new FileUploadService(this.logService, this.apiService);

View File

@@ -546,7 +546,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: DomainSettingsService,
useClass: DefaultDomainSettingsService,
deps: [StateProvider],
deps: [StateProvider, PolicyServiceAbstraction, AccountService],
}),
safeProvider({
provide: CipherServiceAbstraction,

View File

@@ -17,5 +17,6 @@ export enum PolicyType {
FreeFamiliesSponsorshipPolicy = 13, // Disables free families plan for organization
RemoveUnlockWithPin = 14, // Do not allow members to unlock their account with a PIN.
RestrictedItemTypes = 15, // Restricts item types that can be created within an organization
UriMatchDefaults = 16, // Sets the default URI matching strategy for all users within an organization
AutotypeDefaultSetting = 17, // Sets the default autotype setting for desktop app
}

View File

@@ -1,5 +1,8 @@
import { mock } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { FakeStateProvider, FakeAccountService, mockAccountServiceWith } from "../../../spec";
import { Utils } from "../../platform/misc/utils";
import { UserId } from "../../types/guid";
@@ -10,6 +13,7 @@ describe("DefaultDomainSettingsService", () => {
let domainSettingsService: DomainSettingsService;
const mockUserId = Utils.newGuid() as UserId;
const accountService: FakeAccountService = mockAccountServiceWith(mockUserId);
const policyService = mock<PolicyService>();
const fakeStateProvider: FakeStateProvider = new FakeStateProvider(accountService);
const mockEquivalentDomains = [
@@ -19,7 +23,11 @@ describe("DefaultDomainSettingsService", () => {
];
beforeEach(() => {
domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider);
domainSettingsService = new DefaultDomainSettingsService(
fakeStateProvider,
policyService,
accountService,
);
jest.spyOn(domainSettingsService, "getUrlEquivalentDomains");
domainSettingsService.equivalentDomains$ = of(mockEquivalentDomains);

View File

@@ -1,6 +1,12 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { map, Observable } from "rxjs";
import { combineLatest, map, Observable, switchMap, shareReplay } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums/policy-type.enum";
import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import {
NeverDomains,
@@ -87,6 +93,18 @@ export abstract class DomainSettingsService {
defaultUriMatchStrategy$: Observable<UriMatchStrategySetting>;
setDefaultUriMatchStrategy: (newValue: UriMatchStrategySetting) => Promise<void>;
/**
* Org policy value for default for URI-matching
* strategies. Can be overridden by cipher-specific settings.
*/
defaultUriMatchStrategyPolicy$: Observable<UriMatchStrategySetting>;
/**
* Resolved (concerning user setting, org policy, etc) default for URI-matching
* strategies. Can be overridden by cipher-specific settings.
*/
resolvedDefaultUriMatchStrategy$: Observable<UriMatchStrategySetting>;
/**
* Helper function for the common resolution of a given URL against equivalent domains
*/
@@ -109,7 +127,15 @@ export class DefaultDomainSettingsService implements DomainSettingsService {
private defaultUriMatchStrategyState: ActiveUserState<UriMatchStrategySetting>;
readonly defaultUriMatchStrategy$: Observable<UriMatchStrategySetting>;
constructor(private stateProvider: StateProvider) {
readonly defaultUriMatchStrategyPolicy$: Observable<UriMatchStrategySetting>;
readonly resolvedDefaultUriMatchStrategy$: Observable<UriMatchStrategySetting>;
constructor(
private stateProvider: StateProvider,
private policyService: PolicyService,
private accountService: AccountService,
) {
this.showFaviconsState = this.stateProvider.getGlobal(SHOW_FAVICONS);
this.showFavicons$ = this.showFaviconsState.state$.pipe(map((x) => x ?? true));
@@ -129,6 +155,31 @@ export class DefaultDomainSettingsService implements DomainSettingsService {
this.defaultUriMatchStrategy$ = this.defaultUriMatchStrategyState.state$.pipe(
map((x) => x ?? UriMatchStrategy.Domain),
);
this.defaultUriMatchStrategyPolicy$ = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) =>
this.policyService.policiesByType$(PolicyType.UriMatchDefaults, userId),
),
getFirstPolicy,
map((policy) => {
if (!policy?.enabled || policy?.data == null) {
return null;
}
const data = policy.data?.defaultUriMatchStrategy;
// Validate that data is a valid UriMatchStrategy value
return Object.values(UriMatchStrategy).includes(data) ? data : null;
}),
shareReplay({ bufferSize: 1, refCount: true }),
);
this.resolvedDefaultUriMatchStrategy$ = combineLatest([
this.defaultUriMatchStrategy$,
this.defaultUriMatchStrategyPolicy$,
]).pipe(
map(([userSettingValue, policySettingValue]) => policySettingValue || userSettingValue),
shareReplay({ bufferSize: 1, refCount: true }),
);
}
async setShowFavicons(newValue: boolean): Promise<void> {

View File

@@ -634,7 +634,9 @@ export class CipherService implements CipherServiceAbstraction {
const equivalentDomains = await firstValueFrom(
this.domainSettingsService.getUrlEquivalentDomains(url),
);
defaultMatch ??= await firstValueFrom(this.domainSettingsService.defaultUriMatchStrategy$);
defaultMatch ??= await firstValueFrom(
this.domainSettingsService.resolvedDefaultUriMatchStrategy$,
);
const archiveFeatureEnabled = await this.configService.getFeatureFlag(
FeatureFlag.PM19148_InnovationArchive,

View File

@@ -215,7 +215,9 @@ export default {
{
provide: DomainSettingsService,
useValue: {
defaultUriMatchStrategy$: new BehaviorSubject(UriMatchStrategy.StartsWith),
resolvedDefaultUriMatchStrategy$: new BehaviorSubject(UriMatchStrategy.StartsWith),
defaultUriMatchStrategy$: new BehaviorSubject(UriMatchStrategy.Domain),
defaultUriMatchStrategyPolicy$: new BehaviorSubject(null),
},
},
{

View File

@@ -43,7 +43,7 @@ describe("AutofillOptionsComponent", () => {
liveAnnouncer = mock<LiveAnnouncer>();
platformUtilsService = mock<PlatformUtilsService>();
domainSettingsService = mock<DomainSettingsService>();
domainSettingsService.defaultUriMatchStrategy$ = new BehaviorSubject(null);
domainSettingsService.resolvedDefaultUriMatchStrategy$ = new BehaviorSubject(null);
autofillSettingsService = mock<AutofillSettingsServiceAbstraction>();
autofillSettingsService.autofillOnPageLoadDefault$ = new BehaviorSubject(false);

View File

@@ -76,10 +76,12 @@ export class AutofillOptionsComponent implements OnInit {
return this.cipherFormContainer.config.mode === "partial-edit";
}
protected defaultMatchDetection$ = this.domainSettingsService.defaultUriMatchStrategy$.pipe(
protected defaultMatchDetection$ =
this.domainSettingsService.resolvedDefaultUriMatchStrategy$.pipe(
// The default match detection should only be shown when used on the browser
filter(() => this.platformUtilsService.getClientType() == ClientType.Browser),
);
protected autofillOnPageLoadEnabled$ = this.autofillSettingsService.autofillOnPageLoad$;
protected autofillOptions: { label: string; value: boolean | null }[] = [