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