mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-28 12:45:45 +01:00
[SM-409] Project Access Policies (#4472)
* Initial working multi select * Create project access selector component * Refactor to shared components for access policies * Refactor access policies table * Initial working create & update access policies * Add dynamic multi-select + DRY refactor * FIGMA updates * Fix table and refactor access-policy service * Code review updates * Fix disable/loading logic for selector * Don't run onchange for creation * Migrate to new group service * simplify async action * Refactor access-policies Co-authored-by: Will Martin <willmartian@users.noreply.github.com> * Refactor access-selector * Add using potential grantee endpoints. * refactor to use observables * combine access-selector and access-policies component * lift dynamic i18n out of template * use strict equality * fix multiselect refresh * change grantees to function * Fix multiple HTTP calls * don't broadcast on AP update * Code review updates * Use refactored potential-grantees endpoint * potential grantees refactor v2 --------- Co-authored-by: Will Martin <willmartian@users.noreply.github.com> Co-authored-by: William Martin <contact@willmartian.com>
This commit is contained in:
parent
bbc709d74e
commit
4ffe1c7e57
@ -5914,7 +5914,7 @@
|
||||
"message": "Expires"
|
||||
},
|
||||
"canRead": {
|
||||
"message": "Can Read"
|
||||
"message": "Can read"
|
||||
},
|
||||
"accessTokensNoItemsTitle": {
|
||||
"message": "No access tokens to show"
|
||||
@ -6141,6 +6141,33 @@
|
||||
"uploadLicense": {
|
||||
"message": "Upload license"
|
||||
},
|
||||
"projectPeopleDescription": {
|
||||
"message": "Grant groups or people access to this project."
|
||||
},
|
||||
"projectPeopleSelectHint": {
|
||||
"message": "Type or select people or groups"
|
||||
},
|
||||
"projectServiceAccountsDescription": {
|
||||
"message": "Grant service accounts access to this project."
|
||||
},
|
||||
"projectServiceAccountsSelectHint": {
|
||||
"message": "Type or select service accounts"
|
||||
},
|
||||
"projectEmptyPeopleAccessPolicies": {
|
||||
"message": "Add people or groups to start collaborating"
|
||||
},
|
||||
"projectEmptyServiceAccountAccessPolicies": {
|
||||
"message": "Add service accounts to grant access"
|
||||
},
|
||||
"canWrite": {
|
||||
"message": "Can write"
|
||||
},
|
||||
"canReadWrite": {
|
||||
"message": "Can read, write"
|
||||
},
|
||||
"groupSlashUser": {
|
||||
"message": "Group/User"
|
||||
},
|
||||
"lowKdfIterations": {
|
||||
"message": "Low KDF Iterations"
|
||||
},
|
||||
|
@ -0,0 +1,25 @@
|
||||
export class BaseAccessPolicyView {
|
||||
id: string;
|
||||
read: boolean;
|
||||
write: boolean;
|
||||
creationDate: string;
|
||||
revisionDate: string;
|
||||
}
|
||||
|
||||
export class UserProjectAccessPolicyView extends BaseAccessPolicyView {
|
||||
organizationUserId: string;
|
||||
organizationUserName: string;
|
||||
grantedProjectId: string;
|
||||
}
|
||||
|
||||
export class GroupProjectAccessPolicyView extends BaseAccessPolicyView {
|
||||
groupId: string;
|
||||
groupName: string;
|
||||
grantedProjectId: string;
|
||||
}
|
||||
|
||||
export class ServiceAccountProjectAccessPolicyView extends BaseAccessPolicyView {
|
||||
serviceAccountId: string;
|
||||
serviceAccountName: string;
|
||||
grantedProjectId: string;
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
export class PotentialGranteeView {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
email: string;
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
import {
|
||||
GroupProjectAccessPolicyView,
|
||||
ServiceAccountProjectAccessPolicyView,
|
||||
UserProjectAccessPolicyView,
|
||||
} from "./access-policy.view";
|
||||
|
||||
export class ProjectAccessPoliciesView {
|
||||
userAccessPolicies: UserProjectAccessPolicyView[];
|
||||
groupAccessPolicies: GroupProjectAccessPolicyView[];
|
||||
serviceAccountAccessPolicies: ServiceAccountProjectAccessPolicyView[];
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
<ng-container *ngIf="projectAccessPolicies$; else spinner">
|
||||
<div class="tw-w-2/5">
|
||||
<p class="tw-mt-8">
|
||||
{{ description }}
|
||||
</p>
|
||||
<sm-access-selector
|
||||
[projectAccessPolicies$]="projectAccessPolicies$"
|
||||
[potentialGrantees$]="potentialGrantees$"
|
||||
[label]="label"
|
||||
[hint]="hint"
|
||||
[tableType]="accessType"
|
||||
[columnTitle]="columnTitle"
|
||||
[emptyMessage]="emptyMessage"
|
||||
>
|
||||
</sm-access-selector>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<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>
|
@ -0,0 +1,54 @@
|
||||
import { Component, Input, OnInit } from "@angular/core";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { combineLatestWith, Observable, share, startWith, switchMap } from "rxjs";
|
||||
|
||||
import { PotentialGranteeView } from "../../models/view/potential-grantee.view";
|
||||
import { ProjectAccessPoliciesView } from "../../models/view/project-access-policies.view";
|
||||
import { AccessPolicyService } from "../../shared/access-policies/access-policy.service";
|
||||
|
||||
@Component({
|
||||
selector: "sm-project-access",
|
||||
templateUrl: "./project-access.component.html",
|
||||
})
|
||||
export class ProjectAccessComponent implements OnInit {
|
||||
@Input() accessType: "projectPeople" | "projectServiceAccounts";
|
||||
@Input() description: string;
|
||||
@Input() label: string;
|
||||
@Input() hint: string;
|
||||
@Input() columnTitle: string;
|
||||
@Input() emptyMessage: string;
|
||||
|
||||
protected projectAccessPolicies$: Observable<ProjectAccessPoliciesView>;
|
||||
protected potentialGrantees$: Observable<PotentialGranteeView[]>;
|
||||
|
||||
constructor(private route: ActivatedRoute, private accessPolicyService: AccessPolicyService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.projectAccessPolicies$ = this.accessPolicyService.projectAccessPolicies$.pipe(
|
||||
startWith(null),
|
||||
combineLatestWith(this.route.params),
|
||||
switchMap(([_, params]) => {
|
||||
return this.accessPolicyService.getProjectAccessPolicies(
|
||||
params.organizationId,
|
||||
params.projectId
|
||||
);
|
||||
}),
|
||||
share()
|
||||
);
|
||||
|
||||
this.potentialGrantees$ = this.accessPolicyService.projectAccessPolicies$.pipe(
|
||||
startWith(null),
|
||||
combineLatestWith(this.route.params),
|
||||
switchMap(async ([_, params]) => {
|
||||
if (this.accessType == "projectPeople") {
|
||||
return await this.accessPolicyService.getPeoplePotentialGrantees(params.organizationId);
|
||||
} else {
|
||||
return await this.accessPolicyService.getServiceAccountsPotentialGrantees(
|
||||
params.organizationId
|
||||
);
|
||||
}
|
||||
}),
|
||||
share()
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
<sm-project-access
|
||||
accessType="projectPeople"
|
||||
[description]="'projectPeopleDescription' | i18n"
|
||||
[label]="'people' | i18n"
|
||||
[hint]="'projectPeopleSelectHint' | i18n"
|
||||
[columnTitle]="'groupSlashUser' | i18n"
|
||||
[emptyMessage]="'projectEmptyPeopleAccessPolicies' | i18n"
|
||||
>
|
||||
</sm-project-access>
|
@ -0,0 +1,7 @@
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
@Component({
|
||||
selector: "sm-project-people",
|
||||
templateUrl: "./project-people.component.html",
|
||||
})
|
||||
export class ProjectPeopleComponent {}
|
@ -0,0 +1,9 @@
|
||||
<sm-project-access
|
||||
accessType="projectServiceAccounts"
|
||||
[description]="'projectServiceAccountsDescription' | i18n"
|
||||
[label]="'serviceAccounts' | i18n"
|
||||
[hint]="'projectServiceAccountsSelectHint' | i18n"
|
||||
[columnTitle]="'serviceAccounts' | i18n"
|
||||
[emptyMessage]="'projectEmptyServiceAccountAccessPolicies' | i18n"
|
||||
>
|
||||
</sm-project-access>
|
@ -0,0 +1,7 @@
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
@Component({
|
||||
selector: "sm-project-service-accounts",
|
||||
templateUrl: "./project-service-accounts.component.html",
|
||||
})
|
||||
export class ProjectServiceAccountsComponent {}
|
@ -5,7 +5,7 @@
|
||||
<bit-tab-nav-bar label="Main" slot="tabs">
|
||||
<bit-tab-link [route]="['secrets']">{{ "secrets" | i18n }}</bit-tab-link>
|
||||
<bit-tab-link [route]="['people']">{{ "people" | i18n }}</bit-tab-link>
|
||||
<bit-tab-link [route]="['serviceAccounts']">{{ "serviceAccounts" | i18n }}</bit-tab-link>
|
||||
<bit-tab-link [route]="['service-accounts']">{{ "serviceAccounts" | i18n }}</bit-tab-link>
|
||||
</bit-tab-nav-bar>
|
||||
<sm-new-menu></sm-new-menu>
|
||||
</sm-header>
|
||||
|
@ -1,7 +1,9 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
import { RouterModule, Routes } from "@angular/router";
|
||||
|
||||
import { ProjectPeopleComponent } from "./project/project-people.component";
|
||||
import { ProjectSecretsComponent } from "./project/project-secrets.component";
|
||||
import { ProjectServiceAccountsComponent } from "./project/project-service-accounts.component";
|
||||
import { ProjectComponent } from "./project/project.component";
|
||||
import { ProjectsComponent } from "./projects/projects.component";
|
||||
|
||||
@ -23,6 +25,14 @@ const routes: Routes = [
|
||||
path: "secrets",
|
||||
component: ProjectSecretsComponent,
|
||||
},
|
||||
{
|
||||
path: "people",
|
||||
component: ProjectPeopleComponent,
|
||||
},
|
||||
{
|
||||
path: "service-accounts",
|
||||
component: ProjectServiceAccountsComponent,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
@ -6,7 +6,10 @@ import { SecretsManagerSharedModule } from "../shared/sm-shared.module";
|
||||
|
||||
import { ProjectDeleteDialogComponent } from "./dialog/project-delete-dialog.component";
|
||||
import { ProjectDialogComponent } from "./dialog/project-dialog.component";
|
||||
import { ProjectAccessComponent } from "./project/project-access.component";
|
||||
import { ProjectPeopleComponent } from "./project/project-people.component";
|
||||
import { ProjectSecretsComponent } from "./project/project-secrets.component";
|
||||
import { ProjectServiceAccountsComponent } from "./project/project-service-accounts.component";
|
||||
import { ProjectComponent } from "./project/project.component";
|
||||
import { ProjectsListComponent } from "./projects-list/projects-list.component";
|
||||
import { ProjectsRoutingModule } from "./projects-routing.module";
|
||||
@ -17,8 +20,11 @@ import { ProjectsComponent } from "./projects/projects.component";
|
||||
declarations: [
|
||||
ProjectsComponent,
|
||||
ProjectsListComponent,
|
||||
ProjectAccessComponent,
|
||||
ProjectDialogComponent,
|
||||
ProjectDeleteDialogComponent,
|
||||
ProjectPeopleComponent,
|
||||
ProjectServiceAccountsComponent,
|
||||
ProjectComponent,
|
||||
ProjectSecretsComponent,
|
||||
],
|
||||
|
@ -0,0 +1,260 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { Subject } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";
|
||||
import { EncryptService } from "@bitwarden/common/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-crypto-key";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
|
||||
import {
|
||||
BaseAccessPolicyView,
|
||||
GroupProjectAccessPolicyView,
|
||||
ServiceAccountProjectAccessPolicyView,
|
||||
UserProjectAccessPolicyView,
|
||||
} from "../../models/view/access-policy.view";
|
||||
import { PotentialGranteeView } from "../../models/view/potential-grantee.view";
|
||||
import { ProjectAccessPoliciesView } from "../../models/view/project-access-policies.view";
|
||||
|
||||
import { AccessPoliciesCreateRequest } from "./models/requests/access-policies-create.request";
|
||||
import { AccessPolicyUpdateRequest } from "./models/requests/access-policy-update.request";
|
||||
import { AccessPolicyRequest } from "./models/requests/access-policy.request";
|
||||
import {
|
||||
GroupProjectAccessPolicyResponse,
|
||||
ServiceAccountProjectAccessPolicyResponse,
|
||||
UserProjectAccessPolicyResponse,
|
||||
} from "./models/responses/access-policy.response";
|
||||
import { PotentialGranteeResponse } from "./models/responses/potential-grantee.response";
|
||||
import { ProjectAccessPoliciesResponse } from "./models/responses/project-access-policies.response";
|
||||
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
})
|
||||
export class AccessPolicyService {
|
||||
protected _projectAccessPolicies = new Subject<ProjectAccessPoliciesView>();
|
||||
projectAccessPolicies$ = this._projectAccessPolicies.asObservable();
|
||||
|
||||
constructor(
|
||||
private cryptoService: CryptoService,
|
||||
private apiService: ApiService,
|
||||
private encryptService: EncryptService
|
||||
) {}
|
||||
|
||||
async getProjectAccessPolicies(
|
||||
organizationId: string,
|
||||
projectId: string
|
||||
): Promise<ProjectAccessPoliciesView> {
|
||||
const r = await this.apiService.send(
|
||||
"GET",
|
||||
"/projects/" + projectId + "/access-policies",
|
||||
null,
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
const results = new ProjectAccessPoliciesResponse(r);
|
||||
return await this.createProjectAccessPoliciesView(organizationId, results);
|
||||
}
|
||||
|
||||
async getPeoplePotentialGrantees(organizationId: string) {
|
||||
const r = await this.apiService.send(
|
||||
"GET",
|
||||
"/organizations/" + organizationId + "/access-policies/people/potential-grantees",
|
||||
null,
|
||||
true,
|
||||
true
|
||||
);
|
||||
const results = new ListResponse(r, PotentialGranteeResponse);
|
||||
return await this.createPotentialGranteeViews(organizationId, results.data);
|
||||
}
|
||||
|
||||
async getServiceAccountsPotentialGrantees(organizationId: string) {
|
||||
const r = await this.apiService.send(
|
||||
"GET",
|
||||
"/organizations/" + organizationId + "/access-policies/service-accounts/potential-grantees",
|
||||
null,
|
||||
true,
|
||||
true
|
||||
);
|
||||
const results = new ListResponse(r, PotentialGranteeResponse);
|
||||
return await this.createPotentialGranteeViews(organizationId, results.data);
|
||||
}
|
||||
|
||||
async deleteAccessPolicy(accessPolicyId: string): Promise<void> {
|
||||
await this.apiService.send("DELETE", "/access-policies/" + accessPolicyId, null, true, false);
|
||||
this._projectAccessPolicies.next(null);
|
||||
}
|
||||
|
||||
async updateAccessPolicy(baseAccessPolicyView: BaseAccessPolicyView): Promise<void> {
|
||||
const payload = new AccessPolicyUpdateRequest();
|
||||
payload.read = baseAccessPolicyView.read;
|
||||
payload.write = baseAccessPolicyView.write;
|
||||
await this.apiService.send(
|
||||
"PUT",
|
||||
"/access-policies/" + baseAccessPolicyView.id,
|
||||
payload,
|
||||
true,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
async createProjectAccessPolicies(
|
||||
organizationId: string,
|
||||
projectId: string,
|
||||
projectAccessPoliciesView: ProjectAccessPoliciesView
|
||||
): Promise<ProjectAccessPoliciesView> {
|
||||
const request = this.getAccessPoliciesCreateRequest(projectAccessPoliciesView);
|
||||
const r = await this.apiService.send(
|
||||
"POST",
|
||||
"/projects/" + projectId + "/access-policies",
|
||||
request,
|
||||
true,
|
||||
true
|
||||
);
|
||||
const results = new ProjectAccessPoliciesResponse(r);
|
||||
const view = await this.createProjectAccessPoliciesView(organizationId, results);
|
||||
this._projectAccessPolicies.next(view);
|
||||
return view;
|
||||
}
|
||||
|
||||
private async getOrganizationKey(organizationId: string): Promise<SymmetricCryptoKey> {
|
||||
return await this.cryptoService.getOrgKey(organizationId);
|
||||
}
|
||||
|
||||
private getAccessPoliciesCreateRequest(
|
||||
projectAccessPoliciesView: ProjectAccessPoliciesView
|
||||
): AccessPoliciesCreateRequest {
|
||||
const createRequest = new AccessPoliciesCreateRequest();
|
||||
|
||||
if (projectAccessPoliciesView.userAccessPolicies?.length > 0) {
|
||||
createRequest.userAccessPolicyRequests = projectAccessPoliciesView.userAccessPolicies.map(
|
||||
(ap) => {
|
||||
return this.getAccessPolicyRequest(ap.organizationUserId, ap);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (projectAccessPoliciesView.groupAccessPolicies?.length > 0) {
|
||||
createRequest.groupAccessPolicyRequests = projectAccessPoliciesView.groupAccessPolicies.map(
|
||||
(ap) => {
|
||||
return this.getAccessPolicyRequest(ap.groupId, ap);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (projectAccessPoliciesView.serviceAccountAccessPolicies?.length > 0) {
|
||||
createRequest.serviceAccountAccessPolicyRequests =
|
||||
projectAccessPoliciesView.serviceAccountAccessPolicies.map((ap) => {
|
||||
return this.getAccessPolicyRequest(ap.serviceAccountId, ap);
|
||||
});
|
||||
}
|
||||
return createRequest;
|
||||
}
|
||||
|
||||
private getAccessPolicyRequest(
|
||||
granteeId: string,
|
||||
view:
|
||||
| UserProjectAccessPolicyView
|
||||
| GroupProjectAccessPolicyView
|
||||
| ServiceAccountProjectAccessPolicyView
|
||||
) {
|
||||
const request = new AccessPolicyRequest();
|
||||
request.granteeId = granteeId;
|
||||
request.read = view.read;
|
||||
request.write = view.write;
|
||||
return request;
|
||||
}
|
||||
|
||||
private async createProjectAccessPoliciesView(
|
||||
organizationId: string,
|
||||
projectAccessPoliciesResponse: ProjectAccessPoliciesResponse
|
||||
): Promise<ProjectAccessPoliciesView> {
|
||||
const orgKey = await this.getOrganizationKey(organizationId);
|
||||
const view = new ProjectAccessPoliciesView();
|
||||
|
||||
view.userAccessPolicies = projectAccessPoliciesResponse.userAccessPolicies.map((ap) => {
|
||||
return this.createUserProjectAccessPolicyView(ap);
|
||||
});
|
||||
view.groupAccessPolicies = projectAccessPoliciesResponse.groupAccessPolicies.map((ap) => {
|
||||
return this.createGroupProjectAccessPolicyView(ap);
|
||||
});
|
||||
view.serviceAccountAccessPolicies = await Promise.all(
|
||||
projectAccessPoliciesResponse.serviceAccountAccessPolicies.map(async (ap) => {
|
||||
return await this.createServiceAccountProjectAccessPolicyView(orgKey, ap);
|
||||
})
|
||||
);
|
||||
return view;
|
||||
}
|
||||
|
||||
private createUserProjectAccessPolicyView(
|
||||
response: UserProjectAccessPolicyResponse
|
||||
): UserProjectAccessPolicyView {
|
||||
const view = <UserProjectAccessPolicyView>this.createBaseAccessPolicyView(response);
|
||||
view.grantedProjectId = response.grantedProjectId;
|
||||
view.organizationUserId = response.organizationUserId;
|
||||
view.organizationUserName = response.organizationUserName;
|
||||
return view;
|
||||
}
|
||||
|
||||
private createGroupProjectAccessPolicyView(
|
||||
response: GroupProjectAccessPolicyResponse
|
||||
): GroupProjectAccessPolicyView {
|
||||
const view = <GroupProjectAccessPolicyView>this.createBaseAccessPolicyView(response);
|
||||
view.grantedProjectId = response.grantedProjectId;
|
||||
view.groupId = response.groupId;
|
||||
view.groupName = response.groupName;
|
||||
return view;
|
||||
}
|
||||
|
||||
private async createServiceAccountProjectAccessPolicyView(
|
||||
organizationKey: SymmetricCryptoKey,
|
||||
response: ServiceAccountProjectAccessPolicyResponse
|
||||
): Promise<ServiceAccountProjectAccessPolicyView> {
|
||||
const view = <ServiceAccountProjectAccessPolicyView>this.createBaseAccessPolicyView(response);
|
||||
view.grantedProjectId = response.grantedProjectId;
|
||||
view.serviceAccountId = response.serviceAccountId;
|
||||
view.serviceAccountName = await this.encryptService.decryptToUtf8(
|
||||
new EncString(response.serviceAccountName),
|
||||
organizationKey
|
||||
);
|
||||
return view;
|
||||
}
|
||||
|
||||
private createBaseAccessPolicyView(
|
||||
response:
|
||||
| UserProjectAccessPolicyResponse
|
||||
| GroupProjectAccessPolicyResponse
|
||||
| ServiceAccountProjectAccessPolicyResponse
|
||||
) {
|
||||
return {
|
||||
id: response.id,
|
||||
read: response.read,
|
||||
write: response.write,
|
||||
creationDate: response.creationDate,
|
||||
revisionDate: response.revisionDate,
|
||||
};
|
||||
}
|
||||
|
||||
private async createPotentialGranteeViews(
|
||||
organizationId: string,
|
||||
results: PotentialGranteeResponse[]
|
||||
): Promise<PotentialGranteeView[]> {
|
||||
const orgKey = await this.getOrganizationKey(organizationId);
|
||||
return await Promise.all(
|
||||
results.map(async (r) => {
|
||||
const view = new PotentialGranteeView();
|
||||
view.id = r.id;
|
||||
view.type = r.type;
|
||||
view.email = r.email;
|
||||
|
||||
if (r.type === "serviceAccount") {
|
||||
view.name = await this.encryptService.decryptToUtf8(new EncString(r.name), orgKey);
|
||||
} else {
|
||||
view.name = r.name;
|
||||
}
|
||||
return view;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,82 @@
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit" class="tw-mt-5">
|
||||
<bit-form-field>
|
||||
<bit-label>{{ label }}</bit-label>
|
||||
<bit-multi-select
|
||||
class="tw-mr-4 tw-w-full"
|
||||
formControlName="multiSelect"
|
||||
[baseItems]="selectItemsView$ | async"
|
||||
[loading]="loading"
|
||||
></bit-multi-select>
|
||||
<bit-hint>{{ hint }}</bit-hint>
|
||||
<button type="submit" bitButton buttonType="primary" bitFormButton>
|
||||
{{ "add" | i18n }}
|
||||
</button>
|
||||
</bit-form-field>
|
||||
</form>
|
||||
|
||||
<ng-container *ngIf="rows$ | async as rows; else spinner">
|
||||
<bit-table>
|
||||
<ng-container header>
|
||||
<tr>
|
||||
<th bitCell colspan="2">{{ columnTitle }}</th>
|
||||
<th bitCell>{{ "permissions" | i18n }}</th>
|
||||
</tr>
|
||||
</ng-container>
|
||||
|
||||
<ng-template body>
|
||||
<ng-container *ngIf="rows?.length > 0; else empty">
|
||||
<tr bitRow *ngFor="let row of rows">
|
||||
<td bitCell class="tw-w-0 tw-pr-0">
|
||||
<i class="bwi {{ row.icon }} tw-text-xl tw-text-muted" aria-hidden="true"></i>
|
||||
</td>
|
||||
<td bitCell>{{ row.name }}</td>
|
||||
<td bitCell *ngIf="row.type == 'serviceAccount'">
|
||||
<bit-form-field class="tw-inline-block tw-w-auto">
|
||||
<select bitInput disabled>
|
||||
<option value="canRead" selected>{{ "canRead" | i18n }}</option>
|
||||
</select>
|
||||
</bit-form-field>
|
||||
</td>
|
||||
<td bitCell *ngIf="row.type != 'serviceAccount'">
|
||||
<bit-form-field class="tw-inline-block tw-w-auto">
|
||||
<select bitInput (change)="updateAccessPolicy($event.target, row.id)">
|
||||
<option value="canRead" [selected]="row.read && row.write != true">
|
||||
{{ "canRead" | i18n }}
|
||||
</option>
|
||||
<option value="canWrite" [selected]="row.read != true && row.write">
|
||||
{{ "canWrite" | i18n }}
|
||||
</option>
|
||||
<option value="canReadWrite" [selected]="row.read && row.write">
|
||||
{{ "canReadWrite" | i18n }}
|
||||
</option>
|
||||
</select>
|
||||
</bit-form-field>
|
||||
</td>
|
||||
<td bitCell class="tw-w-0">
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-close"
|
||||
buttonType="main"
|
||||
size="default"
|
||||
[attr.title]="'close' | i18n"
|
||||
[attr.aria-label]="'close' | i18n"
|
||||
[bitAction]="delete(row.id)"
|
||||
></button>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
</bit-table>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #empty>
|
||||
<div class="tw-mt-4 tw-text-center">
|
||||
{{ emptyMessage }}
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<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>
|
@ -0,0 +1,286 @@
|
||||
import { Component, Input, OnDestroy, OnInit } from "@angular/core";
|
||||
import { FormControl, FormGroup, Validators } from "@angular/forms";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import {
|
||||
combineLatestWith,
|
||||
distinctUntilChanged,
|
||||
firstValueFrom,
|
||||
map,
|
||||
Observable,
|
||||
Subject,
|
||||
takeUntil,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
|
||||
import { ValidationService } from "@bitwarden/common/abstractions/validation.service";
|
||||
import { SelectItemView } from "@bitwarden/components/src/multi-select/models/select-item-view";
|
||||
|
||||
import {
|
||||
BaseAccessPolicyView,
|
||||
GroupProjectAccessPolicyView,
|
||||
ServiceAccountProjectAccessPolicyView,
|
||||
UserProjectAccessPolicyView,
|
||||
} from "../../models/view/access-policy.view";
|
||||
import { PotentialGranteeView } from "../../models/view/potential-grantee.view";
|
||||
import { ProjectAccessPoliciesView } from "../../models/view/project-access-policies.view";
|
||||
|
||||
import { AccessPolicyService } from "./access-policy.service";
|
||||
|
||||
type RowItemView = {
|
||||
type: "user" | "group" | "serviceAccount";
|
||||
name: string;
|
||||
id: string;
|
||||
read: boolean;
|
||||
write: boolean;
|
||||
icon: string;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: "sm-access-selector",
|
||||
templateUrl: "./access-selector.component.html",
|
||||
})
|
||||
export class AccessSelectorComponent implements OnInit, OnDestroy {
|
||||
@Input() label: string;
|
||||
@Input() hint: string;
|
||||
@Input() tableType: "projectPeople" | "projectServiceAccounts";
|
||||
@Input() columnTitle: string;
|
||||
@Input() emptyMessage: string;
|
||||
|
||||
private readonly userIcon = "bwi-user";
|
||||
private readonly groupIcon = "bwi-family";
|
||||
private readonly serviceAccountIcon = "bwi-wrench";
|
||||
|
||||
@Input() projectAccessPolicies$: Observable<ProjectAccessPoliciesView>;
|
||||
@Input() potentialGrantees$: Observable<PotentialGranteeView[]>;
|
||||
|
||||
private projectId: string;
|
||||
private organizationId: string;
|
||||
private destroy$: Subject<void> = new Subject<void>();
|
||||
|
||||
protected loading = true;
|
||||
protected formGroup = new FormGroup({
|
||||
multiSelect: new FormControl([], [Validators.required]),
|
||||
});
|
||||
|
||||
protected selectItemsView$: Observable<SelectItemView[]>;
|
||||
protected rows$: Observable<RowItemView[]>;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private accessPolicyService: AccessPolicyService,
|
||||
private validationService: ValidationService
|
||||
) {}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
this.route.params.pipe(takeUntil(this.destroy$)).subscribe((params: any) => {
|
||||
this.organizationId = params.organizationId;
|
||||
this.projectId = params.projectId;
|
||||
});
|
||||
|
||||
this.selectItemsView$ = this.projectAccessPolicies$.pipe(
|
||||
distinctUntilChanged(
|
||||
(prev, curr) => this.getAccessPoliciesCount(curr) === this.getAccessPoliciesCount(prev)
|
||||
),
|
||||
combineLatestWith(this.potentialGrantees$),
|
||||
map(([projectAccessPolicies, potentialGrantees]) =>
|
||||
this.createSelectView(projectAccessPolicies, potentialGrantees)
|
||||
),
|
||||
tap(() => {
|
||||
this.loading = false;
|
||||
this.formGroup.enable();
|
||||
this.formGroup.reset();
|
||||
})
|
||||
);
|
||||
|
||||
this.rows$ = this.projectAccessPolicies$.pipe(
|
||||
map((policies) => {
|
||||
const rowData: RowItemView[] = [];
|
||||
|
||||
if (this.tableType === "projectPeople") {
|
||||
policies.userAccessPolicies.forEach((policy) => {
|
||||
rowData.push({
|
||||
type: "user",
|
||||
name: policy.organizationUserName,
|
||||
id: policy.id,
|
||||
read: policy.read,
|
||||
write: policy.write,
|
||||
icon: this.userIcon,
|
||||
});
|
||||
});
|
||||
|
||||
policies.groupAccessPolicies.forEach((policy) => {
|
||||
rowData.push({
|
||||
type: "group",
|
||||
name: policy.groupName,
|
||||
id: policy.id,
|
||||
read: policy.read,
|
||||
write: policy.write,
|
||||
icon: this.groupIcon,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (this.tableType === "projectServiceAccounts") {
|
||||
policies.serviceAccountAccessPolicies.forEach((policy) => {
|
||||
rowData.push({
|
||||
type: "serviceAccount",
|
||||
name: policy.serviceAccountName,
|
||||
id: policy.id,
|
||||
read: policy.read,
|
||||
write: policy.write,
|
||||
icon: this.serviceAccountIcon,
|
||||
});
|
||||
});
|
||||
}
|
||||
return rowData;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
submit = async () => {
|
||||
this.formGroup.markAllAsTouched();
|
||||
if (this.formGroup.invalid) {
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
this.formGroup.disable();
|
||||
|
||||
this.accessPolicyService.createProjectAccessPolicies(
|
||||
this.organizationId,
|
||||
this.projectId,
|
||||
this.createProjectAccessPoliciesViewFromSelected()
|
||||
);
|
||||
|
||||
return firstValueFrom(this.selectItemsView$);
|
||||
};
|
||||
|
||||
private createSelectView = (
|
||||
projectAccessPolicies: ProjectAccessPoliciesView,
|
||||
potentialGrantees: PotentialGranteeView[]
|
||||
): SelectItemView[] => {
|
||||
const selectItemsView = potentialGrantees.map((granteeView) => {
|
||||
let icon: string;
|
||||
let listName: string;
|
||||
if (granteeView.type === "user") {
|
||||
icon = this.userIcon;
|
||||
listName = `${granteeView.name} (${granteeView.email})`;
|
||||
} else if (granteeView.type === "group") {
|
||||
icon = this.groupIcon;
|
||||
listName = granteeView.name;
|
||||
} else {
|
||||
icon = this.serviceAccountIcon;
|
||||
listName = granteeView.name;
|
||||
}
|
||||
return {
|
||||
icon: icon,
|
||||
id: granteeView.id,
|
||||
labelName: granteeView.name,
|
||||
listName: listName,
|
||||
};
|
||||
});
|
||||
return this.filterExistingAccessPolicies(selectItemsView, projectAccessPolicies);
|
||||
};
|
||||
|
||||
private createProjectAccessPoliciesViewFromSelected(): ProjectAccessPoliciesView {
|
||||
const projectAccessPoliciesView = new ProjectAccessPoliciesView();
|
||||
projectAccessPoliciesView.userAccessPolicies = this.formGroup.value.multiSelect
|
||||
?.filter((selection) => selection.icon === this.userIcon)
|
||||
?.map((filtered) => {
|
||||
const view = new UserProjectAccessPolicyView();
|
||||
view.grantedProjectId = this.projectId;
|
||||
view.organizationUserId = filtered.id;
|
||||
view.read = true;
|
||||
view.write = false;
|
||||
return view;
|
||||
});
|
||||
|
||||
projectAccessPoliciesView.groupAccessPolicies = this.formGroup.value.multiSelect
|
||||
?.filter((selection) => selection.icon === this.groupIcon)
|
||||
?.map((filtered) => {
|
||||
const view = new GroupProjectAccessPolicyView();
|
||||
view.grantedProjectId = this.projectId;
|
||||
view.groupId = filtered.id;
|
||||
view.read = true;
|
||||
view.write = false;
|
||||
return view;
|
||||
});
|
||||
|
||||
projectAccessPoliciesView.serviceAccountAccessPolicies = this.formGroup.value.multiSelect
|
||||
?.filter((selection) => selection.icon === this.serviceAccountIcon)
|
||||
?.map((filtered) => {
|
||||
const view = new ServiceAccountProjectAccessPolicyView();
|
||||
view.grantedProjectId = this.projectId;
|
||||
view.serviceAccountId = filtered.id;
|
||||
view.read = true;
|
||||
view.write = false;
|
||||
return view;
|
||||
});
|
||||
return projectAccessPoliciesView;
|
||||
}
|
||||
|
||||
private getAccessPoliciesCount(projectAccessPoliciesView: ProjectAccessPoliciesView) {
|
||||
return (
|
||||
projectAccessPoliciesView.groupAccessPolicies.length +
|
||||
projectAccessPoliciesView.serviceAccountAccessPolicies.length +
|
||||
projectAccessPoliciesView.userAccessPolicies.length
|
||||
);
|
||||
}
|
||||
|
||||
private filterExistingAccessPolicies(
|
||||
potentialGrantees: SelectItemView[],
|
||||
projectAccessPolicies: ProjectAccessPoliciesView
|
||||
): SelectItemView[] {
|
||||
return potentialGrantees
|
||||
.filter(
|
||||
(potentialGrantee) =>
|
||||
!projectAccessPolicies.serviceAccountAccessPolicies.some(
|
||||
(ap) => ap.serviceAccountId === potentialGrantee.id
|
||||
)
|
||||
)
|
||||
.filter(
|
||||
(potentialGrantee) =>
|
||||
!projectAccessPolicies.userAccessPolicies.some(
|
||||
(ap) => ap.organizationUserId === potentialGrantee.id
|
||||
)
|
||||
)
|
||||
.filter(
|
||||
(potentialGrantee) =>
|
||||
!projectAccessPolicies.groupAccessPolicies.some(
|
||||
(ap) => ap.groupId === potentialGrantee.id
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async updateAccessPolicy(target: any, accessPolicyId: string): Promise<void> {
|
||||
try {
|
||||
const accessPolicyView = new BaseAccessPolicyView();
|
||||
accessPolicyView.id = accessPolicyId;
|
||||
if (target.value === "canRead") {
|
||||
accessPolicyView.read = true;
|
||||
accessPolicyView.write = false;
|
||||
} else if (target.value === "canWrite") {
|
||||
accessPolicyView.read = false;
|
||||
accessPolicyView.write = true;
|
||||
} else if (target.value === "canReadWrite") {
|
||||
accessPolicyView.read = true;
|
||||
accessPolicyView.write = true;
|
||||
}
|
||||
|
||||
await this.accessPolicyService.updateAccessPolicy(accessPolicyView);
|
||||
} catch (e) {
|
||||
this.validationService.showError(e);
|
||||
}
|
||||
}
|
||||
|
||||
delete = (accessPolicyId: string) => async () => {
|
||||
this.loading = true;
|
||||
this.formGroup.disable();
|
||||
await this.accessPolicyService.deleteAccessPolicy(accessPolicyId);
|
||||
return firstValueFrom(this.selectItemsView$);
|
||||
};
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
import { AccessPolicyRequest } from "./access-policy.request";
|
||||
|
||||
export class AccessPoliciesCreateRequest {
|
||||
userAccessPolicyRequests?: AccessPolicyRequest[];
|
||||
groupAccessPolicyRequests?: AccessPolicyRequest[];
|
||||
serviceAccountAccessPolicyRequests?: AccessPolicyRequest[];
|
||||
}
|
@ -0,0 +1,4 @@
|
||||
export class AccessPolicyUpdateRequest {
|
||||
read: boolean;
|
||||
write: boolean;
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
export class AccessPolicyRequest {
|
||||
granteeId: string;
|
||||
read: boolean;
|
||||
write: boolean;
|
||||
}
|
@ -0,0 +1,57 @@
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
|
||||
export class BaseAccessPolicyResponse extends BaseResponse {
|
||||
id: string;
|
||||
read: boolean;
|
||||
write: boolean;
|
||||
creationDate: string;
|
||||
revisionDate: string;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.id = this.getResponseProperty("Id");
|
||||
this.read = this.getResponseProperty("Read");
|
||||
this.write = this.getResponseProperty("Write");
|
||||
this.creationDate = this.getResponseProperty("CreationDate");
|
||||
this.revisionDate = this.getResponseProperty("RevisionDate");
|
||||
}
|
||||
}
|
||||
|
||||
export class UserProjectAccessPolicyResponse extends BaseAccessPolicyResponse {
|
||||
organizationUserId: string;
|
||||
organizationUserName: string;
|
||||
grantedProjectId: string;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.organizationUserId = this.getResponseProperty("OrganizationUserId");
|
||||
this.organizationUserName = this.getResponseProperty("OrganizationUserName");
|
||||
this.grantedProjectId = this.getResponseProperty("GrantedProjectId");
|
||||
}
|
||||
}
|
||||
|
||||
export class GroupProjectAccessPolicyResponse extends BaseAccessPolicyResponse {
|
||||
groupId: string;
|
||||
groupName: string;
|
||||
grantedProjectId: string;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.groupId = this.getResponseProperty("GroupId");
|
||||
this.groupName = this.getResponseProperty("GroupName");
|
||||
this.grantedProjectId = this.getResponseProperty("GrantedProjectId");
|
||||
}
|
||||
}
|
||||
|
||||
export class ServiceAccountProjectAccessPolicyResponse extends BaseAccessPolicyResponse {
|
||||
serviceAccountId: string;
|
||||
serviceAccountName: string;
|
||||
grantedProjectId: string;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.serviceAccountId = this.getResponseProperty("ServiceAccountId");
|
||||
this.serviceAccountName = this.getResponseProperty("ServiceAccountName");
|
||||
this.grantedProjectId = this.getResponseProperty("GrantedProjectId");
|
||||
}
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
|
||||
export class PotentialGranteeResponse extends BaseResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
email: string;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.id = this.getResponseProperty("Id");
|
||||
this.name = this.getResponseProperty("Name");
|
||||
this.type = this.getResponseProperty("Type");
|
||||
this.email = this.getResponseProperty("Email");
|
||||
}
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
|
||||
import {
|
||||
GroupProjectAccessPolicyResponse,
|
||||
ServiceAccountProjectAccessPolicyResponse,
|
||||
UserProjectAccessPolicyResponse,
|
||||
} from "./access-policy.response";
|
||||
|
||||
export class ProjectAccessPoliciesResponse extends BaseResponse {
|
||||
userAccessPolicies: UserProjectAccessPolicyResponse[];
|
||||
groupAccessPolicies: GroupProjectAccessPolicyResponse[];
|
||||
serviceAccountAccessPolicies: ServiceAccountProjectAccessPolicyResponse[];
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.userAccessPolicies = this.getResponseProperty("UserAccessPolicies");
|
||||
this.groupAccessPolicies = this.getResponseProperty("GroupAccessPolicies");
|
||||
this.serviceAccountAccessPolicies = this.getResponseProperty("ServiceAccountAccessPolicies");
|
||||
}
|
||||
}
|
@ -1,6 +1,8 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { MultiSelectModule } from "@bitwarden/components";
|
||||
import { ProductSwitcherModule } from "@bitwarden/web-vault/app/layouts/product-switcher/product-switcher.module";
|
||||
import { CoreOrganizationModule } from "@bitwarden/web-vault/app/organizations/core";
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
|
||||
import { BulkStatusDialogComponent } from "../layout/dialogs/bulk-status-dialog.component";
|
||||
@ -8,10 +10,11 @@ import { HeaderComponent } from "../layout/header.component";
|
||||
import { NewMenuComponent } from "../layout/new-menu.component";
|
||||
import { NoItemsComponent } from "../layout/no-items.component";
|
||||
|
||||
import { AccessSelectorComponent } from "./access-policies/access-selector.component";
|
||||
import { SecretsListComponent } from "./secrets-list.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [SharedModule, ProductSwitcherModule],
|
||||
imports: [SharedModule, ProductSwitcherModule, MultiSelectModule, CoreOrganizationModule],
|
||||
exports: [
|
||||
SharedModule,
|
||||
BulkStatusDialogComponent,
|
||||
@ -19,6 +22,7 @@ import { SecretsListComponent } from "./secrets-list.component";
|
||||
NewMenuComponent,
|
||||
NoItemsComponent,
|
||||
SecretsListComponent,
|
||||
AccessSelectorComponent,
|
||||
],
|
||||
declarations: [
|
||||
BulkStatusDialogComponent,
|
||||
@ -26,6 +30,7 @@ import { SecretsListComponent } from "./secrets-list.component";
|
||||
NewMenuComponent,
|
||||
NoItemsComponent,
|
||||
SecretsListComponent,
|
||||
AccessSelectorComponent,
|
||||
],
|
||||
providers: [],
|
||||
bootstrap: [],
|
||||
|
Loading…
Reference in New Issue
Block a user