+ {{ "scim" | i18n }}
+
+
+{{ "scimDescription" | i18n }}
+
+
+
+ {{ "loading" | i18n }}
+
+
diff --git a/bitwarden_license/bit-web/src/app/organizations/manage/scim.component.ts b/bitwarden_license/bit-web/src/app/organizations/manage/scim.component.ts
new file mode 100644
index 0000000000..26c93af18a
--- /dev/null
+++ b/bitwarden_license/bit-web/src/app/organizations/manage/scim.component.ts
@@ -0,0 +1,161 @@
+import { Component, OnInit } from "@angular/core";
+import { FormBuilder, FormControl } from "@angular/forms";
+import { ActivatedRoute } from "@angular/router";
+
+import { ApiService } from "@bitwarden/common/abstractions/api.service";
+import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service";
+import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
+import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
+import { OrganizationApiKeyType } from "@bitwarden/common/enums/organizationApiKeyType";
+import { OrganizationConnectionType } from "@bitwarden/common/enums/organizationConnectionType";
+import { ScimConfigApi } from "@bitwarden/common/models/api/scimConfigApi";
+import { OrganizationApiKeyRequest } from "@bitwarden/common/models/request/organizationApiKeyRequest";
+import { OrganizationConnectionRequest } from "@bitwarden/common/models/request/organizationConnectionRequest";
+import { ScimConfigRequest } from "@bitwarden/common/models/request/scimConfigRequest";
+import { OrganizationConnectionResponse } from "@bitwarden/common/models/response/organizationConnectionResponse";
+
+@Component({
+ selector: "app-org-manage-scim",
+ templateUrl: "scim.component.html",
+})
+export class ScimComponent implements OnInit {
+ loading = true;
+ organizationId: string;
+ existingConnectionId: string;
+ formPromise: Promise;
+ rotatePromise: Promise;
+ enabled = new FormControl(false);
+ showScimSettings = false;
+
+ formData = this.formBuilder.group({
+ endpointUrl: new FormControl({ value: "", disabled: true }),
+ clientSecret: new FormControl({ value: "", disabled: true }),
+ });
+
+ constructor(
+ private formBuilder: FormBuilder,
+ private route: ActivatedRoute,
+ private apiService: ApiService,
+ private platformUtilsService: PlatformUtilsService,
+ private i18nService: I18nService,
+ private environmentService: EnvironmentService
+ ) {}
+
+ async ngOnInit() {
+ this.route.parent.parent.params.subscribe(async (params) => {
+ this.organizationId = params.organizationId;
+ await this.load();
+ });
+ }
+
+ async load() {
+ const connection = await this.apiService.getOrganizationConnection(
+ this.organizationId,
+ OrganizationConnectionType.Scim,
+ ScimConfigApi
+ );
+ await this.setConnectionFormValues(connection);
+ }
+
+ async loadApiKey() {
+ const apiKeyRequest = new OrganizationApiKeyRequest();
+ apiKeyRequest.type = OrganizationApiKeyType.Scim;
+ apiKeyRequest.masterPasswordHash = "N/A";
+ const apiKeyResponse = await this.apiService.postOrganizationApiKey(
+ this.organizationId,
+ apiKeyRequest
+ );
+ this.formData.setValue({
+ endpointUrl: this.getScimEndpointUrl(),
+ clientSecret: apiKeyResponse.apiKey,
+ });
+ }
+
+ async copyScimUrl() {
+ this.platformUtilsService.copyToClipboard(this.getScimEndpointUrl());
+ }
+
+ async rotateScimKey() {
+ const confirmed = await this.platformUtilsService.showDialog(
+ this.i18nService.t("rotateScimKeyWarning"),
+ this.i18nService.t("rotateScimKey"),
+ this.i18nService.t("rotateKey"),
+ this.i18nService.t("cancel"),
+ "warning"
+ );
+ if (!confirmed) {
+ return false;
+ }
+
+ const request = new OrganizationApiKeyRequest();
+ request.type = OrganizationApiKeyType.Scim;
+ request.masterPasswordHash = "N/A";
+
+ this.rotatePromise = this.apiService.postOrganizationRotateApiKey(this.organizationId, request);
+
+ try {
+ const response = await this.rotatePromise;
+ this.formData.setValue({
+ endpointUrl: this.getScimEndpointUrl(),
+ clientSecret: response.apiKey,
+ });
+ this.platformUtilsService.showToast("success", null, this.i18nService.t("scimApiKeyRotated"));
+ } catch {
+ // Logged by appApiAction, do nothing
+ }
+
+ this.rotatePromise = null;
+ }
+
+ async copyScimKey() {
+ this.platformUtilsService.copyToClipboard(this.formData.get("clientSecret").value);
+ }
+
+ async submit() {
+ try {
+ const request = new OrganizationConnectionRequest(
+ this.organizationId,
+ OrganizationConnectionType.Scim,
+ true,
+ new ScimConfigRequest(this.enabled.value)
+ );
+ if (this.existingConnectionId == null) {
+ this.formPromise = this.apiService.createOrganizationConnection(request, ScimConfigApi);
+ } else {
+ this.formPromise = this.apiService.updateOrganizationConnection(
+ request,
+ ScimConfigApi,
+ this.existingConnectionId
+ );
+ }
+ const response = (await this.formPromise) as OrganizationConnectionResponse;
+ await this.setConnectionFormValues(response);
+ this.platformUtilsService.showToast("success", null, this.i18nService.t("scimSettingsSaved"));
+ } catch (e) {
+ // Logged by appApiAction, do nothing
+ }
+
+ this.formPromise = null;
+ }
+
+ getScimEndpointUrl() {
+ return this.environmentService.getScimUrl() + "/" + this.organizationId;
+ }
+
+ private async setConnectionFormValues(connection: OrganizationConnectionResponse) {
+ this.existingConnectionId = connection?.id;
+ if (connection !== null && connection.config?.enabled) {
+ this.showScimSettings = true;
+ this.enabled.setValue(true);
+ this.formData.setValue({
+ endpointUrl: this.getScimEndpointUrl(),
+ clientSecret: "",
+ });
+ await this.loadApiKey();
+ } else {
+ this.showScimSettings = false;
+ this.enabled.setValue(false);
+ }
+ this.loading = false;
+ }
+}
diff --git a/bitwarden_license/bit-web/src/app/organizations/organizations-routing.module.ts b/bitwarden_license/bit-web/src/app/organizations/organizations-routing.module.ts
index 0052f0d67b..1bfd51b7d3 100644
--- a/bitwarden_license/bit-web/src/app/organizations/organizations-routing.module.ts
+++ b/bitwarden_license/bit-web/src/app/organizations/organizations-routing.module.ts
@@ -9,6 +9,7 @@ import { OrganizationLayoutComponent } from "src/app/organizations/layouts/organ
import { ManageComponent } from "src/app/organizations/manage/manage.component";
import { NavigationPermissionsService } from "src/app/organizations/services/navigation-permissions.service";
+import { ScimComponent } from "./manage/scim.component";
import { SsoComponent } from "./manage/sso.component";
const routes: Routes = [
@@ -33,6 +34,14 @@ const routes: Routes = [
permissions: [Permissions.ManageSso],
},
},
+ {
+ path: "scim",
+ component: ScimComponent,
+ canActivate: [PermissionsGuard],
+ data: {
+ permissions: [Permissions.ManageScim],
+ },
+ },
],
},
],
diff --git a/bitwarden_license/bit-web/src/app/organizations/organizations.module.ts b/bitwarden_license/bit-web/src/app/organizations/organizations.module.ts
index 0833e2ef9d..1e9876b287 100644
--- a/bitwarden_license/bit-web/src/app/organizations/organizations.module.ts
+++ b/bitwarden_license/bit-web/src/app/organizations/organizations.module.ts
@@ -2,12 +2,13 @@ import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
-import { JslibModule } from "@bitwarden/angular/jslib.module";
+import { SharedModule } from "src/app/modules/shared.module";
import { InputCheckboxComponent } from "./components/input-checkbox.component";
import { InputTextReadOnlyComponent } from "./components/input-text-readonly.component";
import { InputTextComponent } from "./components/input-text.component";
import { SelectComponent } from "./components/select.component";
+import { ScimComponent } from "./manage/scim.component";
import { SsoComponent } from "./manage/sso.component";
import { OrganizationsRoutingModule } from "./organizations-routing.module";
@@ -18,7 +19,7 @@ import { OrganizationsRoutingModule } from "./organizations-routing.module";
CommonModule,
FormsModule,
ReactiveFormsModule,
- JslibModule,
+ SharedModule,
OrganizationsRoutingModule,
],
declarations: [
@@ -27,6 +28,7 @@ import { OrganizationsRoutingModule } from "./organizations-routing.module";
InputTextReadOnlyComponent,
SelectComponent,
SsoComponent,
+ ScimComponent,
],
})
export class OrganizationsModule {}
diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts
index 24517be522..365e3cd43d 100644
--- a/libs/common/src/abstractions/api.service.ts
+++ b/libs/common/src/abstractions/api.service.ts
@@ -1,3 +1,4 @@
+import { OrganizationApiKeyType } from "../enums/organizationApiKeyType";
import { OrganizationConnectionType } from "../enums/organizationConnectionType";
import { PolicyType } from "../enums/policyType";
import { SetKeyConnectorKeyRequest } from "../models/request/account/setKeyConnectorKeyRequest";
@@ -573,7 +574,8 @@ export abstract class ApiService {
request: OrganizationApiKeyRequest
) => Promise;
getOrganizationApiKeyInformation: (
- id: string
+ id: string,
+ type?: OrganizationApiKeyType
) => Promise>;
postOrganizationRotateApiKey: (
id: string,
diff --git a/libs/common/src/abstractions/environment.service.ts b/libs/common/src/abstractions/environment.service.ts
index 8398b4c634..84bceaa197 100644
--- a/libs/common/src/abstractions/environment.service.ts
+++ b/libs/common/src/abstractions/environment.service.ts
@@ -9,6 +9,7 @@ export type Urls = {
notifications?: string;
events?: string;
keyConnector?: string;
+ scim?: string;
};
export type PayPalConfig = {
@@ -28,6 +29,7 @@ export abstract class EnvironmentService {
getIdentityUrl: () => string;
getEventsUrl: () => string;
getKeyConnectorUrl: () => string;
+ getScimUrl: () => string;
setUrlsFromStorage: () => Promise;
setUrls: (urls: Urls) => Promise;
getUrls: () => Urls;
diff --git a/libs/common/src/enums/organizationApiKeyType.ts b/libs/common/src/enums/organizationApiKeyType.ts
index 27479a8de0..44ba7f8391 100644
--- a/libs/common/src/enums/organizationApiKeyType.ts
+++ b/libs/common/src/enums/organizationApiKeyType.ts
@@ -1,4 +1,5 @@
export enum OrganizationApiKeyType {
Default = 0,
BillingSync = 1,
+ Scim = 2,
}
diff --git a/libs/common/src/enums/organizationConnectionType.ts b/libs/common/src/enums/organizationConnectionType.ts
index 1291874370..d2f9700a6a 100644
--- a/libs/common/src/enums/organizationConnectionType.ts
+++ b/libs/common/src/enums/organizationConnectionType.ts
@@ -1,3 +1,4 @@
export enum OrganizationConnectionType {
CloudBillingSync = 1,
+ Scim = 2,
}
diff --git a/libs/common/src/enums/permissions.ts b/libs/common/src/enums/permissions.ts
index bb0021506a..46ee906620 100644
--- a/libs/common/src/enums/permissions.ts
+++ b/libs/common/src/enums/permissions.ts
@@ -25,4 +25,5 @@ export enum Permissions {
DeleteAssignedCollections,
ManageSso,
ManageBilling,
+ ManageScim,
}
diff --git a/libs/common/src/enums/scimProviderType.ts b/libs/common/src/enums/scimProviderType.ts
new file mode 100644
index 0000000000..43c518fdfb
--- /dev/null
+++ b/libs/common/src/enums/scimProviderType.ts
@@ -0,0 +1,9 @@
+export enum ScimProviderType {
+ Default = 0,
+ AzureAd = 1,
+ Okta = 2,
+ OneLogin = 3,
+ JumpCloud = 4,
+ GoogleWorkspace = 5,
+ Rippling = 6,
+}
diff --git a/libs/common/src/models/api/permissionsApi.ts b/libs/common/src/models/api/permissionsApi.ts
index bac79bd3cf..c35cb69cb5 100644
--- a/libs/common/src/models/api/permissionsApi.ts
+++ b/libs/common/src/models/api/permissionsApi.ts
@@ -25,6 +25,7 @@ export class PermissionsApi extends BaseResponse {
managePolicies: boolean;
manageUsers: boolean;
manageResetPassword: boolean;
+ manageScim: boolean;
constructor(data: any = null) {
super(data);
@@ -51,5 +52,6 @@ export class PermissionsApi extends BaseResponse {
this.managePolicies = this.getResponseProperty("ManagePolicies");
this.manageUsers = this.getResponseProperty("ManageUsers");
this.manageResetPassword = this.getResponseProperty("ManageResetPassword");
+ this.manageScim = this.getResponseProperty("ManageScim");
}
}
diff --git a/libs/common/src/models/api/scimConfigApi.ts b/libs/common/src/models/api/scimConfigApi.ts
new file mode 100644
index 0000000000..2269ac5969
--- /dev/null
+++ b/libs/common/src/models/api/scimConfigApi.ts
@@ -0,0 +1,17 @@
+import { ScimProviderType } from "@bitwarden/common/enums/scimProviderType";
+
+import { BaseResponse } from "../response/baseResponse";
+
+export class ScimConfigApi extends BaseResponse {
+ enabled: boolean;
+ scimProvider: ScimProviderType;
+
+ constructor(data: any) {
+ super(data);
+ if (data == null) {
+ return;
+ }
+ this.enabled = this.getResponseProperty("Enabled");
+ this.scimProvider = this.getResponseProperty("ScimProvider");
+ }
+}
diff --git a/libs/common/src/models/data/organizationData.ts b/libs/common/src/models/data/organizationData.ts
index d093821fc3..5d5e522959 100644
--- a/libs/common/src/models/data/organizationData.ts
+++ b/libs/common/src/models/data/organizationData.ts
@@ -19,6 +19,7 @@ export class OrganizationData {
useApi: boolean;
useSso: boolean;
useKeyConnector: boolean;
+ useScim: boolean;
useResetPassword: boolean;
selfHost: boolean;
usersGetPremium: boolean;
@@ -58,6 +59,7 @@ export class OrganizationData {
this.useApi = response.useApi;
this.useSso = response.useSso;
this.useKeyConnector = response.useKeyConnector;
+ this.useScim = response.useScim;
this.useResetPassword = response.useResetPassword;
this.selfHost = response.selfHost;
this.usersGetPremium = response.usersGetPremium;
diff --git a/libs/common/src/models/domain/organization.ts b/libs/common/src/models/domain/organization.ts
index ba1c8e1a17..c15d936234 100644
--- a/libs/common/src/models/domain/organization.ts
+++ b/libs/common/src/models/domain/organization.ts
@@ -20,6 +20,7 @@ export class Organization {
useApi: boolean;
useSso: boolean;
useKeyConnector: boolean;
+ useScim: boolean;
useResetPassword: boolean;
selfHost: boolean;
usersGetPremium: boolean;
@@ -63,6 +64,7 @@ export class Organization {
this.useApi = obj.useApi;
this.useSso = obj.useSso;
this.useKeyConnector = obj.useKeyConnector;
+ this.useScim = obj.useScim;
this.useResetPassword = obj.useResetPassword;
this.selfHost = obj.selfHost;
this.usersGetPremium = obj.usersGetPremium;
@@ -173,6 +175,10 @@ export class Organization {
return this.isAdmin || this.permissions.manageSso;
}
+ get canManageScim() {
+ return this.isAdmin || this.permissions.manageScim;
+ }
+
get canManagePolicies() {
return this.isAdmin || this.permissions.managePolicies;
}
@@ -207,6 +213,7 @@ export class Organization {
(permissions.includes(Permissions.ManageUsers) && this.canManageUsers) ||
(permissions.includes(Permissions.ManageUsersPassword) && this.canManageUsersPassword) ||
(permissions.includes(Permissions.ManageSso) && this.canManageSso) ||
+ (permissions.includes(Permissions.ManageScim) && this.canManageScim) ||
(permissions.includes(Permissions.ManageBilling) && this.canManageBilling);
return specifiedPermissions && (this.enabled || this.isOwner);
diff --git a/libs/common/src/models/request/organizationConnectionRequest.ts b/libs/common/src/models/request/organizationConnectionRequest.ts
index 69964cb09e..2506ac1d08 100644
--- a/libs/common/src/models/request/organizationConnectionRequest.ts
+++ b/libs/common/src/models/request/organizationConnectionRequest.ts
@@ -1,9 +1,10 @@
import { OrganizationConnectionType } from "../../enums/organizationConnectionType";
import { BillingSyncConfigRequest } from "./billingSyncConfigRequest";
+import { ScimConfigRequest } from "./scimConfigRequest";
/**API request config types for OrganizationConnectionRequest */
-export type OrganizationConnectionRequestConfigs = BillingSyncConfigRequest;
+export type OrganizationConnectionRequestConfigs = BillingSyncConfigRequest | ScimConfigRequest;
export class OrganizationConnectionRequest {
constructor(
diff --git a/libs/common/src/models/request/scimConfigRequest.ts b/libs/common/src/models/request/scimConfigRequest.ts
new file mode 100644
index 0000000000..7549661cdd
--- /dev/null
+++ b/libs/common/src/models/request/scimConfigRequest.ts
@@ -0,0 +1,5 @@
+import { ScimProviderType } from "@bitwarden/common/enums/scimProviderType";
+
+export class ScimConfigRequest {
+ constructor(private enabled: boolean, private scimProvider: ScimProviderType = null) {}
+}
diff --git a/libs/common/src/models/response/organizationConnectionResponse.ts b/libs/common/src/models/response/organizationConnectionResponse.ts
index a1e9a3de67..7f39d250f3 100644
--- a/libs/common/src/models/response/organizationConnectionResponse.ts
+++ b/libs/common/src/models/response/organizationConnectionResponse.ts
@@ -1,10 +1,11 @@
import { OrganizationConnectionType } from "../../enums/organizationConnectionType";
import { BillingSyncConfigApi } from "../api/billingSyncConfigApi";
+import { ScimConfigApi } from "../api/scimConfigApi";
import { BaseResponse } from "./baseResponse";
/**API response config types for OrganizationConnectionResponse */
-export type OrganizationConnectionConfigApis = BillingSyncConfigApi;
+export type OrganizationConnectionConfigApis = BillingSyncConfigApi | ScimConfigApi;
export class OrganizationConnectionResponse<
TConfig extends OrganizationConnectionConfigApis
diff --git a/libs/common/src/models/response/profileOrganizationResponse.ts b/libs/common/src/models/response/profileOrganizationResponse.ts
index 39b7644caa..0287ae96c9 100644
--- a/libs/common/src/models/response/profileOrganizationResponse.ts
+++ b/libs/common/src/models/response/profileOrganizationResponse.ts
@@ -17,6 +17,7 @@ export class ProfileOrganizationResponse extends BaseResponse {
useApi: boolean;
useSso: boolean;
useKeyConnector: boolean;
+ useScim: boolean;
useResetPassword: boolean;
selfHost: boolean;
usersGetPremium: boolean;
@@ -57,6 +58,7 @@ export class ProfileOrganizationResponse extends BaseResponse {
this.useApi = this.getResponseProperty("UseApi");
this.useSso = this.getResponseProperty("UseSso");
this.useKeyConnector = this.getResponseProperty("UseKeyConnector") ?? false;
+ this.useScim = this.getResponseProperty("UseScim") ?? false;
this.useResetPassword = this.getResponseProperty("UseResetPassword");
this.selfHost = this.getResponseProperty("SelfHost");
this.usersGetPremium = this.getResponseProperty("UsersGetPremium");
diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts
index ab41555d9c..35b7d336d9 100644
--- a/libs/common/src/services/api.service.ts
+++ b/libs/common/src/services/api.service.ts
@@ -4,6 +4,7 @@ import { EnvironmentService } from "../abstractions/environment.service";
import { PlatformUtilsService } from "../abstractions/platformUtils.service";
import { TokenService } from "../abstractions/token.service";
import { DeviceType } from "../enums/deviceType";
+import { OrganizationApiKeyType } from "../enums/organizationApiKeyType";
import { OrganizationConnectionType } from "../enums/organizationConnectionType";
import { PolicyType } from "../enums/policyType";
import { Utils } from "../misc/utils";
@@ -1822,15 +1823,14 @@ export class ApiService implements ApiServiceAbstraction {
}
async getOrganizationApiKeyInformation(
- id: string
+ id: string,
+ type: OrganizationApiKeyType = null
): Promise> {
- const r = await this.send(
- "GET",
- "/organizations/" + id + "/api-key-information",
- null,
- true,
- true
- );
+ const uri =
+ type === null
+ ? "/organizations/" + id + "/api-key-information"
+ : "/organizations/" + id + "/api-key-information/" + type;
+ const r = await this.send("GET", uri, null, true, true);
return new ListResponse(r, OrganizationApiKeyInformationResponse);
}
diff --git a/libs/common/src/services/environment.service.ts b/libs/common/src/services/environment.service.ts
index b13afd59ac..c22d7d85e6 100644
--- a/libs/common/src/services/environment.service.ts
+++ b/libs/common/src/services/environment.service.ts
@@ -19,6 +19,7 @@ export class EnvironmentService implements EnvironmentServiceAbstraction {
private notificationsUrl: string;
private eventsUrl: string;
private keyConnectorUrl: string;
+ private scimUrl: string = null;
constructor(private stateService: StateService) {
this.stateService.activeAccount.subscribe(async () => {
@@ -111,6 +112,16 @@ export class EnvironmentService implements EnvironmentServiceAbstraction {
return this.keyConnectorUrl;
}
+ getScimUrl() {
+ if (this.scimUrl != null) {
+ return this.scimUrl + "/v2";
+ }
+
+ return this.getWebVaultUrl() === "https://vault.bitwarden.com"
+ ? "https://scim.bitwarden.com/v2"
+ : this.getWebVaultUrl() + "/scim/v2";
+ }
+
async setUrlsFromStorage(): Promise {
const urls: any = await this.stateService.getEnvironmentUrls();
const envUrls = new EnvironmentUrls();
@@ -123,6 +134,7 @@ export class EnvironmentService implements EnvironmentServiceAbstraction {
this.notificationsUrl = urls.notifications;
this.eventsUrl = envUrls.events = urls.events;
this.keyConnectorUrl = urls.keyConnector;
+ // scimUrl is not saved to storage
}
async setUrls(urls: Urls): Promise {
@@ -135,6 +147,9 @@ export class EnvironmentService implements EnvironmentServiceAbstraction {
urls.events = this.formatUrl(urls.events);
urls.keyConnector = this.formatUrl(urls.keyConnector);
+ // scimUrl cannot be cleared
+ urls.scim = this.formatUrl(urls.scim) ?? this.scimUrl;
+
await this.stateService.setEnvironmentUrls({
base: urls.base,
api: urls.api,
@@ -144,6 +159,7 @@ export class EnvironmentService implements EnvironmentServiceAbstraction {
notifications: urls.notifications,
events: urls.events,
keyConnector: urls.keyConnector,
+ // scimUrl is not saved to storage
});
this.baseUrl = urls.base;
@@ -154,6 +170,7 @@ export class EnvironmentService implements EnvironmentServiceAbstraction {
this.notificationsUrl = urls.notifications;
this.eventsUrl = urls.events;
this.keyConnectorUrl = urls.keyConnector;
+ this.scimUrl = urls.scim;
this.urlsSubject.next(urls);
@@ -170,6 +187,7 @@ export class EnvironmentService implements EnvironmentServiceAbstraction {
notifications: this.notificationsUrl,
events: this.eventsUrl,
keyConnector: this.keyConnectorUrl,
+ scim: this.scimUrl,
};
}