[SM-949] Add Event Logs to Service Account (#6546)

* Add Event Logs to Service Account

* Update bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/event-logs/service-accounts-events.component.ts

Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com>

* Add takeUntil import

* add service account access guard

---------

Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com>
Co-authored-by: Thomas Avery <tavery@bitwarden.com>
This commit is contained in:
Robyn MacCallum 2023-10-19 17:56:51 -04:00 committed by GitHub
parent 87dbe8997d
commit e9f0c07b02
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 263 additions and 0 deletions

View File

@ -0,0 +1,43 @@
import { Injectable } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventResponse } from "@bitwarden/common/models/response/event.response";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
@Injectable({
providedIn: "root",
})
export class ServiceAccountEventLogApiService {
constructor(private apiService: ApiService) {}
async getEvents(
serviceAccountId: string,
start: string,
end: string,
token: string
): Promise<ListResponse<EventResponse>> {
const r = await this.apiService.send(
"GET",
this.addEventParameters("/sm/events/service-accounts/" + serviceAccountId, start, end, token),
null,
true,
true
);
return new ListResponse(r, EventResponse);
}
private addEventParameters(base: string, start: string, end: string, token: string) {
if (start != null) {
base += "?start=" + start;
}
if (end != null) {
base += base.indexOf("?") > -1 ? "&" : "?";
base += "end=" + end;
}
if (token != null) {
base += base.indexOf("?") > -1 ? "&" : "?";
base += "continuationToken=" + token;
}
return base;
}
}

View File

@ -0,0 +1,105 @@
<div class="tw-mb-4">
<h1>{{ "eventLogs" | i18n }}</h1>
<div class="tw-mt-4 tw-flex tw-items-center">
<bit-form-field>
<bit-label>{{ "from" | i18n }}</bit-label>
<input
bitInput
type="datetime-local"
placeholder="{{ 'startDate' | i18n }}"
[(ngModel)]="start"
(change)="dirtyDates = true"
/>
</bit-form-field>
<span class="tw-mx-2">-</span>
<bit-form-field>
<bit-label>{{ "to" | i18n }}</bit-label>
<input
bitInput
type="datetime-local"
placeholder="{{ 'endDate' | i18n }}"
[(ngModel)]="end"
(change)="dirtyDates = true"
/>
</bit-form-field>
<form #refreshForm [appApiAction]="refreshPromise">
<button
class="tw-mx-3 tw-mt-1"
type="button"
bitButton
buttonType="primary"
(click)="loadEvents(true)"
[disabled]="loaded && refreshForm.loading"
>
{{ "update" | i18n }}
</button>
</form>
<form #exportForm [appApiAction]="exportPromise">
<button
type="button"
class="tw-mt-1"
bitButton
[ngClass]="{ loading: exportForm.loading }"
(click)="exportEvents()"
[disabled]="(loaded && exportForm.loading) || dirtyDates"
>
<span>{{ "export" | i18n }}</span>
<i
class="bwi bwi-fw"
aria-hidden="true"
[ngClass]="{
'bwi-sign-in': !exportForm.loading,
'bwi-spinner bwi-spin': exportForm.loading
}"
></i>
</button>
</form>
</div>
</div>
<ng-container *ngIf="!loaded">
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<ng-container *ngIf="loaded">
<p *ngIf="!events || !events.length">{{ "noEventsInList" | i18n }}</p>
<bit-table *ngIf="events && events.length">
<ng-container header>
<tr>
<th bitCell>{{ "timestamp" | i18n }}</th>
<th bitCell>{{ "client" | i18n }}</th>
<th bitCell>{{ "event" | i18n }}</th>
</tr>
</ng-container>
<ng-template body>
<tr bitRow *ngFor="let e of events" alignContent="top">
<td bitCell class="tw-whitespace-nowrap">{{ e.date | date : "medium" }}</td>
<td bitCell>
<span title="{{ e.appName }}, {{ e.ip }}">{{ e.appName }}</span>
</td>
<td bitCell [innerHTML]="e.message"></td>
</tr>
</ng-template>
</bit-table>
<button
#moreBtn
[appApiAction]="morePromise"
type="button"
bitButton
buttonType="primary"
(click)="loadEvents(false)"
[disabled]="loaded && $any(moreBtn).loading"
*ngIf="continuationToken"
>
<i
class="bwi bwi-spinner bwi-spin"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
*ngIf="moreBtn.loading"
></i>
<span>{{ "loadMore" | i18n }}</span>
</button>
</ng-container>

View File

@ -0,0 +1,77 @@
import { Component, OnDestroy } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { Subject, takeUntil } from "rxjs";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { BaseEventsComponent } from "@bitwarden/web-vault/app/common/base.events.component";
import { EventService } from "@bitwarden/web-vault/app/core";
import { EventExportService } from "@bitwarden/web-vault/app/tools/event-export";
import { ServiceAccountEventLogApiService } from "./service-account-event-log-api.service";
@Component({
selector: "sm-service-accounts-events",
templateUrl: "./service-accounts-events.component.html",
})
export class ServiceAccountEventsComponent extends BaseEventsComponent implements OnDestroy {
exportFileName = "service-account-events";
private destroy$ = new Subject<void>();
private serviceAccountId: string;
constructor(
eventService: EventService,
private serviceAccountEventsApiService: ServiceAccountEventLogApiService,
private route: ActivatedRoute,
i18nService: I18nService,
exportService: EventExportService,
platformUtilsService: PlatformUtilsService,
logService: LogService,
fileDownloadService: FileDownloadService
) {
super(
eventService,
i18nService,
exportService,
platformUtilsService,
logService,
fileDownloadService
);
}
async ngOnInit() {
// eslint-disable-next-line rxjs/no-async-subscribe
this.route.params.pipe(takeUntil(this.destroy$)).subscribe(async (params) => {
this.serviceAccountId = params.serviceAccountId;
await this.load();
});
}
async load() {
await this.loadEvents(true);
this.loaded = true;
}
protected requestEvents(startDate: string, endDate: string, continuationToken: string) {
return this.serviceAccountEventsApiService.getEvents(
this.serviceAccountId,
startDate,
endDate,
continuationToken
);
}
protected getUserName() {
return {
name: this.i18nService.t("serviceAccount") + " " + this.serviceAccountId,
email: "",
};
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@ -0,0 +1,28 @@
import { inject } from "@angular/core";
import { ActivatedRouteSnapshot, CanActivateFn, createUrlTreeFromSnapshot } from "@angular/router";
import { ServiceAccountService } from "../service-account.service";
/**
* Redirects to service accounts page if the user doesn't have access to service account.
*/
export const serviceAccountAccessGuard: CanActivateFn = async (route: ActivatedRouteSnapshot) => {
const serviceAccountService = inject(ServiceAccountService);
try {
const serviceAccount = await serviceAccountService.getByServiceAccountId(
route.params.serviceAccountId,
route.params.organizationId
);
if (serviceAccount) {
return true;
}
} catch {
return createUrlTreeFromSnapshot(route, [
"/sm",
route.params.organizationId,
"service-accounts",
]);
}
return createUrlTreeFromSnapshot(route, ["/sm", route.params.organizationId, "service-accounts"]);
};

View File

@ -13,6 +13,7 @@
<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-link [route]="['events']">{{ "eventLogs" | i18n }}</bit-tab-link>
</bit-tab-nav-bar>
<button
type="button"

View File

@ -2,6 +2,8 @@ import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { AccessTokenComponent } from "./access/access-tokens.component";
import { ServiceAccountEventsComponent } from "./event-logs/service-accounts-events.component";
import { serviceAccountAccessGuard } from "./guards/service-account-access.guard";
import { ServiceAccountPeopleComponent } from "./people/service-account-people.component";
import { ServiceAccountProjectsComponent } from "./projects/service-account-projects.component";
import { ServiceAccountComponent } from "./service-account.component";
@ -15,6 +17,7 @@ const routes: Routes = [
{
path: ":serviceAccountId",
component: ServiceAccountComponent,
canActivate: [serviceAccountAccessGuard],
children: [
{
path: "",
@ -33,6 +36,10 @@ const routes: Routes = [
path: "projects",
component: ServiceAccountProjectsComponent,
},
{
path: "events",
component: ServiceAccountEventsComponent,
},
],
},
];

View File

@ -11,6 +11,7 @@ import { AccessTokenDialogComponent } from "./access/dialogs/access-token-dialog
import { ExpirationOptionsComponent } from "./access/dialogs/expiration-options.component";
import { ServiceAccountDeleteDialogComponent } from "./dialog/service-account-delete-dialog.component";
import { ServiceAccountDialogComponent } from "./dialog/service-account-dialog.component";
import { ServiceAccountEventsComponent } from "./event-logs/service-accounts-events.component";
import { ServiceAccountPeopleComponent } from "./people/service-account-people.component";
import { ServiceAccountProjectsComponent } from "./projects/service-account-projects.component";
import { ServiceAccountComponent } from "./service-account.component";
@ -29,6 +30,7 @@ import { ServiceAccountsComponent } from "./service-accounts.component";
ServiceAccountComponent,
ServiceAccountDeleteDialogComponent,
ServiceAccountDialogComponent,
ServiceAccountEventsComponent,
ServiceAccountPeopleComponent,
ServiceAccountProjectsComponent,
ServiceAccountsComponent,