From 17e1a2b5fcb4fce7a5c38db239522b2e48887179 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:16:57 +0200 Subject: [PATCH] Task/remove deprecated permissions attribute from access (#7639) * Remove deprecated permissions attribute from access * Update changelog --- CHANGELOG.md | 1 + apps/api/src/app/access/access.controller.ts | 10 +--- apps/api/src/app/user/user.service.ts | 1 - ...reate-or-update-access-dialog.component.ts | 42 ++++++++++---- .../create-or-update-access-dialog.html | 6 +- .../interfaces/interfaces.ts | 3 +- .../user-account-access.component.ts | 23 ++++---- libs/common/src/lib/dtos/create-access.dto.ts | 12 ++-- libs/common/src/lib/dtos/update-access.dto.ts | 12 ++-- .../src/lib/interfaces/access.interface.ts | 8 --- .../src/lib/interfaces/user.interface.ts | 2 +- libs/common/src/lib/scopes.spec.ts | 57 ++++++++----------- libs/common/src/lib/scopes.ts | 21 ++----- .../migration.sql | 5 ++ prisma/schema.prisma | 21 +++---- 15 files changed, 101 insertions(+), 123 deletions(-) create mode 100644 prisma/migrations/20260820120000_removed_permissions_from_access/migration.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 7947be561..8945eb620 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improved the sorting to be case-insensitive in the account selector component - Refactored the services to use the `@Service()` decorator of _Angular_ +- Removed the deprecated `permissions` attribute of the access in favor of the scopes ## 3.55.0 - 2026-08-19 diff --git a/apps/api/src/app/access/access.controller.ts b/apps/api/src/app/access/access.controller.ts index 8bd22fc25..0b8ae15ea 100644 --- a/apps/api/src/app/access/access.controller.ts +++ b/apps/api/src/app/access/access.controller.ts @@ -49,14 +49,13 @@ export class AccessController { }); return accessesWithGranteeUser.map((accessItem) => { - const { alias, granteeUser, id, permissions, settings } = accessItem; + const { alias, granteeUser, id, settings } = accessItem; const scopes = getScopesOfAccess(accessItem); if (granteeUser) { return { alias, id, - permissions, scopes, grantee: granteeUser?.id, settings: settings as AccessSettings, @@ -67,7 +66,6 @@ export class AccessController { return { alias, id, - permissions, scopes, grantee: 'Public', settings: settings as AccessSettings, @@ -98,10 +96,9 @@ export class AccessController { granteeUser: data.granteeUserId ? { connect: { id: data.granteeUserId } } : undefined, - permissions: data.permissions, scopes: getScopesOfAccess({ granteeUserId: data.granteeUserId, - permissions: data.permissions + scopes: data.scopes }), settings: this.accessService.buildSettings(data.filters), user: { connect: { id: this.request.user.id } } @@ -171,10 +168,9 @@ export class AccessController { granteeUser: data.granteeUserId ? { connect: { id: data.granteeUserId } } : { disconnect: true }, - permissions: data.permissions, scopes: getScopesOfAccess({ granteeUserId: data.granteeUserId, - permissions: data.permissions ?? originalAccess.permissions + scopes: data.scopes ?? originalAccess.scopes }), settings: this.accessService.buildSettings(data.filters) }, diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 0a4f21190..0a3a3ca76 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -220,7 +220,6 @@ export class UserService { return { alias: accessItem.alias, id: accessItem.id, - permissions: accessItem.permissions, scopes: getScopesOfAccess(accessItem) }; }), diff --git a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts index ef6d0ab39..12425b50e 100644 --- a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts +++ b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts @@ -1,6 +1,12 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos'; import { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces'; +import { + SCOPES_OF_READ_ACCESS, + SCOPES_OF_READ_RESTRICTED_ACCESS, + hasScope, + scopes +} from '@ghostfolio/common/scopes'; import { AccountWithPlatform } from '@ghostfolio/common/types'; import { validateObjectForForm } from '@ghostfolio/common/utils'; import { NotificationService } from '@ghostfolio/ui/notifications'; @@ -40,7 +46,6 @@ import { import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; -import { AccessPermission } from '@prisma/client'; import { StatusCodes } from 'http-status-codes'; import { EMPTY, catchError } from 'rxjs'; @@ -111,10 +116,10 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { access?.grantee ?? null, isPublic ? null : Validators.required ], - permissions: [ - access?.permissions[0] ?? AccessPermission.READ_RESTRICTED, - Validators.required - ], + hasScopeToReadValues: hasScope( + access?.scopes, + scopes.portfolioReadValues + ), type: [ { disabled: this.mode === 'update', value: access?.type ?? 'PRIVATE' }, Validators.required @@ -139,7 +144,10 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { ?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((accessType) => { const granteeUserIdControl = this.accessForm.get('granteeUserId'); - const permissionsControl = this.accessForm.get('permissions'); + + const hasScopeToReadValuesControl = this.accessForm.get( + 'hasScopeToReadValues' + ); if (accessType === 'PRIVATE') { granteeUserIdControl?.setValidators(Validators.required); @@ -147,9 +155,9 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { } else { granteeUserIdControl?.clearValidators(); granteeUserIdControl?.setValue(null); - permissionsControl?.setValue( - access?.permissions[0] ?? AccessPermission.READ_RESTRICTED - ); + + // A public access never exposes the monetary values + hasScopeToReadValuesControl?.setValue(false); } granteeUserIdControl?.updateValueAndValidity(); @@ -178,6 +186,18 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { ); } + /** + * The dialog offers the read access only. The write scopes are not granted + * here yet. + */ + private buildScopes() { + return [ + ...(this.accessForm.get('hasScopeToReadValues')?.value + ? SCOPES_OF_READ_ACCESS + : SCOPES_OF_READ_RESTRICTED_ACCESS) + ]; + } + private async createAccess() { const filters = this.buildFilters(); @@ -185,7 +205,7 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { alias: this.accessForm.get('alias')?.value, filters: filters.length > 0 ? filters : undefined, granteeUserId: this.accessForm.get('granteeUserId')?.value, - permissions: [this.accessForm.get('permissions')?.value] + scopes: this.buildScopes() }; try { @@ -244,7 +264,7 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { filters: filters.length > 0 ? filters : undefined, granteeUserId: this.accessForm.get('granteeUserId')?.value, id: accessId, - permissions: [this.accessForm.get('permissions')?.value] + scopes: this.buildScopes() }; try { diff --git a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html index 1736aa9fc..601548e98 100644 --- a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html +++ b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html @@ -36,10 +36,10 @@
Permission - - Restricted view + + Restricted view @if (accessForm.get('type')?.value === 'PRIVATE') { - View + View } diff --git a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts index b4fdd76d1..8d1ac0ba9 100644 --- a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts +++ b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts @@ -1,6 +1,5 @@ import { Access } from '@ghostfolio/common/interfaces'; export interface CreateOrUpdateAccessDialogParams { - // TODO: Include the scopes once the dialog allows to configure them - access?: Omit; + access?: Access; } diff --git a/apps/client/src/app/components/user-account-access/user-account-access.component.ts b/apps/client/src/app/components/user-account-access/user-account-access.component.ts index b481d160a..57857803c 100644 --- a/apps/client/src/app/components/user-account-access/user-account-access.component.ts +++ b/apps/client/src/app/components/user-account-access/user-account-access.component.ts @@ -228,7 +228,7 @@ export class GfUserAccountAccessComponent implements OnInit { alias: access.alias, grantee: access.grantee === 'Public' ? undefined : access.grantee, id: access.id, - permissions: access.permissions, + scopes: access.scopes, settings: access.settings, type: access.type } @@ -247,18 +247,15 @@ export class GfUserAccountAccessComponent implements OnInit { } private update() { - this.accessesGet = this.user.access.map( - ({ alias, id, permissions, scopes }) => { - return { - id, - permissions, - scopes, - alias: alias ?? '', - grantee: $localize`Me`, - type: 'PRIVATE' - }; - } - ); + this.accessesGet = this.user.access.map(({ alias, id, scopes }) => { + return { + id, + scopes, + alias: alias ?? '', + grantee: $localize`Me`, + type: 'PRIVATE' + }; + }); this.dataService .fetchAccesses() diff --git a/libs/common/src/lib/dtos/create-access.dto.ts b/libs/common/src/lib/dtos/create-access.dto.ts index 370b1a2f8..abfaa30a4 100644 --- a/libs/common/src/lib/dtos/create-access.dto.ts +++ b/libs/common/src/lib/dtos/create-access.dto.ts @@ -1,7 +1,7 @@ import { Filter } from '@ghostfolio/common/interfaces'; +import { Scope, scopes } from '@ghostfolio/common/scopes'; -import { AccessPermission } from '@prisma/client'; -import { IsArray, IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; +import { IsArray, IsIn, IsOptional, IsString, IsUUID } from 'class-validator'; export class CreateAccessDto { @IsOptional() @@ -16,10 +16,8 @@ export class CreateAccessDto { @IsUUID() granteeUserId?: string; - /** - * @deprecated Use the scopes instead - */ - @IsEnum(AccessPermission, { each: true }) + @IsArray() + @IsIn(Object.values(scopes), { each: true }) @IsOptional() - permissions?: AccessPermission[]; + scopes?: Scope[]; } diff --git a/libs/common/src/lib/dtos/update-access.dto.ts b/libs/common/src/lib/dtos/update-access.dto.ts index 57d8fe9c0..c96294fc4 100644 --- a/libs/common/src/lib/dtos/update-access.dto.ts +++ b/libs/common/src/lib/dtos/update-access.dto.ts @@ -1,7 +1,7 @@ import { Filter } from '@ghostfolio/common/interfaces'; +import { Scope, scopes } from '@ghostfolio/common/scopes'; -import { AccessPermission } from '@prisma/client'; -import { IsArray, IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; +import { IsArray, IsIn, IsOptional, IsString, IsUUID } from 'class-validator'; export class UpdateAccessDto { @IsOptional() @@ -19,10 +19,8 @@ export class UpdateAccessDto { @IsString() id: string; - /** - * @deprecated Use the scopes instead - */ - @IsEnum(AccessPermission, { each: true }) + @IsArray() + @IsIn(Object.values(scopes), { each: true }) @IsOptional() - permissions?: AccessPermission[]; + scopes?: Scope[]; } diff --git a/libs/common/src/lib/interfaces/access.interface.ts b/libs/common/src/lib/interfaces/access.interface.ts index 819dc60eb..54ddaecfd 100644 --- a/libs/common/src/lib/interfaces/access.interface.ts +++ b/libs/common/src/lib/interfaces/access.interface.ts @@ -1,19 +1,11 @@ import { AccessType } from '@ghostfolio/common/types'; -import { AccessPermission } from '@prisma/client'; - import { AccessSettings } from './access-settings.interface'; export interface Access { alias: string | null; grantee?: string; id: string; - - /** - * @deprecated Use the scopes instead - */ - permissions: AccessPermission[]; - scopes: string[]; settings?: AccessSettings; type: AccessType; diff --git a/libs/common/src/lib/interfaces/user.interface.ts b/libs/common/src/lib/interfaces/user.interface.ts index 8ce8cbaa6..35084bcbb 100644 --- a/libs/common/src/lib/interfaces/user.interface.ts +++ b/libs/common/src/lib/interfaces/user.interface.ts @@ -10,7 +10,7 @@ import { UserSettings } from './user-settings.interface'; // TODO: Compare with UserWithSettings export interface User { - access: Pick[]; + access: Pick[]; accounts: AccountWithPlatform[]; activitiesCount: number; dateOfFirstActivity: Date; diff --git a/libs/common/src/lib/scopes.spec.ts b/libs/common/src/lib/scopes.spec.ts index cb182207e..c19e0ffbb 100644 --- a/libs/common/src/lib/scopes.spec.ts +++ b/libs/common/src/lib/scopes.spec.ts @@ -1,5 +1,6 @@ import { SCOPES_OF_READ_ACCESS, + SCOPES_OF_READ_RESTRICTED_ACCESS, SCOPES_OF_WRITE_ACCESS, getScopesOfAccess, getScopesOfOwnAccess, @@ -11,7 +12,7 @@ import { describe('Scopes', () => { describe('Scopes of read access', () => { // A new scope which reads data has to be added here deliberately, because - // an access with the permission to read receives this list + // an access which reads data receives this list it('Covers every read scope', () => { expect(SCOPES_OF_READ_ACCESS).toEqual([ scopes.accountRead, @@ -55,68 +56,56 @@ describe('Scopes', () => { }); describe('Get scopes of access', () => { - it('Scopes take precedence over the permissions', () => { + it('Gives the scopes of the access', () => { expect( getScopesOfAccess({ granteeUserId: 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d', - permissions: ['READ'], - scopes: [scopes.portfolioRead] + scopes: [scopes.portfolioRead, scopes.portfolioReadValues] }) - ).toEqual([scopes.portfolioRead]); + ).toEqual([scopes.portfolioRead, scopes.portfolioReadValues]); }); - it('Derive from the permission to read', () => { - // An access created before the scopes have been introduced has no scopes + it('Without the scope to read the values', () => { expect( getScopesOfAccess({ granteeUserId: 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d', - permissions: ['READ'], - scopes: [] + scopes: [scopes.portfolioRead] }) - ).toContain(scopes.portfolioReadValues); + ).not.toContain(scopes.portfolioReadValues); }); - it('Derive from the permission to read restricted', () => { + it('Without scopes', () => { expect( getScopesOfAccess({ - granteeUserId: 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d', - permissions: ['READ_RESTRICTED'], - scopes: [] + granteeUserId: 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d' }) - ).not.toContain(scopes.portfolioReadValues); + ).toEqual([]); }); - it('The permission to read gives no write scope', () => { + // TODO: Remove this expectation once the dialog allows to configure the + // write scopes + it('Gives no write scope', () => { const scopesOfAccess = getScopesOfAccess({ granteeUserId: 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d', - permissions: ['READ'], - scopes: [] + scopes: [...SCOPES_OF_READ_ACCESS, ...SCOPES_OF_WRITE_ACCESS] }); for (const scope of SCOPES_OF_WRITE_ACCESS) { expect(scopesOfAccess).not.toContain(scope); } }); - - it('Without permissions and scopes', () => { - expect( - getScopesOfAccess({ - granteeUserId: 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d' - }) - ).not.toContain(scopes.portfolioReadValues); - }); }); describe('Get scopes of public access', () => { it('Allows reading the portfolio', () => { - expect(getScopesOfAccess({ permissions: ['READ_RESTRICTED'] })).toContain( - scopes.portfolioRead - ); + expect( + getScopesOfAccess({ scopes: [...SCOPES_OF_READ_RESTRICTED_ACCESS] }) + ).toContain(scopes.portfolioRead); }); it('Excludes the accounts and the watchlist', () => { const scopesOfAccess = getScopesOfAccess({ - permissions: ['READ_RESTRICTED'] + scopes: [...SCOPES_OF_READ_RESTRICTED_ACCESS] }); expect(scopesOfAccess).not.toContain(scopes.accountRead); @@ -135,10 +124,10 @@ describe('Scopes', () => { ).toEqual([scopes.portfolioRead]); }); - it('Cannot be widened by the permission to read', () => { - expect(getScopesOfAccess({ permissions: ['READ'] })).not.toContain( - scopes.portfolioReadValues - ); + it('Cannot expose the monetary values', () => { + expect( + getScopesOfAccess({ scopes: [...SCOPES_OF_READ_ACCESS] }) + ).not.toContain(scopes.portfolioReadValues); }); }); diff --git a/libs/common/src/lib/scopes.ts b/libs/common/src/lib/scopes.ts index dc12b4ca9..2c0a07e34 100644 --- a/libs/common/src/lib/scopes.ts +++ b/libs/common/src/lib/scopes.ts @@ -1,5 +1,3 @@ -import { AccessPermission } from '@prisma/client'; - /** * Scopes describe what a grantee may do on behalf of the granting user. They * are a separate axis from the permissions, which describe the capabilities of @@ -54,32 +52,25 @@ const SCOPES_OF_PUBLIC_ACCESS: readonly Scope[] = [ scopes.portfolioRead ]; -const SCOPES_OF_READ_RESTRICTED_ACCESS: readonly Scope[] = +export const SCOPES_OF_READ_RESTRICTED_ACCESS: readonly Scope[] = SCOPES_OF_READ_ACCESS.filter((scope) => { return scope !== scopes.portfolioReadValues; }); export function getScopesOfAccess({ granteeUserId, - permissions, scopes: scopesOfAccess }: { granteeUserId?: string | null; - permissions?: AccessPermission[]; scopes?: string[]; }): string[] { - let scopesToEvaluate: readonly string[] = scopesOfAccess ?? []; - - if (!scopesToEvaluate.length) { - // TODO: Remove the derivation from the permissions once they have been - // dropped from the access - scopesToEvaluate = permissions?.includes('READ') - ? SCOPES_OF_READ_ACCESS - : SCOPES_OF_READ_RESTRICTED_ACCESS; - } + const scopesToEvaluate = scopesOfAccess ?? []; if (granteeUserId) { - return [...scopesToEvaluate]; + // TODO: Permit the write scopes once the dialog allows to configure them + return SCOPES_OF_READ_ACCESS.filter((scope) => { + return scopesToEvaluate.includes(scope); + }); } // An access which has not been granted to a user is public, hence it is diff --git a/prisma/migrations/20260820120000_removed_permissions_from_access/migration.sql b/prisma/migrations/20260820120000_removed_permissions_from_access/migration.sql new file mode 100644 index 000000000..60c611226 --- /dev/null +++ b/prisma/migrations/20260820120000_removed_permissions_from_access/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Access" DROP COLUMN "permissions"; + +-- DropEnum +DROP TYPE "AccessPermission"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ca394887f..41f1353c9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -10,17 +10,15 @@ datasource db { model Access { alias String? - createdAt DateTime @default(now()) - granteeUser User? @relation("accessGet", fields: [granteeUserId], onDelete: Cascade, references: [id]) + createdAt DateTime @default(now()) + granteeUser User? @relation("accessGet", fields: [granteeUserId], onDelete: Cascade, references: [id]) granteeUserId String? - id String @id @default(uuid()) - /// @deprecated Use the scopes instead - permissions AccessPermission[] @default([READ_RESTRICTED]) - scopes String[] @default([]) - settings Json @default("{}") - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + scopes String[] @default([]) + settings Json @default("{}") + updatedAt DateTime @updatedAt userId String - user User @relation("accessGive", fields: [userId], onDelete: Cascade, references: [id]) + user User @relation("accessGive", fields: [userId], onDelete: Cascade, references: [id]) @@index([alias]) @@index([granteeUserId]) @@ -333,11 +331,6 @@ model User { @@index([thirdPartyId]) } -enum AccessPermission { - READ - READ_RESTRICTED -} - enum AssetClass { ALTERNATIVE_INVESTMENT COMMODITY