diff --git a/libs/common/src/admin-console/abstractions/policy/vnext-policy.service.ts b/libs/common/src/admin-console/abstractions/policy/vnext-policy.service.ts new file mode 100644 index 00000000000..1e96d6b8d00 --- /dev/null +++ b/libs/common/src/admin-console/abstractions/policy/vnext-policy.service.ts @@ -0,0 +1,68 @@ +import { Observable } from "rxjs"; + +import { UserId } from "../../../types/guid"; +import { PolicyType } from "../../enums"; +import { PolicyData } from "../../models/data/policy.data"; +import { MasterPasswordPolicyOptions } from "../../models/domain/master-password-policy-options"; +import { Policy } from "../../models/domain/policy"; +import { ResetPasswordPolicyOptions } from "../../models/domain/reset-password-policy-options"; + +export abstract class vNextPolicyService { + /** + * All policies for the provided user from sync data. + * May include policies that are disabled or otherwise do not apply to the user. Be careful using this! + * Consider {@link policiesByType$} instead, which will only return policies that should be enforced against the user. + */ + abstract policies$: (userId: UserId) => Observable<Policy[]>; + + /** + * @returns all {@link Policy} objects of a given type that apply to the specified user. + * A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner). + * @param policyType the {@link PolicyType} to search for + * @param userId the {@link UserId} to search against + */ + abstract policiesByType$: (policyType: PolicyType, userId: UserId) => Observable<Policy[]>; + + /** + * @returns true if a policy of the specified type applies to the specified user, otherwise false. + * A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner). + * This does not take into account the policy's configuration - if that is important, use {@link policiesByType$} to get the + * {@link Policy} objects and then filter by Policy.data. + */ + abstract policyAppliesToUser$: (policyType: PolicyType, userId: UserId) => Observable<boolean>; + + // Policy specific interfaces + + /** + * Combines all Master Password policies that apply to the user. + * @returns a set of options which represent the minimum Master Password settings that the user must + * comply with in order to comply with **all** Master Password policies. + */ + abstract masterPasswordPolicyOptions$: ( + userId: UserId, + policies?: Policy[], + ) => Observable<MasterPasswordPolicyOptions | undefined>; + + /** + * Evaluates whether a proposed Master Password complies with all Master Password policies that apply to the user. + */ + abstract evaluateMasterPassword: ( + passwordStrength: number, + newPassword: string, + enforcedPolicyOptions?: MasterPasswordPolicyOptions, + ) => boolean; + + /** + * @returns {@link ResetPasswordPolicyOptions} for the specified organization and a boolean indicating whether the policy + * is enabled + */ + abstract getResetPasswordPolicyOptions: ( + policies: Policy[], + orgId: string, + ) => [ResetPasswordPolicyOptions, boolean]; +} + +export abstract class vNextInternalPolicyService extends vNextPolicyService { + abstract upsert: (policy: PolicyData, userId: UserId) => Promise<void>; + abstract replace: (policies: { [id: string]: PolicyData }, userId: UserId) => Promise<void>; +} diff --git a/libs/common/src/admin-console/services/policy/default-vnext-policy.service.spec.ts b/libs/common/src/admin-console/services/policy/default-vnext-policy.service.spec.ts new file mode 100644 index 00000000000..f58e1d27ee6 --- /dev/null +++ b/libs/common/src/admin-console/services/policy/default-vnext-policy.service.spec.ts @@ -0,0 +1,590 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import { firstValueFrom, of } from "rxjs"; + +import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec"; +import { FakeSingleUserState } from "../../../../spec/fake-state"; +import { + OrganizationUserStatusType, + OrganizationUserType, + PolicyType, +} from "../../../admin-console/enums"; +import { PermissionsApi } from "../../../admin-console/models/api/permissions.api"; +import { OrganizationData } from "../../../admin-console/models/data/organization.data"; +import { PolicyData } from "../../../admin-console/models/data/policy.data"; +import { MasterPasswordPolicyOptions } from "../../../admin-console/models/domain/master-password-policy-options"; +import { Organization } from "../../../admin-console/models/domain/organization"; +import { Policy } from "../../../admin-console/models/domain/policy"; +import { ResetPasswordPolicyOptions } from "../../../admin-console/models/domain/reset-password-policy-options"; +import { POLICIES } from "../../../admin-console/services/policy/policy.service"; +import { PolicyId, UserId } from "../../../types/guid"; +import { OrganizationService } from "../../abstractions/organization/organization.service.abstraction"; + +import { DefaultvNextPolicyService, getFirstPolicy } from "./default-vnext-policy.service"; + +describe("PolicyService", () => { + const userId = "userId" as UserId; + let stateProvider: FakeStateProvider; + let organizationService: MockProxy<OrganizationService>; + let singleUserState: FakeSingleUserState<Record<PolicyId, PolicyData>>; + + let policyService: DefaultvNextPolicyService; + + beforeEach(() => { + const accountService = mockAccountServiceWith(userId); + stateProvider = new FakeStateProvider(accountService); + organizationService = mock<OrganizationService>(); + singleUserState = stateProvider.singleUser.getFake(userId, POLICIES); + + const organizations$ = of([ + // User + organization("org1", true, true, OrganizationUserStatusType.Confirmed, false), + // Owner + organization( + "org2", + true, + true, + OrganizationUserStatusType.Confirmed, + false, + OrganizationUserType.Owner, + ), + // Does not use policies + organization("org3", true, false, OrganizationUserStatusType.Confirmed, false), + // Another User + organization("org4", true, true, OrganizationUserStatusType.Confirmed, false), + // Another User + organization("org5", true, true, OrganizationUserStatusType.Confirmed, false), + // Can manage policies + organization("org6", true, true, OrganizationUserStatusType.Confirmed, true), + ]); + + organizationService.organizations$.calledWith(userId).mockReturnValue(organizations$); + + policyService = new DefaultvNextPolicyService(stateProvider, organizationService); + }); + + it("upsert", async () => { + singleUserState.nextState( + arrayToRecord([ + policyData("1", "test-organization", PolicyType.MaximumVaultTimeout, true, { minutes: 14 }), + ]), + ); + + await policyService.upsert( + policyData("99", "test-organization", PolicyType.DisableSend, true), + userId, + ); + + expect(await firstValueFrom(policyService.policies$(userId))).toEqual([ + { + id: "1", + organizationId: "test-organization", + type: PolicyType.MaximumVaultTimeout, + enabled: true, + data: { minutes: 14 }, + }, + { + id: "99", + organizationId: "test-organization", + type: PolicyType.DisableSend, + enabled: true, + }, + ]); + }); + + it("replace", async () => { + singleUserState.nextState( + arrayToRecord([ + policyData("1", "test-organization", PolicyType.MaximumVaultTimeout, true, { minutes: 14 }), + ]), + ); + + await policyService.replace( + { + "2": policyData("2", "test-organization", PolicyType.DisableSend, true), + }, + userId, + ); + + expect(await firstValueFrom(policyService.policies$(userId))).toEqual([ + { + id: "2", + organizationId: "test-organization", + type: PolicyType.DisableSend, + enabled: true, + }, + ]); + }); + + describe("masterPasswordPolicyOptions", () => { + it("returns default policy options", async () => { + const data: any = { + minComplexity: 5, + minLength: 20, + requireUpper: true, + }; + const model = [ + new Policy(policyData("1", "test-organization-3", PolicyType.MasterPassword, true, data)), + ]; + jest.spyOn(policyService as any, "policies$").mockReturnValue(of(model)); + + const result = await firstValueFrom(policyService.masterPasswordPolicyOptions$(userId)); + + expect(result).toEqual({ + minComplexity: 5, + minLength: 20, + requireLower: false, + requireNumbers: false, + requireSpecial: false, + requireUpper: true, + enforceOnLogin: false, + }); + }); + + it("returns undefined", async () => { + const data: any = {}; + const model = [ + new Policy( + policyData("3", "test-organization-3", PolicyType.DisablePersonalVaultExport, true, data), + ), + new Policy( + policyData("4", "test-organization-3", PolicyType.MaximumVaultTimeout, true, data), + ), + ]; + jest.spyOn(policyService as any, "policies$").mockReturnValue(of(model)); + + const result = await firstValueFrom(policyService.masterPasswordPolicyOptions$(userId)); + + expect(result).toBeUndefined(); + }); + + it("returns specified policy options", async () => { + const data: any = { + minLength: 14, + }; + const model = [ + new Policy( + policyData("3", "test-organization-3", PolicyType.DisablePersonalVaultExport, true, data), + ), + new Policy(policyData("4", "test-organization-3", PolicyType.MasterPassword, true, data)), + ]; + jest.spyOn(policyService as any, "policies$").mockReturnValue(of(model)); + + const result = await firstValueFrom(policyService.masterPasswordPolicyOptions$(userId)); + + expect(result).toEqual({ + minComplexity: 0, + minLength: 14, + requireLower: false, + requireNumbers: false, + requireSpecial: false, + requireUpper: false, + enforceOnLogin: false, + }); + }); + }); + + describe("evaluateMasterPassword", () => { + it("false", async () => { + const enforcedPolicyOptions = new MasterPasswordPolicyOptions(); + enforcedPolicyOptions.minLength = 14; + const result = policyService.evaluateMasterPassword(10, "password", enforcedPolicyOptions); + + expect(result).toEqual(false); + }); + + it("true", async () => { + const enforcedPolicyOptions = new MasterPasswordPolicyOptions(); + const result = policyService.evaluateMasterPassword(0, "password", enforcedPolicyOptions); + + expect(result).toEqual(true); + }); + }); + + describe("getResetPasswordPolicyOptions", () => { + it("default", async () => { + const result = policyService.getResetPasswordPolicyOptions([], ""); + + expect(result).toEqual([new ResetPasswordPolicyOptions(), false]); + }); + + it("returns autoEnrollEnabled true", async () => { + const data: any = { + autoEnrollEnabled: true, + }; + const policies = [ + new Policy(policyData("5", "test-organization-3", PolicyType.ResetPassword, true, data)), + ]; + const result = policyService.getResetPasswordPolicyOptions(policies, "test-organization-3"); + + expect(result).toEqual([{ autoEnrollEnabled: true }, true]); + }); + }); + + describe("policiesByType$", () => { + it("returns the specified PolicyType", async () => { + singleUserState.nextState( + arrayToRecord([ + policyData("policy1", "org1", PolicyType.ActivateAutofill, true), + policyData("policy2", "org1", PolicyType.DisablePersonalVaultExport, true), + ]), + ); + + const result = await firstValueFrom( + policyService + .policiesByType$(PolicyType.DisablePersonalVaultExport, userId) + .pipe(getFirstPolicy), + ); + + expect(result).toEqual({ + id: "policy2", + organizationId: "org1", + type: PolicyType.DisablePersonalVaultExport, + enabled: true, + }); + }); + + it("does not return disabled policies", async () => { + singleUserState.nextState( + arrayToRecord([ + policyData("policy1", "org1", PolicyType.ActivateAutofill, true), + policyData("policy2", "org1", PolicyType.DisablePersonalVaultExport, false), + ]), + ); + + const result = await firstValueFrom( + policyService + .policiesByType$(PolicyType.DisablePersonalVaultExport, userId) + .pipe(getFirstPolicy), + ); + + expect(result).toBeUndefined(); + }); + + it("does not return policies that do not apply to the user because the user's role is exempt", async () => { + singleUserState.nextState( + arrayToRecord([ + policyData("policy1", "org1", PolicyType.ActivateAutofill, true), + policyData("policy2", "org2", PolicyType.DisablePersonalVaultExport, false), + ]), + ); + + const result = await firstValueFrom( + policyService + .policiesByType$(PolicyType.DisablePersonalVaultExport, userId) + .pipe(getFirstPolicy), + ); + expect(result).toBeUndefined(); + }); + + it.each([ + ["owners", "org2"], + ["administrators", "org6"], + ])("returns the password generator policy for %s", async (_, organization) => { + singleUserState.nextState( + arrayToRecord([ + policyData("policy1", "org1", PolicyType.ActivateAutofill, false), + policyData("policy2", organization, PolicyType.PasswordGenerator, true), + ]), + ); + + const result = await firstValueFrom( + policyService.policiesByType$(PolicyType.PasswordGenerator, userId).pipe(getFirstPolicy), + ); + + expect(result).toBeTruthy(); + }); + + it("does not return policies for organizations that do not use policies", async () => { + singleUserState.nextState( + arrayToRecord([ + policyData("policy1", "org3", PolicyType.ActivateAutofill, true), + policyData("policy2", "org2", PolicyType.DisablePersonalVaultExport, true), + ]), + ); + + const result = await firstValueFrom( + policyService.policiesByType$(PolicyType.ActivateAutofill, userId).pipe(getFirstPolicy), + ); + + expect(result).toBeUndefined(); + }); + }); + + describe("policies$", () => { + it("returns all policies when none are disabled", async () => { + singleUserState.nextState( + arrayToRecord([ + policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true), + policyData("policy2", "org1", PolicyType.ActivateAutofill, true), + policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, true), + policyData("policy4", "org1", PolicyType.DisablePersonalVaultExport, true), + ]), + ); + + const result = await firstValueFrom(policyService.policies$(userId)); + + expect(result).toEqual([ + { + id: "policy1", + organizationId: "org4", + type: PolicyType.DisablePersonalVaultExport, + enabled: true, + }, + { + id: "policy2", + organizationId: "org1", + type: PolicyType.ActivateAutofill, + enabled: true, + }, + { + id: "policy3", + organizationId: "org5", + type: PolicyType.DisablePersonalVaultExport, + enabled: true, + }, + { + id: "policy4", + organizationId: "org1", + type: PolicyType.DisablePersonalVaultExport, + enabled: true, + }, + ]); + }); + + it("returns all policies when some are disabled", async () => { + singleUserState.nextState( + arrayToRecord([ + policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true), + policyData("policy2", "org1", PolicyType.ActivateAutofill, true), + policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, false), // disabled + policyData("policy4", "org1", PolicyType.DisablePersonalVaultExport, true), + ]), + ); + + const result = await firstValueFrom(policyService.policies$(userId)); + + expect(result).toEqual([ + { + id: "policy1", + organizationId: "org4", + type: PolicyType.DisablePersonalVaultExport, + enabled: true, + }, + { + id: "policy2", + organizationId: "org1", + type: PolicyType.ActivateAutofill, + enabled: true, + }, + { + id: "policy3", + organizationId: "org5", + type: PolicyType.DisablePersonalVaultExport, + enabled: false, + }, + { + id: "policy4", + organizationId: "org1", + type: PolicyType.DisablePersonalVaultExport, + enabled: true, + }, + ]); + }); + + it("returns policies that do not apply to the user because the user's role is exempt", async () => { + singleUserState.nextState( + arrayToRecord([ + policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true), + policyData("policy2", "org1", PolicyType.ActivateAutofill, true), + policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, true), + policyData("policy4", "org2", PolicyType.DisablePersonalVaultExport, true), // owner + ]), + ); + + const result = await firstValueFrom(policyService.policies$(userId)); + + expect(result).toEqual([ + { + id: "policy1", + organizationId: "org4", + type: PolicyType.DisablePersonalVaultExport, + enabled: true, + }, + { + id: "policy2", + organizationId: "org1", + type: PolicyType.ActivateAutofill, + enabled: true, + }, + { + id: "policy3", + organizationId: "org5", + type: PolicyType.DisablePersonalVaultExport, + enabled: true, + }, + { + id: "policy4", + organizationId: "org2", + type: PolicyType.DisablePersonalVaultExport, + enabled: true, + }, + ]); + }); + + it("does not return policies for organizations that do not use policies", async () => { + singleUserState.nextState( + arrayToRecord([ + policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true), + policyData("policy2", "org1", PolicyType.ActivateAutofill, true), + policyData("policy3", "org3", PolicyType.DisablePersonalVaultExport, true), // does not use policies + policyData("policy4", "org1", PolicyType.DisablePersonalVaultExport, true), + ]), + ); + + const result = await firstValueFrom(policyService.policies$(userId)); + + expect(result).toEqual([ + { + id: "policy1", + organizationId: "org4", + type: PolicyType.DisablePersonalVaultExport, + enabled: true, + }, + { + id: "policy2", + organizationId: "org1", + type: PolicyType.ActivateAutofill, + enabled: true, + }, + { + id: "policy3", + organizationId: "org3", + type: PolicyType.DisablePersonalVaultExport, + enabled: true, + }, + { + id: "policy4", + organizationId: "org1", + type: PolicyType.DisablePersonalVaultExport, + enabled: true, + }, + ]); + }); + }); + + describe("policyAppliesToUser$", () => { + it("returns true when the policyType applies to the user", async () => { + singleUserState.nextState( + arrayToRecord([ + policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true), + policyData("policy2", "org1", PolicyType.ActivateAutofill, true), + policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, true), + policyData("policy4", "org1", PolicyType.DisablePersonalVaultExport, true), + ]), + ); + + const result = await firstValueFrom( + policyService.policyAppliesToUser$(PolicyType.DisablePersonalVaultExport, userId), + ); + + expect(result).toBe(true); + }); + + it("returns false when policyType is disabled", async () => { + singleUserState.nextState( + arrayToRecord([ + policyData("policy2", "org1", PolicyType.ActivateAutofill, true), + policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, false), // disabled + ]), + ); + + const result = await firstValueFrom( + policyService.policyAppliesToUser$(PolicyType.DisablePersonalVaultExport, userId), + ); + + expect(result).toBe(false); + }); + + it("returns false when the policyType does not apply to the user because the user's role is exempt", async () => { + singleUserState.nextState( + arrayToRecord([ + policyData("policy2", "org1", PolicyType.ActivateAutofill, true), + policyData("policy4", "org2", PolicyType.DisablePersonalVaultExport, true), // owner + ]), + ); + + const result = await firstValueFrom( + policyService.policyAppliesToUser$(PolicyType.DisablePersonalVaultExport, userId), + ); + + expect(result).toBe(false); + }); + + it("returns false for organizations that do not use policies", async () => { + singleUserState.nextState( + arrayToRecord([ + policyData("policy2", "org1", PolicyType.ActivateAutofill, true), + policyData("policy3", "org3", PolicyType.DisablePersonalVaultExport, true), // does not use policies + ]), + ); + + const result = await firstValueFrom( + policyService.policyAppliesToUser$(PolicyType.DisablePersonalVaultExport, userId), + ); + + expect(result).toBe(false); + }); + }); + + function policyData( + id: string, + organizationId: string, + type: PolicyType, + enabled: boolean, + data?: any, + ) { + const policyData = new PolicyData({} as any); + policyData.id = id as PolicyId; + policyData.organizationId = organizationId; + policyData.type = type; + policyData.enabled = enabled; + policyData.data = data; + + return policyData; + } + + function organizationData( + id: string, + enabled: boolean, + usePolicies: boolean, + status: OrganizationUserStatusType, + managePolicies: boolean, + type: OrganizationUserType = OrganizationUserType.User, + ) { + const organizationData = new OrganizationData({} as any, {} as any); + organizationData.id = id; + organizationData.enabled = enabled; + organizationData.usePolicies = usePolicies; + organizationData.status = status; + organizationData.permissions = new PermissionsApi({ managePolicies: managePolicies } as any); + organizationData.type = type; + return organizationData; + } + + function organization( + id: string, + enabled: boolean, + usePolicies: boolean, + status: OrganizationUserStatusType, + managePolicies: boolean, + type: OrganizationUserType = OrganizationUserType.User, + ) { + return new Organization( + organizationData(id, enabled, usePolicies, status, managePolicies, type), + ); + } + + function arrayToRecord(input: PolicyData[]): Record<PolicyId, PolicyData> { + return Object.fromEntries(input.map((i) => [i.id, i])); + } +}); diff --git a/libs/common/src/admin-console/services/policy/default-vnext-policy.service.ts b/libs/common/src/admin-console/services/policy/default-vnext-policy.service.ts new file mode 100644 index 00000000000..bc56638a987 --- /dev/null +++ b/libs/common/src/admin-console/services/policy/default-vnext-policy.service.ts @@ -0,0 +1,240 @@ +import { combineLatest, map, Observable, of } from "rxjs"; + +import { StateProvider } from "../../../platform/state"; +import { UserId } from "../../../types/guid"; +import { OrganizationService } from "../../abstractions/organization/organization.service.abstraction"; +import { vNextPolicyService } from "../../abstractions/policy/vnext-policy.service"; +import { OrganizationUserStatusType, PolicyType } from "../../enums"; +import { PolicyData } from "../../models/data/policy.data"; +import { MasterPasswordPolicyOptions } from "../../models/domain/master-password-policy-options"; +import { Organization } from "../../models/domain/organization"; +import { Policy } from "../../models/domain/policy"; +import { ResetPasswordPolicyOptions } from "../../models/domain/reset-password-policy-options"; + +import { POLICIES } from "./vnext-policy-state"; + +export function policyRecordToArray(policiesMap: { [id: string]: PolicyData }) { + return Object.values(policiesMap || {}).map((f) => new Policy(f)); +} + +export const getFirstPolicy = map<Policy[], Policy | undefined>((policies) => { + return policies.at(0) ?? undefined; +}); + +export class DefaultvNextPolicyService implements vNextPolicyService { + constructor( + private stateProvider: StateProvider, + private organizationService: OrganizationService, + ) {} + + private policyState(userId: UserId) { + return this.stateProvider.getUser(userId, POLICIES); + } + + private policyData$(userId: UserId) { + return this.policyState(userId).state$.pipe(map((policyData) => policyData ?? {})); + } + + policies$(userId: UserId) { + return this.policyData$(userId).pipe(map((policyData) => policyRecordToArray(policyData))); + } + + policiesByType$(policyType: PolicyType, userId: UserId) { + const filteredPolicies$ = this.policies$(userId).pipe( + map((policies) => policies.filter((p) => p.type === policyType)), + ); + + if (!userId) { + throw new Error("No userId provided"); + } + + const organizations$ = this.organizationService.organizations$(userId); + + return combineLatest([filteredPolicies$, organizations$]).pipe( + map(([policies, organizations]) => this.enforcedPolicyFilter(policies, organizations)), + ); + } + + policyAppliesToUser$(policyType: PolicyType, userId: UserId) { + return this.policiesByType$(policyType, userId).pipe( + getFirstPolicy, + map((policy) => !!policy), + ); + } + + private enforcedPolicyFilter(policies: Policy[], organizations: Organization[]) { + const orgDict = Object.fromEntries(organizations.map((o) => [o.id, o])); + return policies.filter((policy) => { + const organization = orgDict[policy.organizationId]; + + // This shouldn't happen, i.e. the user should only have policies for orgs they are a member of + // But if it does, err on the side of enforcing the policy + if (!organization) { + return true; + } + + return ( + policy.enabled && + organization.status >= OrganizationUserStatusType.Accepted && + organization.usePolicies && + !this.isExemptFromPolicy(policy.type, organization) + ); + }); + } + + masterPasswordPolicyOptions$( + userId: UserId, + policies?: Policy[], + ): Observable<MasterPasswordPolicyOptions | undefined> { + const policies$ = policies ? of(policies) : this.policies$(userId); + return policies$.pipe( + map((obsPolicies) => { + const enforcedOptions: MasterPasswordPolicyOptions = new MasterPasswordPolicyOptions(); + const filteredPolicies = + obsPolicies.filter((p) => p.type === PolicyType.MasterPassword) ?? []; + + if (filteredPolicies.length === 0) { + return; + } + + filteredPolicies.forEach((currentPolicy) => { + if (!currentPolicy.enabled || !currentPolicy.data) { + return; + } + + if ( + currentPolicy.data.minComplexity != null && + currentPolicy.data.minComplexity > enforcedOptions.minComplexity + ) { + enforcedOptions.minComplexity = currentPolicy.data.minComplexity; + } + + if ( + currentPolicy.data.minLength != null && + currentPolicy.data.minLength > enforcedOptions.minLength + ) { + enforcedOptions.minLength = currentPolicy.data.minLength; + } + + if (currentPolicy.data.requireUpper) { + enforcedOptions.requireUpper = true; + } + + if (currentPolicy.data.requireLower) { + enforcedOptions.requireLower = true; + } + + if (currentPolicy.data.requireNumbers) { + enforcedOptions.requireNumbers = true; + } + + if (currentPolicy.data.requireSpecial) { + enforcedOptions.requireSpecial = true; + } + + if (currentPolicy.data.enforceOnLogin) { + enforcedOptions.enforceOnLogin = true; + } + }); + + return enforcedOptions; + }), + ); + } + + evaluateMasterPassword( + passwordStrength: number, + newPassword: string, + enforcedPolicyOptions?: MasterPasswordPolicyOptions, + ): boolean { + if (!enforcedPolicyOptions) { + return true; + } + + if ( + enforcedPolicyOptions.minComplexity > 0 && + enforcedPolicyOptions.minComplexity > passwordStrength + ) { + return false; + } + + if ( + enforcedPolicyOptions.minLength > 0 && + enforcedPolicyOptions.minLength > newPassword.length + ) { + return false; + } + + if (enforcedPolicyOptions.requireUpper && newPassword.toLocaleLowerCase() === newPassword) { + return false; + } + + if (enforcedPolicyOptions.requireLower && newPassword.toLocaleUpperCase() === newPassword) { + return false; + } + + if (enforcedPolicyOptions.requireNumbers && !/[0-9]/.test(newPassword)) { + return false; + } + + // eslint-disable-next-line + if (enforcedPolicyOptions.requireSpecial && !/[!@#$%\^&*]/g.test(newPassword)) { + return false; + } + + return true; + } + + getResetPasswordPolicyOptions( + policies: Policy[], + orgId: string, + ): [ResetPasswordPolicyOptions, boolean] { + const resetPasswordPolicyOptions = new ResetPasswordPolicyOptions(); + + if (!policies || !orgId) { + return [resetPasswordPolicyOptions, false]; + } + + const policy = policies.find( + (p) => p.organizationId === orgId && p.type === PolicyType.ResetPassword && p.enabled, + ); + resetPasswordPolicyOptions.autoEnrollEnabled = policy?.data?.autoEnrollEnabled ?? false; + + return [resetPasswordPolicyOptions, policy?.enabled ?? false]; + } + + async upsert(policy: PolicyData, userId: UserId): Promise<void> { + await this.policyState(userId).update((policies) => { + policies ??= {}; + policies[policy.id] = policy; + return policies; + }); + } + + async replace(policies: { [id: string]: PolicyData }, userId: UserId): Promise<void> { + await this.stateProvider.setUserState(POLICIES, policies, userId); + } + + /** + * Determines whether an orgUser is exempt from a specific policy because of their role + * Generally orgUsers who can manage policies are exempt from them, but some policies are stricter + */ + private isExemptFromPolicy(policyType: PolicyType, organization: Organization) { + switch (policyType) { + case PolicyType.MaximumVaultTimeout: + // Max Vault Timeout applies to everyone except owners + return organization.isOwner; + case PolicyType.PasswordGenerator: + // password generation policy applies to everyone + return false; + case PolicyType.PersonalOwnership: + // individual vault policy applies to everyone except admins and owners + return organization.isAdmin; + case PolicyType.FreeFamiliesSponsorshipPolicy: + // free Bitwarden families policy applies to everyone + return false; + default: + return organization.canManagePolicies; + } + } +} diff --git a/libs/common/src/admin-console/services/policy/vnext-policy-state.ts b/libs/common/src/admin-console/services/policy/vnext-policy-state.ts new file mode 100644 index 00000000000..7f53e781ad3 --- /dev/null +++ b/libs/common/src/admin-console/services/policy/vnext-policy-state.ts @@ -0,0 +1,8 @@ +import { POLICIES_DISK, UserKeyDefinition } from "../../../platform/state"; +import { PolicyId } from "../../../types/guid"; +import { PolicyData } from "../../models/data/policy.data"; + +export const POLICIES = UserKeyDefinition.record<PolicyData, PolicyId>(POLICIES_DISK, "policies", { + deserializer: (policyData) => policyData, + clearOn: ["logout"], +});