Browse Source

Add expiration date to access

pull/7709/head
Thomas Kaul 6 days ago
parent
commit
2258d35b74
  1. 7
      apps/api/src/app/access/access.controller.ts
  2. 2
      apps/api/src/app/user/user.service.ts
  3. 144
      apps/api/src/services/impersonation/impersonation.service.spec.ts
  4. 34
      apps/api/src/services/impersonation/impersonation.service.ts
  5. 18
      apps/client/src/app/components/access-table/access-table.component.html
  6. 13
      apps/client/src/app/components/access-table/access-table.component.ts
  7. 19
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  8. 20
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html
  9. 22
      apps/client/src/app/components/user-account-access/user-account-access.component.ts
  10. 4
      libs/common/src/lib/dtos/create-access.dto.ts
  11. 12
      libs/common/src/lib/dtos/update-access.dto.ts
  12. 2
      libs/common/src/lib/interfaces/access.interface.ts
  13. 5
      libs/common/src/lib/interfaces/user.interface.ts
  14. 12
      prisma/migrations/20260823140000_added_expires_at_and_last_used_at_to_access/migration.sql
  15. 2
      prisma/schema.prisma

7
apps/api/src/app/access/access.controller.ts

@ -50,11 +50,14 @@ export class AccessController {
});
return accessesWithGranteeUser.map((accessItem) => {
const { alias, granteeUser, id, settings, type } = accessItem;
const { alias, expiresAt, granteeUser, id, lastUsedAt, settings, type } =
accessItem;
return {
alias,
expiresAt,
id,
lastUsedAt,
type,
grantee: granteeUser?.id,
scopes: getScopesOfAccess(accessItem),
@ -105,6 +108,7 @@ export class AccessController {
return await this.accessService.createAccess({
type,
alias: data.alias || undefined,
expiresAt: new Date(data.expiresAt),
granteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } }
: undefined,
@ -192,6 +196,7 @@ export class AccessController {
return await this.accessService.updateAccess({
data: {
alias: data.alias,
expiresAt: new Date(data.expiresAt),
granteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } }
: { disconnect: true },

2
apps/api/src/app/user/user.service.ts

@ -230,7 +230,9 @@ export class UserService {
access: access.map((accessItem) => {
return {
alias: accessItem.alias,
expiresAt: accessItem.expiresAt,
id: accessItem.id,
lastUsedAt: accessItem.lastUsedAt,
scopes: getScopesOfAccess(accessItem)
};
}),

144
apps/api/src/services/impersonation/impersonation.service.spec.ts

@ -12,6 +12,7 @@ import {
import type { UserWithSettings } from '@ghostfolio/common/types';
import { Access } from '@prisma/client';
import { addDays, subDays } from 'date-fns';
import { ImpersonationService } from './impersonation.service';
@ -40,6 +41,8 @@ describe('Impersonation service', () => {
type: SubscriptionType.Basic
});
const updateAccess = jest.fn().mockResolvedValue(undefined);
const configurationService = {
get: (key: string) => {
return key === 'ENABLE_FEATURE_SUBSCRIPTION'
@ -79,7 +82,8 @@ describe('Impersonation service', () => {
}
return access;
}
},
update: updateAccess
},
user: {
findUnique: async () => {
@ -94,6 +98,7 @@ describe('Impersonation service', () => {
return {
getSubscription,
updateAccess,
service: new ImpersonationService(
configurationService,
prismaService,
@ -361,6 +366,143 @@ describe('Impersonation service', () => {
// The guard rejects the request in this case, hence the context must not
// present the data of the authenticated user as impersonated data
describe('With an expiration date', () => {
const expiringAccess = {
granteeUserId: authenticatedUserId,
id: accessId,
scopes: [scopes.portfolioRead],
type: 'PRIVATE',
userId: impersonatedUserId
} as unknown as Access;
const impersonatedUser = {
createdAt: new Date('2024-01-01'),
id: impersonatedUserId,
settings: { settings: { baseCurrency: 'USD' } },
subscriptions: []
};
it('Resolves an access which expires in the future', async () => {
const { service } = createService({
impersonatedUser,
access: {
...expiringAccess,
expiresAt: addDays(new Date(), 1)
} as unknown as Access
});
const { isActive } = await service.resolve({
impersonationId: accessId,
user: authenticatedUser
});
expect(isActive).toEqual(true);
});
it('Refuses an access which has expired', async () => {
const { service } = createService({
impersonatedUser,
access: {
...expiringAccess,
expiresAt: subDays(new Date(), 1)
} as unknown as Access
});
const { isActive, scopes: scopesOfContext } = await service.resolve({
impersonationId: accessId,
user: authenticatedUser
});
expect(isActive).toEqual(false);
expect(scopesOfContext).toEqual(getScopesOfOwnAccess());
});
// An expired access must not fall through to the permission to impersonate
// all users, which would give an administrator the access again
it('Refuses an access which has expired for an administrator', async () => {
const { service } = createService({
impersonatedUser,
access: {
...expiringAccess,
expiresAt: subDays(new Date(), 1)
} as unknown as Access
});
const { isActive } = await service.resolve({
impersonationId: accessId,
user: {
...authenticatedUser,
permissions: [permissions.impersonateAllUsers]
} as unknown as typeof authenticatedUser
});
expect(isActive).toEqual(false);
});
});
describe('With the date of the last usage', () => {
const impersonatedUser = {
createdAt: new Date('2024-01-01'),
id: impersonatedUserId,
settings: { settings: { baseCurrency: 'USD' } },
subscriptions: []
};
const usedAccess = {
granteeUserId: authenticatedUserId,
id: accessId,
scopes: [scopes.portfolioRead],
type: 'PRIVATE',
userId: impersonatedUserId
} as unknown as Access;
it('Records the first usage', async () => {
const { service, updateAccess } = createService({
impersonatedUser,
access: usedAccess
});
await service.resolve({
impersonationId: accessId,
user: authenticatedUser
});
expect(updateAccess).toHaveBeenCalledTimes(1);
});
it('Records the usage of a previous day', async () => {
const { service, updateAccess } = createService({
impersonatedUser,
access: {
...usedAccess,
lastUsedAt: subDays(new Date(), 1)
} as unknown as Access
});
await service.resolve({
impersonationId: accessId,
user: authenticatedUser
});
expect(updateAccess).toHaveBeenCalledTimes(1);
});
// A request which repeats must not write to the database every time
it('Does not record a usage of the same day again', async () => {
const { service, updateAccess } = createService({
impersonatedUser,
access: { ...usedAccess, lastUsedAt: new Date() } as unknown as Access
});
await service.resolve({
impersonationId: accessId,
user: authenticatedUser
});
expect(updateAccess).not.toHaveBeenCalled();
});
});
describe('With an identifier which cannot be resolved', () => {
it('Resolves the own access instead', async () => {
const { service } = createService();

34
apps/api/src/services/impersonation/impersonation.service.ts

@ -16,6 +16,7 @@ import type {
import { Injectable } from '@nestjs/common';
import { Access, AccessType } from '@prisma/client';
import { isBefore, isToday } from 'date-fns';
@Injectable()
export class ImpersonationService {
@ -112,9 +113,12 @@ export class ImpersonationService {
}
});
if (accessObject?.userId) {
if (accessObject?.userId && !isExpired(accessObject)) {
await this.recordUsage(accessObject);
return { access: accessObject, userId: accessObject.userId };
} else if (
!accessObject &&
hasPermission(user.permissions, permissions.impersonateAllUsers)
) {
// The identifier is a user id in this case, hence verify its existence
@ -133,11 +137,37 @@ export class ImpersonationService {
}
});
if (accessObject?.userId) {
if (accessObject?.userId && !isExpired(accessObject)) {
await this.recordUsage(accessObject);
return { access: accessObject, userId: accessObject.userId };
}
}
return { userId: null };
}
/**
* Records that the access has been used. The value is the first usage of the
* day, because a request which repeats must not write to the database again.
*/
private async recordUsage({ id, lastUsedAt }: Access) {
if (lastUsedAt && isToday(lastUsedAt)) {
return;
}
try {
await this.prismaService.access.update({
data: { lastUsedAt: new Date() },
where: { id }
});
} catch {
// The date of the last usage is not essential for the request, hence a
// failure to store it must not fail the request
}
}
}
function isExpired({ expiresAt }: Access) {
return expiresAt ? isBefore(expiresAt, new Date()) : false;
}

18
apps/client/src/app/components/access-table/access-table.component.html

@ -27,6 +27,24 @@
</td>
</ng-container>
<ng-container matColumnDef="lastUsedAt">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Last Used</th>
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
@if (element.lastUsedAt) {
{{ element.lastUsedAt | date: defaultDateFormat }}
} @else {
<span></span>
}
</td>
</ng-container>
<ng-container matColumnDef="expiresAt">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Expiration</th>
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
{{ element.expiresAt | date: defaultDateFormat }}
</td>
</ng-container>
<ng-container matColumnDef="details">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Details</th>
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>

13
apps/client/src/app/components/access-table/access-table.component.ts

@ -1,5 +1,6 @@
import { MCP_ENDPOINT } from '@ghostfolio/common/config';
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { getDateFormatString, getLocale } from '@ghostfolio/common/helper';
import { Access, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes, publicRoutes } from '@ghostfolio/common/routes/routes';
@ -9,6 +10,7 @@ import { NotificationService } from '@ghostfolio/ui/notifications';
import { DataService } from '@ghostfolio/ui/services';
import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard';
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
@ -41,6 +43,7 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ClipboardModule,
CommonModule,
GfAccessLevelIconComponent,
IonIcon,
MatButtonModule,
@ -78,7 +81,14 @@ export class GfAccessTableComponent {
protected readonly dataSource = new MatTableDataSource<Access>();
protected readonly displayedColumns = computed(() => {
const columns = ['alias', 'grantee', 'type', 'details'];
const columns = [
'alias',
'grantee',
'type',
'details',
'lastUsedAt',
'expiresAt'
];
if (this.showActions()) {
columns.push('actions');
@ -87,6 +97,7 @@ export class GfAccessTableComponent {
return columns;
});
protected readonly defaultDateFormat = getDateFormatString(getLocale());
protected readonly getAccessLevel = getAccessLevel;
protected hasPermissionToEnableMcp = false;

19
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts

@ -1,5 +1,6 @@
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos';
import { getToday } from '@ghostfolio/common/helper';
import { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import {
@ -41,6 +42,7 @@ import {
Validators
} from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDatepickerModule } from '@angular/material/datepicker';
import {
MAT_DIALOG_DATA,
MatDialogModule,
@ -49,6 +51,7 @@ import {
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { addYears, endOfDay } from 'date-fns';
import { StatusCodes } from 'http-status-codes';
import { EMPTY, catchError } from 'rxjs';
@ -63,6 +66,7 @@ import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
GfPortfolioFilterFormComponent,
MatButtonModule,
MatDialogModule,
MatDatepickerModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
@ -80,6 +84,7 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
protected accessForm: FormGroup;
protected readonly mode: 'create' | 'update';
protected readonly today = getToday();
private hasExperimentalFeatures = false;
private hasPermissionToEnableMcp = false;
@ -129,6 +134,12 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
this.accessForm = this.formBuilder.group({
accessLevel: getAccessLevel(access?.scopes),
alias: [access?.alias ?? ''],
expiresAt: [
access?.expiresAt
? new Date(access.expiresAt)
: addYears(this.today, 1),
Validators.required
],
filters: [null],
granteeUserId: [
isPrivate ? (access?.grantee ?? null) : null,
@ -203,6 +214,12 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
}
}
private buildExpiresAt() {
const expiresAt = this.accessForm.get('expiresAt')?.value as Date;
return endOfDay(expiresAt).toISOString();
}
private buildFilters(): Filter[] {
return getFiltersFromPortfolioFilterFormValue(
this.accessForm.get('filters')?.value
@ -229,6 +246,7 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
const access: CreateAccessDto = {
alias: this.accessForm.get('alias')?.value,
expiresAt: this.buildExpiresAt(),
filters: filters.length > 0 ? filters : undefined,
granteeUserId: this.accessForm.get('granteeUserId')?.value,
scopes: this.buildScopes(),
@ -288,6 +306,7 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
const access: UpdateAccessDto = {
alias: this.accessForm.get('alias')?.value,
expiresAt: this.buildExpiresAt(),
filters: filters.length > 0 ? filters : undefined,
granteeUserId: this.accessForm.get('granteeUserId')?.value,
id: accessId,

20
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html

@ -45,6 +45,26 @@
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Expiration Date</mat-label>
<input
formControlName="expiresAt"
matInput
[matDatepicker]="expiresAt"
[min]="today"
/>
<mat-datepicker-toggle class="mr-2" matSuffix [for]="expiresAt">
<ion-icon
class="text-muted"
matDatepickerToggleIcon
name="calendar-clear-outline"
/>
</mat-datepicker-toggle>
<mat-datepicker #expiresAt />
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Permission</mat-label>

22
apps/client/src/app/components/user-account-access/user-account-access.component.ts

@ -199,15 +199,19 @@ export class GfUserAccountAccessComponent {
}
private update() {
this.accessesGet = this.user.access.map(({ alias, id, scopes }) => {
return {
id,
scopes,
alias: alias ?? '',
grantee: $localize`Me`,
type: 'PRIVATE'
};
});
this.accessesGet = this.user.access.map(
({ alias, expiresAt, id, lastUsedAt, scopes }) => {
return {
expiresAt,
id,
lastUsedAt,
scopes,
alias: alias ?? '',
grantee: $localize`Me`,
type: 'PRIVATE'
};
}
);
this.dataService
.fetchAccesses()

4
libs/common/src/lib/dtos/create-access.dto.ts

@ -4,6 +4,7 @@ import { Scope, scopes } from '@ghostfolio/common/scopes';
import { AccessType } from '@prisma/client';
import {
IsArray,
IsDateString,
IsEnum,
IsIn,
IsOptional,
@ -16,6 +17,9 @@ export class CreateAccessDto {
@IsString()
alias?: string;
@IsDateString()
expiresAt: string;
@IsArray()
@IsOptional()
filters?: Filter[];

12
libs/common/src/lib/dtos/update-access.dto.ts

@ -1,13 +1,23 @@
import { Filter } from '@ghostfolio/common/interfaces';
import { Scope, scopes } from '@ghostfolio/common/scopes';
import { IsArray, IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
import {
IsArray,
IsDateString,
IsIn,
IsOptional,
IsString,
IsUUID
} from 'class-validator';
export class UpdateAccessDto {
@IsOptional()
@IsString()
alias?: string;
@IsDateString()
expiresAt: string;
@IsArray()
@IsOptional()
filters?: Filter[];

2
libs/common/src/lib/interfaces/access.interface.ts

@ -4,6 +4,8 @@ import { AccessSettings } from './access-settings.interface';
export interface Access {
alias: string | null;
expiresAt: Date;
lastUsedAt?: Date | null;
grantee?: string;
id: string;
scopes: string[];

5
libs/common/src/lib/interfaces/user.interface.ts

@ -10,7 +10,10 @@ import { UserSettings } from './user-settings.interface';
// TODO: Compare with UserWithSettings
export interface User {
access: Pick<Access, 'alias' | 'id' | 'scopes'>[];
access: Pick<
Access,
'alias' | 'expiresAt' | 'id' | 'lastUsedAt' | 'scopes'
>[];
accounts: AccountWithPlatform[];
activitiesCount: number;
activityTypes: ActivityType[];

12
prisma/migrations/20260823140000_added_expires_at_and_last_used_at_to_access/migration.sql

@ -0,0 +1,12 @@
-- AlterTable
ALTER TABLE "Access" ADD COLUMN "expiresAt" TIMESTAMP(3),
ADD COLUMN "lastUsedAt" TIMESTAMP(3);
-- Give an access which was granted before a date in the far future, because it
-- was granted with no expiration date
UPDATE "Access"
SET "expiresAt" = '2030-12-31 23:59:59.999'
WHERE "expiresAt" IS NULL;
-- AlterTable
ALTER TABLE "Access" ALTER COLUMN "expiresAt" SET NOT NULL;

2
prisma/schema.prisma

@ -11,9 +11,11 @@ datasource db {
model Access {
alias String?
createdAt DateTime @default(now())
expiresAt DateTime
granteeUser User? @relation("accessGet", fields: [granteeUserId], onDelete: Cascade, references: [id])
granteeUserId String?
id String @id @default(uuid())
lastUsedAt DateTime?
scopes String[] @default([])
settings Json @default("{}")
type AccessType @default(PRIVATE)

Loading…
Cancel
Save