From 8c598b8783f4846f57d8f55ea15fe5df5306eaad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Fri, 5 Sep 2025 11:22:55 +0100 Subject: [PATCH] [PM-22839] Update Device Approvals visibility based on SSO configuration (#16144) * Add ssoEnabled and ssoMemberDecryptionType properties to ProfileOrganizationResponse * Add SSO support to Organization model with ssoEnabled and ssoMemberDecryptionType properties, and implement related tests * Upsert organization SSO settings in memory after save Updates organization data in memory with new SSO configuration values to ensure immediate UI updates for Device Approvals page visibility. * Refactor SSO component to simplify upsertOrganizationWithSsoChanges method - Updated the method signature to accept a single OrganizationSsoRequest object instead of separate parameters. - Adjusted the internal logic to directly use properties from the OrganizationSsoRequest for updating the organization state. * Specify OrganizationData type for updatedOrganization in SSO component --- .../bit-web/src/app/auth/sso/sso.component.ts | 28 ++- .../models/data/organization.data.spec.ts | 2 + .../models/data/organization.data.ts | 5 + .../models/domain/organization.spec.ts | 182 ++++++++++++++++++ .../models/domain/organization.ts | 12 +- .../response/profile-organization.response.ts | 5 + 6 files changed, 231 insertions(+), 3 deletions(-) create mode 100644 libs/common/src/admin-console/models/domain/organization.spec.ts diff --git a/bitwarden_license/bit-web/src/app/auth/sso/sso.component.ts b/bitwarden_license/bit-web/src/app/auth/sso/sso.component.ts index fd56e7d4afc..9baeaabb33f 100644 --- a/bitwarden_license/bit-web/src/app/auth/sso/sso.component.ts +++ b/bitwarden_license/bit-web/src/app/auth/sso/sso.component.ts @@ -16,8 +16,9 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { getOrganizationById, - OrganizationService, + InternalOrganizationServiceAbstraction, } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { OrganizationData } from "@bitwarden/common/admin-console/models/data/organization.data"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { @@ -196,7 +197,7 @@ export class SsoComponent implements OnInit, OnDestroy { private apiService: ApiService, private platformUtilsService: PlatformUtilsService, private i18nService: I18nService, - private organizationService: OrganizationService, + private organizationService: InternalOrganizationServiceAbstraction, private accountService: AccountService, private organizationApiService: OrganizationApiServiceAbstraction, private toastService: ToastService, @@ -298,6 +299,8 @@ export class SsoComponent implements OnInit, OnDestroy { const response = await this.organizationApiService.updateSso(this.organizationId, request); this.populateForm(response); + await this.upsertOrganizationWithSsoChanges(request); + this.toastService.showToast({ variant: "success", title: null, @@ -399,4 +402,25 @@ export class SsoComponent implements OnInit, OnDestroy { document.body.append(div); } + + private async upsertOrganizationWithSsoChanges( + organizationSsoRequest: OrganizationSsoRequest, + ): Promise { + const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); + const currentOrganization = await firstValueFrom( + this.organizationService + .organizations$(userId) + .pipe(getOrganizationById(this.organizationId)), + ); + + if (currentOrganization) { + const updatedOrganization: OrganizationData = { + ...currentOrganization, + ssoEnabled: organizationSsoRequest.enabled, + ssoMemberDecryptionType: organizationSsoRequest.data.memberDecryptionType, + }; + + await this.organizationService.upsert(updatedOrganization, userId); + } + } } diff --git a/libs/common/src/admin-console/models/data/organization.data.spec.ts b/libs/common/src/admin-console/models/data/organization.data.spec.ts index a6a2caa49c7..346fc3db4bb 100644 --- a/libs/common/src/admin-console/models/data/organization.data.spec.ts +++ b/libs/common/src/admin-console/models/data/organization.data.spec.ts @@ -61,6 +61,8 @@ describe("ORGANIZATIONS state", () => { useOrganizationDomains: false, useAdminSponsoredFamilies: false, isAdminInitiated: false, + ssoEnabled: false, + ssoMemberDecryptionType: undefined, }, }; const result = sut.deserializer(JSON.parse(JSON.stringify(expectedResult))); diff --git a/libs/common/src/admin-console/models/data/organization.data.ts b/libs/common/src/admin-console/models/data/organization.data.ts index 16f6f90f347..95b4b294445 100644 --- a/libs/common/src/admin-console/models/data/organization.data.ts +++ b/libs/common/src/admin-console/models/data/organization.data.ts @@ -2,6 +2,7 @@ // @ts-strict-ignore import { Jsonify } from "type-fest"; +import { MemberDecryptionType } from "../../../auth/enums/sso"; import { ProductTierType } from "../../../billing/enums"; import { OrganizationUserStatusType, OrganizationUserType, ProviderType } from "../../enums"; import { PermissionsApi } from "../api/permissions.api"; @@ -63,6 +64,8 @@ export class OrganizationData { useRiskInsights: boolean; useAdminSponsoredFamilies: boolean; isAdminInitiated: boolean; + ssoEnabled: boolean; + ssoMemberDecryptionType?: MemberDecryptionType; constructor( response?: ProfileOrganizationResponse, @@ -128,6 +131,8 @@ export class OrganizationData { this.useRiskInsights = response.useRiskInsights; this.useAdminSponsoredFamilies = response.useAdminSponsoredFamilies; this.isAdminInitiated = response.isAdminInitiated; + this.ssoEnabled = response.ssoEnabled; + this.ssoMemberDecryptionType = response.ssoMemberDecryptionType; this.isMember = options.isMember; this.isProviderUser = options.isProviderUser; diff --git a/libs/common/src/admin-console/models/domain/organization.spec.ts b/libs/common/src/admin-console/models/domain/organization.spec.ts new file mode 100644 index 00000000000..cc158c71056 --- /dev/null +++ b/libs/common/src/admin-console/models/domain/organization.spec.ts @@ -0,0 +1,182 @@ +import { MemberDecryptionType } from "../../../auth/enums/sso"; +import { ProductTierType } from "../../../billing/enums"; +import { OrganizationUserStatusType, OrganizationUserType } from "../../enums"; +import { PermissionsApi } from "../api/permissions.api"; +import { OrganizationData } from "../data/organization.data"; + +import { Organization } from "./organization"; + +describe("Organization", () => { + let data: OrganizationData; + + beforeEach(() => { + data = { + id: "test-org-id", + name: "Test Organization", + status: OrganizationUserStatusType.Confirmed, + type: OrganizationUserType.Admin, + enabled: true, + usePolicies: true, + useGroups: true, + useDirectory: true, + useEvents: true, + useTotp: true, + use2fa: true, + useApi: true, + useSso: true, + useOrganizationDomains: true, + useKeyConnector: false, + useScim: true, + useCustomPermissions: false, + useResetPassword: true, + useSecretsManager: true, + usePasswordManager: true, + useActivateAutofillPolicy: false, + selfHost: false, + usersGetPremium: false, + seats: 10, + maxCollections: 100, + maxStorageGb: 1, + ssoBound: false, + identifier: "test-identifier", + permissions: new PermissionsApi({ + accessEventLogs: false, + accessImportExport: false, + accessReports: false, + createNewCollections: false, + editAnyCollection: false, + deleteAnyCollection: false, + editAssignedCollections: false, + deleteAssignedCollections: false, + manageCiphers: false, + manageGroups: false, + managePolicies: false, + manageSso: false, + manageUsers: false, + manageResetPassword: false, + manageScim: false, + }), + resetPasswordEnrolled: false, + userId: "user-id", + organizationUserId: "org-user-id", + hasPublicAndPrivateKeys: false, + providerId: null, + providerName: null, + providerType: null, + isProviderUser: false, + isMember: true, + familySponsorshipFriendlyName: null, + familySponsorshipAvailable: false, + productTierType: ProductTierType.Enterprise, + keyConnectorEnabled: false, + keyConnectorUrl: null, + familySponsorshipLastSyncDate: null, + familySponsorshipValidUntil: null, + familySponsorshipToDelete: null, + accessSecretsManager: false, + limitCollectionCreation: false, + limitCollectionDeletion: false, + limitItemDeletion: false, + allowAdminAccessToAllCollectionItems: true, + userIsManagedByOrganization: false, + useRiskInsights: false, + useAdminSponsoredFamilies: false, + isAdminInitiated: false, + ssoEnabled: false, + ssoMemberDecryptionType: MemberDecryptionType.MasterPassword, + } as OrganizationData; + }); + + describe("canManageDeviceApprovals", () => { + it("should return false when user is not admin and has no manageResetPassword permission", () => { + data.type = OrganizationUserType.User; + data.useSso = true; + data.ssoEnabled = true; + data.ssoMemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; + data.permissions.manageResetPassword = false; + + const organization = new Organization(data); + + expect(organization.canManageDeviceApprovals).toBe(false); + }); + + it("should return false when useSso is false", () => { + data.type = OrganizationUserType.Admin; + data.useSso = false; + data.ssoEnabled = true; + data.ssoMemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; + + const organization = new Organization(data); + + expect(organization.canManageDeviceApprovals).toBe(false); + }); + + it("should return false when ssoEnabled is false", () => { + data.type = OrganizationUserType.Admin; + data.useSso = true; + data.ssoEnabled = false; + data.ssoMemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; + + const organization = new Organization(data); + + expect(organization.canManageDeviceApprovals).toBe(false); + }); + + it("should return false when ssoMemberDecryptionType is not TrustedDeviceEncryption", () => { + data.type = OrganizationUserType.Admin; + data.useSso = true; + data.ssoEnabled = true; + data.ssoMemberDecryptionType = MemberDecryptionType.MasterPassword; + + const organization = new Organization(data); + + expect(organization.canManageDeviceApprovals).toBe(false); + }); + + it("should return true when admin has all required SSO settings enabled", () => { + data.type = OrganizationUserType.Admin; + data.useSso = true; + data.ssoEnabled = true; + data.ssoMemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; + + const organization = new Organization(data); + + expect(organization.canManageDeviceApprovals).toBe(true); + }); + + it("should return true when owner has all required SSO settings enabled", () => { + data.type = OrganizationUserType.Owner; + data.useSso = true; + data.ssoEnabled = true; + data.ssoMemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; + + const organization = new Organization(data); + + expect(organization.canManageDeviceApprovals).toBe(true); + }); + + it("should return true when user has manageResetPassword permission and all SSO settings enabled", () => { + data.type = OrganizationUserType.User; + data.useSso = true; + data.ssoEnabled = true; + data.ssoMemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; + data.permissions.manageResetPassword = true; + + const organization = new Organization(data); + + expect(organization.canManageDeviceApprovals).toBe(true); + }); + + it("should return true when provider user has all required SSO settings enabled", () => { + data.type = OrganizationUserType.User; + data.isProviderUser = true; + data.useSso = true; + data.ssoEnabled = true; + data.ssoMemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; + + const organization = new Organization(data); + + expect(organization.canManageDeviceApprovals).toBe(true); + }); + }); +}); diff --git a/libs/common/src/admin-console/models/domain/organization.ts b/libs/common/src/admin-console/models/domain/organization.ts index 130b32e519e..88895383876 100644 --- a/libs/common/src/admin-console/models/domain/organization.ts +++ b/libs/common/src/admin-console/models/domain/organization.ts @@ -2,6 +2,7 @@ // @ts-strict-ignore import { Jsonify } from "type-fest"; +import { MemberDecryptionType } from "../../../auth/enums/sso"; import { ProductTierType } from "../../../billing/enums"; import { OrganizationId } from "../../../types/guid"; import { OrganizationUserStatusType, OrganizationUserType, ProviderType } from "../../enums"; @@ -94,6 +95,8 @@ export class Organization { useRiskInsights: boolean; useAdminSponsoredFamilies: boolean; isAdminInitiated: boolean; + ssoEnabled: boolean; + ssoMemberDecryptionType?: MemberDecryptionType; constructor(obj?: OrganizationData) { if (obj == null) { @@ -155,6 +158,8 @@ export class Organization { this.useRiskInsights = obj.useRiskInsights; this.useAdminSponsoredFamilies = obj.useAdminSponsoredFamilies; this.isAdminInitiated = obj.isAdminInitiated; + this.ssoEnabled = obj.ssoEnabled; + this.ssoMemberDecryptionType = obj.ssoMemberDecryptionType; } get canAccess() { @@ -304,7 +309,12 @@ export class Organization { } get canManageDeviceApprovals() { - return (this.isAdmin || this.permissions.manageResetPassword) && this.useSso; + return ( + (this.isAdmin || this.permissions.manageResetPassword) && + this.useSso && + this.ssoEnabled && + this.ssoMemberDecryptionType === MemberDecryptionType.TrustedDeviceEncryption + ); } get isExemptFromPolicies() { diff --git a/libs/common/src/admin-console/models/response/profile-organization.response.ts b/libs/common/src/admin-console/models/response/profile-organization.response.ts index 6e451ce9808..53c3858ed1d 100644 --- a/libs/common/src/admin-console/models/response/profile-organization.response.ts +++ b/libs/common/src/admin-console/models/response/profile-organization.response.ts @@ -1,3 +1,4 @@ +import { MemberDecryptionType } from "../../../auth/enums/sso"; import { ProductTierType } from "../../../billing/enums"; import { BaseResponse } from "../../../models/response/base.response"; import { OrganizationUserStatusType, OrganizationUserType, ProviderType } from "../../enums"; @@ -58,6 +59,8 @@ export class ProfileOrganizationResponse extends BaseResponse { useRiskInsights: boolean; useAdminSponsoredFamilies: boolean; isAdminInitiated: boolean; + ssoEnabled: boolean; + ssoMemberDecryptionType?: MemberDecryptionType; constructor(response: any) { super(response); @@ -127,5 +130,7 @@ export class ProfileOrganizationResponse extends BaseResponse { this.useRiskInsights = this.getResponseProperty("UseRiskInsights"); this.useAdminSponsoredFamilies = this.getResponseProperty("UseAdminSponsoredFamilies"); this.isAdminInitiated = this.getResponseProperty("IsAdminInitiated"); + this.ssoEnabled = this.getResponseProperty("SsoEnabled") ?? false; + this.ssoMemberDecryptionType = this.getResponseProperty("SsoMemberDecryptionType"); } }