[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>
This commit is contained in:
Thomas Avery 2024-05-02 11:05:10 -05:00 committed by GitHub
parent 26988730b1
commit bdbb16ab4c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 289 additions and 83 deletions

View File

@ -67,3 +67,7 @@ export class ServiceAccountProjectPolicyPermissionDetailsView {
export class ServiceAccountGrantedPoliciesView {
grantedProjectPolicies: ServiceAccountProjectPolicyPermissionDetailsView[];
}
export class ProjectServiceAccountsAccessPoliciesView {
serviceAccountAccessPolicies: ServiceAccountProjectAccessPolicyView[];
}

View File

@ -1,17 +1,27 @@
<div class="tw-w-2/5">
<p class="tw-mt-8">
{{ "projectMachineAccountsDescription" | i18n }}
</p>
<sm-access-selector
[rows]="rows$ | async"
granteeType="serviceAccounts"
[label]="'machineAccounts' | i18n"
[hint]="'projectMachineAccountsSelectHint' | i18n"
[columnTitle]="'machineAccounts' | i18n"
[emptyMessage]="'projectEmptyMachineAccountAccessPolicies' | i18n"
(onCreateAccessPolicies)="handleCreateAccessPolicies($event)"
(onDeleteAccessPolicy)="handleDeleteAccessPolicy($event)"
(onUpdateAccessPolicy)="handleUpdateAccessPolicy($event)"
>
</sm-access-selector>
</div>
<form [formGroup]="formGroup" [bitSubmit]="submit" *ngIf="!loading; else spinner">
<div class="tw-w-2/5">
<p class="tw-mt-8" *ngIf="!loading">
{{ "projectMachineAccountsDescription" | i18n }}
</p>
<sm-access-policy-selector
[loading]="loading"
formControlName="accessPolicies"
[addButtonMode]="true"
[items]="items"
[label]="'machineAccounts' | i18n"
[hint]="'projectMachineAccountsSelectHint' | i18n"
[columnTitle]="'machineAccounts' | i18n"
[emptyMessage]="'projectEmptyMachineAccountAccessPolicies' | i18n"
>
</sm-access-policy-selector>
<button bitButton buttonType="primary" bitFormButton type="submit" class="tw-mt-7">
{{ "save" | i18n }}
</button>
</div>
</form>
<ng-template #spinner>
<div class="tw-items-center tw-justify-center tw-pt-64 tw-text-center">
<i class="bwi bwi-spinner bwi-spin bwi-3x"></i>
</div>
</ng-template>

View File

@ -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<void>();
private organizationId: string;
private projectId: string;
protected rows$: Observable<AccessSelectorRowView[]> =
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<ProjectServiceAccountsAccessPoliciesView> {
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;
}
}

View File

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

View File

@ -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[] {

View File

@ -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<ProjectServiceAccountsAccessPoliciesView> {
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<ProjectServiceAccountsAccessPoliciesView> {
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<ProjectServiceAccountsAccessPoliciesView> {
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;
}
}

View File

@ -0,0 +1,5 @@
import { AccessPolicyRequest } from "./access-policy.request";
export class ProjectServiceAccountsAccessPoliciesRequest {
serviceAccountAccessPolicyRequests?: AccessPolicyRequest[];
}

View File

@ -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),
);
}
}