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 }} -

- - -
+
+
+

+ {{ "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), + ); + } +}