From bdbb16ab4c150460f11663f5b47b209d3543be10 Mon Sep 17 00:00:00 2001
From: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com>
Date: Thu, 2 May 2024 11:05:10 -0500
Subject: [PATCH] [SM-923] Migrate Project -> Service Accounts access policy
selector (#8789)
* Add request and response models
* Add view
* Add support in ap item types
* Add new endpoints to the access policy service
* Migrate to access policy selector
---------
Co-authored-by: cd-bitwarden <106776772+cd-bitwarden@users.noreply.github.com>
---
.../models/view/access-policy.view.ts | 4 +
.../project-service-accounts.component.html | 44 ++--
.../project-service-accounts.component.ts | 195 ++++++++++++------
.../models/ap-item-value.type.ts | 21 ++
.../models/ap-item-view.type.ts | 24 +++
.../access-policies/access-policy.service.ts | 64 ++++++
...ervice-accounts-access-policies.request.ts | 5 +
...rvice-accounts-access-policies.response.ts | 15 ++
8 files changed, 289 insertions(+), 83 deletions(-)
create mode 100644 bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/models/requests/project-service-accounts-access-policies.request.ts
create mode 100644 bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/models/responses/project-service-accounts-access-policies.response.ts
diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/models/view/access-policy.view.ts b/bitwarden_license/bit-web/src/app/secrets-manager/models/view/access-policy.view.ts
index 18b6994459..6c005a1225 100644
--- a/bitwarden_license/bit-web/src/app/secrets-manager/models/view/access-policy.view.ts
+++ b/bitwarden_license/bit-web/src/app/secrets-manager/models/view/access-policy.view.ts
@@ -67,3 +67,7 @@ export class ServiceAccountProjectPolicyPermissionDetailsView {
export class ServiceAccountGrantedPoliciesView {
grantedProjectPolicies: ServiceAccountProjectPolicyPermissionDetailsView[];
}
+
+export class ProjectServiceAccountsAccessPoliciesView {
+ serviceAccountAccessPolicies: ServiceAccountProjectAccessPolicyView[];
+}
diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-service-accounts.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-service-accounts.component.html
index 443711fd36..5d22358277 100644
--- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-service-accounts.component.html
+++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-service-accounts.component.html
@@ -1,17 +1,27 @@
-
-
- {{ "projectMachineAccountsDescription" | i18n }}
-
-
-
-
+
+
+
+
+
+
+
diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-service-accounts.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-service-accounts.component.ts
index 1521bb742d..668bdbae43 100644
--- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-service-accounts.component.ts
+++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-service-accounts.component.ts
@@ -1,93 +1,69 @@
-import { Component, OnDestroy, OnInit } from "@angular/core";
+import { ChangeDetectorRef, Component, OnDestroy, OnInit } from "@angular/core";
+import { FormControl, FormGroup } from "@angular/forms";
import { ActivatedRoute } from "@angular/router";
-import { map, Observable, startWith, Subject, switchMap, takeUntil } from "rxjs";
+import { combineLatest, Subject, switchMap, takeUntil } from "rxjs";
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
-import { SelectItemView } from "@bitwarden/components";
+import { ProjectServiceAccountsAccessPoliciesView } from "../../models/view/access-policy.view";
import {
- ProjectAccessPoliciesView,
- ServiceAccountProjectAccessPolicyView,
-} from "../../models/view/access-policy.view";
+ ApItemValueType,
+ convertToProjectServiceAccountsAccessPoliciesView,
+} from "../../shared/access-policies/access-policy-selector/models/ap-item-value.type";
+import {
+ ApItemViewType,
+ convertPotentialGranteesToApItemViewType,
+ convertProjectServiceAccountsViewToApItemViews,
+} from "../../shared/access-policies/access-policy-selector/models/ap-item-view.type";
import { AccessPolicyService } from "../../shared/access-policies/access-policy.service";
-import {
- AccessSelectorComponent,
- AccessSelectorRowView,
-} from "../../shared/access-policies/access-selector.component";
@Component({
selector: "sm-project-service-accounts",
templateUrl: "./project-service-accounts.component.html",
})
export class ProjectServiceAccountsComponent implements OnInit, OnDestroy {
+ private currentAccessPolicies: ApItemViewType[];
private destroy$ = new Subject();
private organizationId: string;
private projectId: string;
- protected rows$: Observable =
- this.accessPolicyService.projectAccessPolicyChanges$.pipe(
- startWith(null),
- switchMap(() =>
- this.accessPolicyService.getProjectAccessPolicies(this.organizationId, this.projectId),
- ),
- map((policies) =>
- policies.serviceAccountAccessPolicies.map((policy) => ({
- type: "serviceAccount",
- name: policy.serviceAccountName,
- id: policy.serviceAccountId,
- accessPolicyId: policy.id,
- read: policy.read,
- write: policy.write,
- icon: AccessSelectorComponent.serviceAccountIcon,
- static: false,
- })),
- ),
- );
+ private currentAccessPolicies$ = combineLatest([this.route.params]).pipe(
+ switchMap(([params]) =>
+ this.accessPolicyService
+ .getProjectServiceAccountsAccessPolicies(params.organizationId, params.projectId)
+ .then((policies) => {
+ return convertProjectServiceAccountsViewToApItemViews(policies);
+ }),
+ ),
+ );
- protected async handleUpdateAccessPolicy(policy: AccessSelectorRowView) {
- try {
- return await this.accessPolicyService.updateAccessPolicy(
- AccessSelectorComponent.getBaseAccessPolicyView(policy),
- );
- } catch (e) {
- this.validationService.showError(e);
- }
- }
+ private potentialGrantees$ = combineLatest([this.route.params]).pipe(
+ switchMap(([params]) =>
+ this.accessPolicyService
+ .getServiceAccountsPotentialGrantees(params.organizationId)
+ .then((grantees) => {
+ return convertPotentialGranteesToApItemViewType(grantees);
+ }),
+ ),
+ );
- protected handleCreateAccessPolicies(selected: SelectItemView[]) {
- const projectAccessPoliciesView = new ProjectAccessPoliciesView();
- projectAccessPoliciesView.serviceAccountAccessPolicies = selected
- .filter(
- (selection) => AccessSelectorComponent.getAccessItemType(selection) === "serviceAccount",
- )
- .map((filtered) => {
- const view = new ServiceAccountProjectAccessPolicyView();
- view.grantedProjectId = this.projectId;
- view.serviceAccountId = filtered.id;
- view.read = true;
- view.write = false;
- return view;
- });
+ protected formGroup = new FormGroup({
+ accessPolicies: new FormControl([] as ApItemValueType[]),
+ });
- return this.accessPolicyService.createProjectAccessPolicies(
- this.organizationId,
- this.projectId,
- projectAccessPoliciesView,
- );
- }
-
- protected async handleDeleteAccessPolicy(policy: AccessSelectorRowView) {
- try {
- await this.accessPolicyService.deleteAccessPolicy(policy.accessPolicyId);
- } catch (e) {
- this.validationService.showError(e);
- }
- }
+ protected loading = true;
+ protected potentialGrantees: ApItemViewType[];
+ protected items: ApItemViewType[];
constructor(
private route: ActivatedRoute,
+ private changeDetectorRef: ChangeDetectorRef,
private validationService: ValidationService,
private accessPolicyService: AccessPolicyService,
+ private platformUtilsService: PlatformUtilsService,
+ private i18nService: I18nService,
) {}
ngOnInit(): void {
@@ -95,10 +71,97 @@ export class ProjectServiceAccountsComponent implements OnInit, OnDestroy {
this.organizationId = params.organizationId;
this.projectId = params.projectId;
});
+
+ combineLatest([this.potentialGrantees$, this.currentAccessPolicies$])
+ .pipe(takeUntil(this.destroy$))
+ .subscribe(([potentialGrantees, currentAccessPolicies]) => {
+ this.potentialGrantees = potentialGrantees;
+ this.items = this.getItems(potentialGrantees, currentAccessPolicies);
+ this.setSelected(currentAccessPolicies);
+ });
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
+
+ submit = async () => {
+ if (this.isFormInvalid()) {
+ return;
+ }
+ const formValues = this.formGroup.value.accessPolicies;
+ this.formGroup.disable();
+
+ try {
+ const accessPoliciesView = await this.updateProjectServiceAccountsAccessPolicies(
+ this.organizationId,
+ this.projectId,
+ formValues,
+ );
+
+ const updatedView = convertProjectServiceAccountsViewToApItemViews(accessPoliciesView);
+ this.items = this.getItems(this.potentialGrantees, updatedView);
+ this.setSelected(updatedView);
+
+ this.platformUtilsService.showToast(
+ "success",
+ null,
+ this.i18nService.t("projectAccessUpdated"),
+ );
+ } catch (e) {
+ this.validationService.showError(e);
+ this.setSelected(this.currentAccessPolicies);
+ }
+ this.formGroup.enable();
+ };
+
+ private setSelected(policiesToSelect: ApItemViewType[]) {
+ this.loading = true;
+ this.currentAccessPolicies = policiesToSelect;
+ if (policiesToSelect != undefined) {
+ // Must detect changes so that AccessSelector @Inputs() are aware of the latest
+ // potentialGrantees, otherwise no selected values will be patched below
+ this.changeDetectorRef.detectChanges();
+ this.formGroup.patchValue({
+ accessPolicies: policiesToSelect.map((m) => ({
+ type: m.type,
+ id: m.id,
+ permission: m.permission,
+ })),
+ });
+ }
+ this.loading = false;
+ }
+
+ private isFormInvalid(): boolean {
+ this.formGroup.markAllAsTouched();
+ return this.formGroup.invalid;
+ }
+
+ private async updateProjectServiceAccountsAccessPolicies(
+ organizationId: string,
+ projectId: string,
+ selectedPolicies: ApItemValueType[],
+ ): Promise {
+ const view = convertToProjectServiceAccountsAccessPoliciesView(projectId, selectedPolicies);
+ return await this.accessPolicyService.putProjectServiceAccountsAccessPolicies(
+ organizationId,
+ projectId,
+ view,
+ );
+ }
+
+ private getItems(potentialGrantees: ApItemViewType[], currentAccessPolicies: ApItemViewType[]) {
+ // If the user doesn't have access to the service account, they won't be in the potentialGrantees list.
+ // Add them to the potentialGrantees list if they are selected.
+ const items = [...potentialGrantees];
+ for (const policy of currentAccessPolicies) {
+ const exists = potentialGrantees.some((grantee) => grantee.id === policy.id);
+ if (!exists) {
+ items.push(policy);
+ }
+ }
+ return items;
+ }
}
diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/models/ap-item-value.type.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/models/ap-item-value.type.ts
index 37c9f5523a..237fa2f323 100644
--- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/models/ap-item-value.type.ts
+++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/models/ap-item-value.type.ts
@@ -8,6 +8,7 @@ import {
ServiceAccountGrantedPoliciesView,
ServiceAccountProjectPolicyPermissionDetailsView,
ServiceAccountProjectAccessPolicyView,
+ ProjectServiceAccountsAccessPoliciesView,
} from "../../../../models/view/access-policy.view";
import { ApItemEnum } from "./enums/ap-item.enum";
@@ -102,3 +103,23 @@ export function convertToServiceAccountGrantedPoliciesView(
return view;
}
+
+export function convertToProjectServiceAccountsAccessPoliciesView(
+ projectId: string,
+ selectedPolicyValues: ApItemValueType[],
+): ProjectServiceAccountsAccessPoliciesView {
+ const view = new ProjectServiceAccountsAccessPoliciesView();
+
+ view.serviceAccountAccessPolicies = selectedPolicyValues
+ .filter((x) => x.type == ApItemEnum.ServiceAccount)
+ .map((filtered) => {
+ const policyView = new ServiceAccountProjectAccessPolicyView();
+ policyView.serviceAccountId = filtered.id;
+ policyView.grantedProjectId = projectId;
+ policyView.read = ApPermissionEnumUtil.toRead(filtered.permission);
+ policyView.write = ApPermissionEnumUtil.toWrite(filtered.permission);
+ return policyView;
+ });
+
+ return view;
+}
diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/models/ap-item-view.type.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/models/ap-item-view.type.ts
index 996818001d..07e08afcf9 100644
--- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/models/ap-item-view.type.ts
+++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/models/ap-item-view.type.ts
@@ -4,6 +4,7 @@ import { SelectItemView } from "@bitwarden/components";
import {
ProjectPeopleAccessPoliciesView,
ServiceAccountGrantedPoliciesView,
+ ProjectServiceAccountsAccessPoliciesView,
ServiceAccountPeopleAccessPoliciesView,
} from "../../../../models/view/access-policy.view";
import { PotentialGranteeView } from "../../../../models/view/potential-grantee.view";
@@ -98,6 +99,29 @@ export function convertGrantedPoliciesToAccessPolicyItemViews(
return accessPolicies;
}
+export function convertProjectServiceAccountsViewToApItemViews(
+ value: ProjectServiceAccountsAccessPoliciesView,
+): ApItemViewType[] {
+ const accessPolicies: ApItemViewType[] = [];
+
+ value.serviceAccountAccessPolicies.forEach((accessPolicyView) => {
+ accessPolicies.push({
+ type: ApItemEnum.ServiceAccount,
+ icon: ApItemEnumUtil.itemIcon(ApItemEnum.ServiceAccount),
+ id: accessPolicyView.serviceAccountId,
+ accessPolicyId: accessPolicyView.id,
+ labelName: accessPolicyView.serviceAccountName,
+ listName: accessPolicyView.serviceAccountName,
+ permission: ApPermissionEnumUtil.toApPermissionEnum(
+ accessPolicyView.read,
+ accessPolicyView.write,
+ ),
+ readOnly: false,
+ });
+ });
+ return accessPolicies;
+}
+
export function convertPotentialGranteesToApItemViewType(
grantees: PotentialGranteeView[],
): ApItemViewType[] {
diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy.service.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy.service.ts
index 967bbf7ed0..98684e3a60 100644
--- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy.service.ts
+++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy.service.ts
@@ -19,6 +19,7 @@ import {
UserServiceAccountAccessPolicyView,
ServiceAccountPeopleAccessPoliciesView,
ServiceAccountGrantedPoliciesView,
+ ProjectServiceAccountsAccessPoliciesView,
ServiceAccountProjectPolicyPermissionDetailsView,
} from "../../models/view/access-policy.view";
import { PotentialGranteeView } from "../../models/view/potential-grantee.view";
@@ -29,6 +30,7 @@ import { ServiceAccountGrantedPoliciesRequest } from "../access-policies/models/
import { AccessPolicyUpdateRequest } from "./models/requests/access-policy-update.request";
import { AccessPolicyRequest } from "./models/requests/access-policy.request";
+import { ProjectServiceAccountsAccessPoliciesRequest } from "./models/requests/project-service-accounts-access-policies.request";
import {
GroupServiceAccountAccessPolicyResponse,
UserServiceAccountAccessPolicyResponse,
@@ -38,6 +40,7 @@ import {
} from "./models/responses/access-policy.response";
import { PotentialGranteeResponse } from "./models/responses/potential-grantee.response";
import { ProjectPeopleAccessPoliciesResponse } from "./models/responses/project-people-access-policies.response";
+import { ProjectServiceAccountsAccessPoliciesResponse } from "./models/responses/project-service-accounts-access-policies.response";
import { ServiceAccountGrantedPoliciesPermissionDetailsResponse } from "./models/responses/service-account-granted-policies-permission-details.response";
import { ServiceAccountPeopleAccessPoliciesResponse } from "./models/responses/service-account-people-access-policies.response";
import { ServiceAccountProjectPolicyPermissionDetailsResponse } from "./models/responses/service-account-project-policy-permission-details.response";
@@ -175,6 +178,40 @@ export class AccessPolicyService {
return await this.createServiceAccountGrantedPoliciesView(result, organizationId);
}
+ async getProjectServiceAccountsAccessPolicies(
+ organizationId: string,
+ projectId: string,
+ ): Promise {
+ const r = await this.apiService.send(
+ "GET",
+ "/projects/" + projectId + "/access-policies/service-accounts",
+ null,
+ true,
+ true,
+ );
+
+ const result = new ProjectServiceAccountsAccessPoliciesResponse(r);
+ return await this.createProjectServiceAccountsAccessPoliciesView(result, organizationId);
+ }
+
+ async putProjectServiceAccountsAccessPolicies(
+ organizationId: string,
+ projectId: string,
+ policies: ProjectServiceAccountsAccessPoliciesView,
+ ): Promise {
+ const request = this.getProjectServiceAccountsAccessPoliciesRequest(policies);
+ const r = await this.apiService.send(
+ "PUT",
+ "/projects/" + projectId + "/access-policies/service-accounts",
+ request,
+ true,
+ true,
+ );
+
+ const result = new ProjectServiceAccountsAccessPoliciesResponse(r);
+ return await this.createProjectServiceAccountsAccessPoliciesView(result, organizationId);
+ }
+
async createProjectAccessPolicies(
organizationId: string,
projectId: string,
@@ -325,6 +362,18 @@ export class AccessPolicyService {
return request;
}
+ private getProjectServiceAccountsAccessPoliciesRequest(
+ policies: ProjectServiceAccountsAccessPoliciesView,
+ ): ProjectServiceAccountsAccessPoliciesRequest {
+ const request = new ProjectServiceAccountsAccessPoliciesRequest();
+
+ request.serviceAccountAccessPolicyRequests = policies.serviceAccountAccessPolicies.map((ap) => {
+ return this.getAccessPolicyRequest(ap.serviceAccountId, ap);
+ });
+
+ return request;
+ }
+
private async createServiceAccountGrantedPoliciesView(
response: ServiceAccountGrantedPoliciesPermissionDetailsResponse,
organizationId: string,
@@ -535,4 +584,19 @@ export class AccessPolicyService {
currentUserInGroup: response.currentUserInGroup,
};
}
+
+ private async createProjectServiceAccountsAccessPoliciesView(
+ response: ProjectServiceAccountsAccessPoliciesResponse,
+ organizationId: string,
+ ): Promise {
+ const orgKey = await this.getOrganizationKey(organizationId);
+
+ const view = new ProjectServiceAccountsAccessPoliciesView();
+ view.serviceAccountAccessPolicies = await Promise.all(
+ response.serviceAccountAccessPolicies.map(async (ap) => {
+ return await this.createServiceAccountProjectAccessPolicyView(orgKey, ap);
+ }),
+ );
+ return view;
+ }
}
diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/models/requests/project-service-accounts-access-policies.request.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/models/requests/project-service-accounts-access-policies.request.ts
new file mode 100644
index 0000000000..e287775cd3
--- /dev/null
+++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/models/requests/project-service-accounts-access-policies.request.ts
@@ -0,0 +1,5 @@
+import { AccessPolicyRequest } from "./access-policy.request";
+
+export class ProjectServiceAccountsAccessPoliciesRequest {
+ serviceAccountAccessPolicyRequests?: AccessPolicyRequest[];
+}
diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/models/responses/project-service-accounts-access-policies.response.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/models/responses/project-service-accounts-access-policies.response.ts
new file mode 100644
index 0000000000..f26a9996dd
--- /dev/null
+++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/models/responses/project-service-accounts-access-policies.response.ts
@@ -0,0 +1,15 @@
+import { BaseResponse } from "@bitwarden/common/models/response/base.response";
+
+import { ServiceAccountProjectAccessPolicyResponse } from "./access-policy.response";
+
+export class ProjectServiceAccountsAccessPoliciesResponse extends BaseResponse {
+ serviceAccountAccessPolicies: ServiceAccountProjectAccessPolicyResponse[];
+
+ constructor(response: any) {
+ super(response);
+ const serviceAccountAccessPolicies = this.getResponseProperty("ServiceAccountAccessPolicies");
+ this.serviceAccountAccessPolicies = serviceAccountAccessPolicies.map(
+ (k: any) => new ServiceAccountProjectAccessPolicyResponse(k),
+ );
+ }
+}