1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-09-27 04:03:00 +02:00

[SM-352] Projects tab for service accounts (#4858)

* Init service layer changes

* refactor service to inherit abstract

* refactor access-selector component

* update access selector in projects

* add service accounts access selector

* update i18n

* fix delete action; use useExisting in providers

* update static permissions

* service account people should be readwrite on creation

* use setter instead of observable input

* remove warning callout

* remove abstract service

* truncate name in table

* remove extra comments

* Add projects access policy page

* Add locale

* use map instead of forEach

* refactor view factories

* update SA people copy

* map list responses

* Swap to using granted policies endpoints

* Remove text-xl from icon

---------

Co-authored-by: Thomas Avery <tavery@bitwarden.com>
Co-authored-by: William Martin <contact@willmartian.com>
Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com>
This commit is contained in:
Oscar Hinton 2023-02-28 16:31:19 +01:00 committed by GitHub
parent 4e112573f5
commit 6348269a1a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 249 additions and 19 deletions

View File

@ -5864,7 +5864,7 @@
"message": "Service account name"
},
"newSaSelectAccess":{
"message": "Type or select projects or secrets"
"message": "Type or select projects"
},
"newSaTypeToFilter":{
"message": "Type to filter"
@ -6365,6 +6365,12 @@
"serviceAccountPeopleDescription": {
"message": "Grant groups or people access to this service account."
},
"serviceAccountProjectsDescription": {
"message": "Assign projects to this service account. "
},
"serviceAccountEmptyProjectAccesspolicies": {
"message": "Add projects to grant access"
},
"canWrite": {
"message": "Can write"
},

View File

@ -34,6 +34,7 @@ export class ServiceAccountProjectAccessPolicyView extends BaseAccessPolicyView
serviceAccountId: string;
serviceAccountName: string;
grantedProjectId: string;
grantedProjectName: string;
}
export class ProjectAccessPoliciesView {

View File

@ -36,7 +36,7 @@ export class ProjectPeopleComponent implements OnInit, OnDestroy {
rows.push({
type: "user",
name: policy.organizationUserName,
granteeId: policy.organizationUserId,
id: policy.organizationUserId,
accessPolicyId: policy.id,
read: policy.read,
write: policy.write,
@ -48,7 +48,7 @@ export class ProjectPeopleComponent implements OnInit, OnDestroy {
rows.push({
type: "group",
name: policy.groupName,
granteeId: policy.groupId,
id: policy.groupId,
accessPolicyId: policy.id,
read: policy.read,
write: policy.write,

View File

@ -33,7 +33,7 @@ export class ProjectServiceAccountsComponent implements OnInit, OnDestroy {
policies.serviceAccountAccessPolicies.map((policy) => ({
type: "serviceAccount",
name: policy.serviceAccountName,
granteeId: policy.serviceAccountId,
id: policy.serviceAccountId,
accessPolicyId: policy.id,
read: policy.read,
write: policy.write,

View File

@ -36,7 +36,7 @@ export class ServiceAccountPeopleComponent {
rows.push({
type: "user",
name: policy.organizationUserName,
granteeId: policy.organizationUserId,
id: policy.organizationUserId,
accessPolicyId: policy.id,
read: policy.read,
write: policy.write,
@ -49,7 +49,7 @@ export class ServiceAccountPeopleComponent {
rows.push({
type: "group",
name: policy.groupName,
granteeId: policy.groupId,
id: policy.groupId,
accessPolicyId: policy.id,
read: policy.read,
write: policy.write,

View File

@ -0,0 +1,15 @@
<div class="tw-mt-4 tw-w-2/5">
<p class="tw-mt-6">
{{ "serviceAccountProjectsDescription" | i18n }}
</p>
<sm-access-selector
[rows]="rows$ | async"
granteeType="projects"
[label]="'projects' | i18n"
[hint]="'newSaSelectAccess' | i18n"
[columnTitle]="'projects' | i18n"
[emptyMessage]="'serviceAccountEmptyProjectAccesspolicies' | i18n"
(onCreateAccessPolicies)="handleCreateAccessPolicies($event)"
>
</sm-access-selector>
</div>

View File

@ -0,0 +1,78 @@
import { Component } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { combineLatestWith, map, Observable, startWith, Subject, switchMap, takeUntil } from "rxjs";
import { SelectItemView } from "@bitwarden/components/src/multi-select/models/select-item-view";
import { ServiceAccountProjectAccessPolicyView } from "../../models/view/access-policy.view";
import { AccessPolicyService } from "../../shared/access-policies/access-policy.service";
import {
AccessSelectorComponent,
AccessSelectorRowView,
} from "../../shared/access-policies/access-selector.component";
@Component({
selector: "sm-service-account-projects",
templateUrl: "./service-account-projects.component.html",
})
export class ServiceAccountProjectsComponent {
private destroy$ = new Subject<void>();
private serviceAccountId: string;
private organizationId: string;
protected rows$: Observable<AccessSelectorRowView[]> =
this.accessPolicyService.serviceAccountGrantedPolicyChanges$.pipe(
startWith(null),
combineLatestWith(this.route.params),
switchMap(([_, params]) =>
this.accessPolicyService.getGrantedPolicies(params.serviceAccountId, params.organizationId)
),
map((policies) => {
return policies.map((policy) => {
return {
type: "project",
name: policy.grantedProjectName,
id: policy.grantedProjectId,
accessPolicyId: policy.id,
read: policy.read,
write: policy.write,
icon: AccessSelectorComponent.projectIcon,
static: true,
} as AccessSelectorRowView;
});
})
);
protected handleCreateAccessPolicies(selected: SelectItemView[]) {
const serviceAccountProjectAccessPolicyView = selected
.filter((selection) => AccessSelectorComponent.getAccessItemType(selection) === "project")
.map((filtered) => {
const view = new ServiceAccountProjectAccessPolicyView();
view.serviceAccountId = this.serviceAccountId;
view.grantedProjectId = filtered.id;
view.read = true;
view.write = false;
return view;
});
return this.accessPolicyService.createGrantedPolicies(
this.organizationId,
this.serviceAccountId,
serviceAccountProjectAccessPolicyView
);
}
constructor(private route: ActivatedRoute, private accessPolicyService: AccessPolicyService) {}
ngOnInit(): void {
this.route.params.pipe(takeUntil(this.destroy$)).subscribe((params) => {
this.organizationId = params.organizationId;
this.serviceAccountId = params.serviceAccountId;
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@ -10,7 +10,7 @@
</bit-breadcrumbs>
<sm-new-menu></sm-new-menu>
<bit-tab-nav-bar label="Main" slot="tabs">
<bit-tab-link [route]="['secrets']">{{ "secrets" | i18n }}</bit-tab-link>
<bit-tab-link [route]="['projects']">{{ "projects" | i18n }}</bit-tab-link>
<bit-tab-link [route]="['people']">{{ "people" | i18n }}</bit-tab-link>
<bit-tab-link [route]="['access']">{{ "accessTokens" | i18n }}</bit-tab-link>
</bit-tab-nav-bar>

View File

@ -3,6 +3,7 @@ import { RouterModule, Routes } from "@angular/router";
import { AccessTokenComponent } from "./access/access-tokens.component";
import { ServiceAccountPeopleComponent } from "./people/service-account-people.component";
import { ServiceAccountProjectsComponent } from "./projects/service-account-projects.component";
import { ServiceAccountComponent } from "./service-account.component";
import { ServiceAccountsComponent } from "./service-accounts.component";
@ -18,7 +19,7 @@ const routes: Routes = [
{
path: "",
pathMatch: "full",
redirectTo: "access",
redirectTo: "projects",
},
{
path: "access",
@ -28,6 +29,10 @@ const routes: Routes = [
path: "people",
component: ServiceAccountPeopleComponent,
},
{
path: "projects",
component: ServiceAccountProjectsComponent,
},
],
},
];

View File

@ -11,6 +11,7 @@ import { AccessTokenDialogComponent } from "./access/dialogs/access-token-dialog
import { ExpirationOptionsComponent } from "./access/dialogs/expiration-options.component";
import { ServiceAccountDialogComponent } from "./dialog/service-account-dialog.component";
import { ServiceAccountPeopleComponent } from "./people/service-account-people.component";
import { ServiceAccountProjectsComponent } from "./projects/service-account-projects.component";
import { ServiceAccountComponent } from "./service-account.component";
import { ServiceAccountsListComponent } from "./service-accounts-list.component";
import { ServiceAccountsRoutingModule } from "./service-accounts-routing.module";
@ -20,12 +21,14 @@ import { ServiceAccountsComponent } from "./service-accounts.component";
imports: [SecretsManagerSharedModule, ServiceAccountsRoutingModule, BreadcrumbsModule],
declarations: [
AccessListComponent,
ExpirationOptionsComponent,
AccessTokenComponent,
AccessTokenCreateDialogComponent,
AccessTokenDialogComponent,
ExpirationOptionsComponent,
ServiceAccountComponent,
ServiceAccountDialogComponent,
ServiceAccountPeopleComponent,
ServiceAccountProjectsComponent,
ServiceAccountsComponent,
ServiceAccountsListComponent,
ServiceAccountPeopleComponent,

View File

@ -25,6 +25,7 @@ import { ServiceAccountAccessPoliciesResponse } from "../../shared/access-polici
import { AccessPolicyUpdateRequest } from "./models/requests/access-policy-update.request";
import { AccessPolicyRequest } from "./models/requests/access-policy.request";
import { GrantedPolicyRequest } from "./models/requests/granted-policy.request";
import {
GroupServiceAccountAccessPolicyResponse,
UserServiceAccountAccessPolicyResponse,
@ -40,6 +41,9 @@ import { PotentialGranteeResponse } from "./models/responses/potential-grantee.r
export class AccessPolicyService {
private _projectAccessPolicyChanges$ = new Subject<ProjectAccessPoliciesView>();
private _serviceAccountAccessPolicyChanges$ = new Subject<ServiceAccountAccessPoliciesView>();
private _serviceAccountGrantedPolicyChanges$ = new Subject<
ServiceAccountProjectAccessPolicyView[]
>();
/**
* Emits when a project access policy is created or deleted.
@ -52,12 +56,56 @@ export class AccessPolicyService {
readonly serviceAccountAccessPolicyChanges$ =
this._serviceAccountAccessPolicyChanges$.asObservable();
/**
* Emits when a service account granted policy is created or deleted.
*/
readonly serviceAccountGrantedPolicyChanges$ =
this._serviceAccountGrantedPolicyChanges$.asObservable();
constructor(
private cryptoService: CryptoService,
protected apiService: ApiService,
protected encryptService: EncryptService
) {}
async getGrantedPolicies(
serviceAccountId: string,
organizationId: string
): Promise<ServiceAccountProjectAccessPolicyView[]> {
const r = await this.apiService.send(
"GET",
"/service-accounts/" + serviceAccountId + "/granted-policies",
null,
true,
true
);
const results = new ListResponse(r, ServiceAccountProjectAccessPolicyResponse);
return await this.createServiceAccountProjectAccessPolicyViews(results.data, organizationId);
}
async createGrantedPolicies(
organizationId: string,
serviceAccountId: string,
policies: ServiceAccountProjectAccessPolicyView[]
): Promise<ServiceAccountProjectAccessPolicyView[]> {
const request = this.getGrantedPoliciesCreateRequest(policies);
const r = await this.apiService.send(
"POST",
"/service-accounts/" + serviceAccountId + "/granted-policies",
request,
true,
true
);
const results = new ListResponse(r, ServiceAccountProjectAccessPolicyResponse);
const views = await this.createServiceAccountProjectAccessPolicyViews(
results.data,
organizationId
);
this._serviceAccountGrantedPolicyChanges$.next(views);
return views;
}
async getProjectAccessPolicies(
organizationId: string,
projectId: string
@ -132,6 +180,7 @@ export class AccessPolicyService {
await this.apiService.send("DELETE", "/access-policies/" + accessPolicyId, null, true, false);
this._projectAccessPolicyChanges$.next(null);
this._serviceAccountAccessPolicyChanges$.next(null);
this._serviceAccountGrantedPolicyChanges$.next(null);
}
async updateAccessPolicy(baseAccessPolicyView: BaseAccessPolicyView): Promise<void> {
@ -228,6 +277,10 @@ export class AccessPolicyService {
...this.createBaseAccessPolicyView(response),
grantedProjectId: response.grantedProjectId,
serviceAccountId: response.serviceAccountId,
grantedProjectName: await this.encryptService.decryptToUtf8(
new EncString(response.grantedProjectName),
organizationKey
),
serviceAccountName: await this.encryptService.decryptToUtf8(
new EncString(response.serviceAccountName),
organizationKey
@ -318,6 +371,18 @@ export class AccessPolicyService {
return await this.createPotentialGranteeViews(organizationId, results.data);
}
async getProjectsPotentialGrantees(organizationId: string) {
const r = await this.apiService.send(
"GET",
"/organizations/" + organizationId + "/access-policies/projects/potential-grantees",
null,
true,
true
);
const results = new ListResponse(r, PotentialGranteeResponse);
return await this.createPotentialGranteeViews(organizationId, results.data);
}
protected async getOrganizationKey(organizationId: string): Promise<SymmetricCryptoKey> {
return await this.cryptoService.getOrgKey(organizationId);
}
@ -367,7 +432,7 @@ export class AccessPolicyService {
view.type = r.type;
view.email = r.email;
if (r.type === "serviceAccount") {
if (r.type === "serviceAccount" || r.type === "project") {
view.name = await this.encryptService.decryptToUtf8(new EncString(r.name), orgKey);
} else {
view.name = r.name;
@ -376,4 +441,44 @@ export class AccessPolicyService {
})
);
}
private getGrantedPoliciesCreateRequest(
policies: ServiceAccountProjectAccessPolicyView[]
): GrantedPolicyRequest[] {
return policies.map((ap) => {
const request = new GrantedPolicyRequest();
request.grantedId = ap.grantedProjectId;
request.read = ap.read;
request.write = ap.write;
return request;
});
}
private async createServiceAccountProjectAccessPolicyViews(
responses: ServiceAccountProjectAccessPolicyResponse[],
organizationId: string
): Promise<ServiceAccountProjectAccessPolicyView[]> {
const orgKey = await this.getOrganizationKey(organizationId);
return await Promise.all(
responses.map(async (response: ServiceAccountProjectAccessPolicyResponse) => {
const view = new ServiceAccountProjectAccessPolicyView();
view.id = response.id;
view.read = response.read;
view.write = response.write;
view.creationDate = response.creationDate;
view.revisionDate = response.revisionDate;
view.serviceAccountId = response.serviceAccountId;
view.grantedProjectId = response.grantedProjectId;
view.serviceAccountName = await this.encryptService.decryptToUtf8(
new EncString(response.serviceAccountName),
orgKey
);
view.grantedProjectName = await this.encryptService.decryptToUtf8(
new EncString(response.grantedProjectName),
orgKey
);
return view;
})
);
}
}

View File

@ -63,8 +63,8 @@
bitIconButton="bwi-close"
buttonType="main"
size="default"
[attr.title]="'close' | i18n"
[attr.aria-label]="'close' | i18n"
[attr.title]="'remove' | i18n"
[attr.aria-label]="'remove' | i18n"
[bitAction]="delete(row.accessPolicyId)"
></button>
</td>

View File

@ -12,9 +12,9 @@ import { BaseAccessPolicyView } from "../../models/view/access-policy.view";
import { AccessPolicyService } from "./access-policy.service";
export type AccessSelectorRowView = {
type: "user" | "group" | "serviceAccount";
type: "user" | "group" | "serviceAccount" | "project";
name: string;
granteeId: string;
id: string;
accessPolicyId: string;
read: boolean;
write: boolean;
@ -30,6 +30,7 @@ export class AccessSelectorComponent implements OnInit {
static readonly userIcon = "bwi-user";
static readonly groupIcon = "bwi-family";
static readonly serviceAccountIcon = "bwi-wrench";
static readonly projectIcon = "bwi-collection";
@Output() onCreateAccessPolicies = new EventEmitter<SelectItemView[]>();
@ -37,7 +38,7 @@ export class AccessSelectorComponent implements OnInit {
@Input() hint: string;
@Input() columnTitle: string;
@Input() emptyMessage: string;
@Input() granteeType: "people" | "serviceAccounts";
@Input() granteeType: "people" | "serviceAccounts" | "projects";
protected rows$ = new Subject<AccessSelectorRowView[]>();
@Input() private set rows(value: AccessSelectorRowView[]) {
@ -57,7 +58,7 @@ export class AccessSelectorComponent implements OnInit {
switchMap(([rows, params]) =>
this.getPotentialGrantees(params.organizationId).then((grantees) =>
grantees
.filter((g) => !rows.some((row) => row.granteeId === g.id))
.filter((g) => !rows.some((row) => row.id === g.id))
.map((granteeView) => {
let icon: string;
let listName = granteeView.name;
@ -74,6 +75,8 @@ export class AccessSelectorComponent implements OnInit {
icon = AccessSelectorComponent.groupIcon;
} else if (granteeView.type === "serviceAccount") {
icon = AccessSelectorComponent.serviceAccountIcon;
} else if (granteeView.type === "project") {
icon = AccessSelectorComponent.projectIcon;
}
return {
icon: icon,
@ -144,9 +147,14 @@ export class AccessSelectorComponent implements OnInit {
};
private getPotentialGrantees(organizationId: string) {
return this.granteeType === "people"
? this.accessPolicyService.getPeoplePotentialGrantees(organizationId)
: this.accessPolicyService.getServiceAccountsPotentialGrantees(organizationId);
switch (this.granteeType) {
case "people":
return this.accessPolicyService.getPeoplePotentialGrantees(organizationId);
case "serviceAccounts":
return this.accessPolicyService.getServiceAccountsPotentialGrantees(organizationId);
case "projects":
return this.accessPolicyService.getProjectsPotentialGrantees(organizationId);
}
}
static getAccessItemType(item: SelectItemView) {
@ -157,6 +165,8 @@ export class AccessSelectorComponent implements OnInit {
return "group";
case AccessSelectorComponent.serviceAccountIcon:
return "serviceAccount";
case AccessSelectorComponent.projectIcon:
return "project";
}
}
}

View File

@ -0,0 +1,5 @@
export class GrantedPolicyRequest {
grantedId: string;
read: boolean;
write: boolean;
}

View File

@ -73,11 +73,13 @@ export class ServiceAccountProjectAccessPolicyResponse extends BaseAccessPolicyR
serviceAccountId: string;
serviceAccountName: string;
grantedProjectId: string;
grantedProjectName: string;
constructor(response: any) {
super(response);
this.serviceAccountId = this.getResponseProperty("ServiceAccountId");
this.serviceAccountName = this.getResponseProperty("ServiceAccountName");
this.grantedProjectId = this.getResponseProperty("GrantedProjectId");
this.grantedProjectName = this.getResponseProperty("GrantedProjectName");
}
}