Browse Source

Task/restrict support for filtering to public access for portfolio sharing (#7753)

* Restrict support for filtering to public access

* Update changelog
main
Thomas Kaul 8 hours ago
committed by GitHub
parent
commit
716697ec6a
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 26
      apps/api/src/app/access/access.controller.ts
  3. 61
      apps/api/src/app/endpoints/mcp/mcp.controller.ts
  4. 4
      apps/api/src/services/impersonation/impersonation.service.ts
  5. 18
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  6. 15
      libs/common/src/lib/helper.spec.ts
  7. 4
      libs/common/src/lib/helper.ts
  8. 3
      libs/common/src/lib/types/impersonation-context.type.ts

1
CHANGELOG.md

@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Restricted the support for filtering to the public access to share the portfolio (experimental)
- Improved the language localization for German (`de`)
### Fixed

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

@ -4,7 +4,10 @@ import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos';
import { SubscriptionType } from '@ghostfolio/common/enums';
import { isValidGranteeOfAccess } from '@ghostfolio/common/helper';
import {
canApplyFiltersToAccess,
isValidGranteeOfAccess
} from '@ghostfolio/common/helper';
import { Access, AccessSettings } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import { getScopesOfAccess } from '@ghostfolio/common/scopes';
@ -53,6 +56,8 @@ export class AccessController {
const { alias, expiresAt, granteeUser, id, lastUsedAt, settings, type } =
accessItem;
const { filters } = (settings ?? {}) as AccessSettings;
return {
alias,
expiresAt,
@ -61,7 +66,7 @@ export class AccessController {
type,
grantee: granteeUser?.id,
scopes: getScopesOfAccess(accessItem),
settings: settings as AccessSettings
settings: canApplyFiltersToAccess({ type }) ? { filters } : {}
};
});
}
@ -104,6 +109,13 @@ export class AccessController {
);
}
if (data.filters?.length && !canApplyFiltersToAccess({ type })) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
try {
return await this.accessService.createAccess({
type,
@ -192,6 +204,16 @@ export class AccessController {
);
}
if (
data.filters?.length &&
!canApplyFiltersToAccess({ type: originalAccess.type })
) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
try {
return await this.accessService.updateAccess({
data: {

61
apps/api/src/app/endpoints/mcp/mcp.controller.ts

@ -88,12 +88,7 @@ export class GhostfolioMcpController {
})
public async getActivities(
@Impersonation()
{
filters,
scopes: scopesOfAccess,
userId,
userSettings
}: ImpersonationContext,
{ scopes: scopesOfAccess, userId, userSettings }: ImpersonationContext,
@Payload()
{
activityTypes,
@ -113,65 +108,18 @@ export class GhostfolioMcpController {
}));
}
const filtersOfAccess = filters ?? [];
const filtersOfTool = this.apiService.buildFiltersFromQueryParams({
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAssetClasses: assetClasses?.join(','),
filterByDataSource: holding?.dataSource,
filterBySymbol: holding?.symbol
});
// A tool must never widen the access, hence a filter of the tool which
// the access does not permit gives no activity
const filtersOfToolOutsideAccess = filtersOfTool.filter(({ id, type }) => {
const filtersOfAccessOfType = filtersOfAccess.filter((filter) => {
return filter.type === type;
});
return (
filtersOfAccessOfType.length > 0 &&
!filtersOfAccessOfType.some((filter) => {
return filter.id === id;
})
);
});
if (filtersOfToolOutsideAccess.length > 0) {
const valuesOutsideAccess = filtersOfToolOutsideAccess
.map(({ id }) => {
return id;
})
.join(', ');
return {
content: [
{
text: `No activities found. The access does not permit these values of the parameters: ${valuesOutsideAccess}.`,
type: 'text' as const
}
]
};
}
const typesOfFiltersOfTool = new Set(
filtersOfTool.map(({ type }) => {
return type;
})
);
// The filters of a type are combined with a logical or, hence a filter of
// the tool replaces the filters of the access of the same type instead of
// joining them
const filtersOfAccessOutsideTool = filtersOfAccess.filter(({ type }) => {
return !typesOfFiltersOfTool.has(type);
});
const table = await this.aiService.getActivitiesTable({
endDate,
filters,
skip,
startDate,
userId,
filters: [...filtersOfAccessOutsideTool, ...filtersOfTool],
take: take ?? MCP_MAX_ACTIVITIES,
types: activityTypes,
userCurrency: userSettings.baseCurrency,
@ -194,10 +142,9 @@ export class GhostfolioMcpController {
name: 'get-portfolio'
})
public async getPortfolio(
@Impersonation() { filters, userId, userSettings }: ImpersonationContext
@Impersonation() { userId, userSettings }: ImpersonationContext
) {
const prompt = await this.aiService.getPrompt({
filters,
userId,
languageCode: DEFAULT_LANGUAGE_CODE,
mode: 'portfolio',

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

@ -3,7 +3,7 @@ import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscripti
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { AccessSettings, UserSettings } from '@ghostfolio/common/interfaces';
import { UserSettings } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import {
getScopesOfAccess,
@ -65,11 +65,9 @@ export class ImpersonationService {
where: { id: impersonatedUserId }
});
const { filters } = (access?.settings ?? {}) as AccessSettings;
const settings = impersonatedUser?.settings?.settings as UserSettings;
return {
filters,
accessId: impersonationId,
authenticatedUserSubscription: user?.subscription,
isActive: true,

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

@ -1,6 +1,7 @@
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_LOCALE } from '@ghostfolio/common/config';
import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos';
import { canApplyFiltersToAccess } from '@ghostfolio/common/helper';
import { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import {
@ -52,6 +53,7 @@ import {
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { AccessType } from '@prisma/client';
import { addYears, endOfDay, isBefore, isValid, startOfDay } from 'date-fns';
import { StatusCodes } from 'http-status-codes';
import { EMPTY, catchError } from 'rxjs';
@ -112,7 +114,10 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
}
public get canApplyFilters() {
return this.isPublicAccess && this.hasExperimentalFeatures;
return (
canApplyFiltersToAccess({ type: this.accessType }) &&
this.hasExperimentalFeatures
);
}
public get canGrantMcpAccess() {
@ -177,7 +182,7 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
this.accessForm
.get('type')
?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((accessType) => {
.subscribe((accessType: AccessType) => {
const granteeUserIdControl = this.accessForm.get('granteeUserId');
if (accessType === 'PRIVATE') {
@ -191,8 +196,7 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
this.accessForm.get('accessLevel')?.setValue('READ_RESTRICTED');
}
if (accessType !== 'PUBLIC') {
// Only a public access can be limited to a part of the portfolio
if (!canApplyFiltersToAccess({ type: accessType })) {
this.accessForm.get('filters')?.setValue(null);
}
@ -208,8 +212,12 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
return this.accessForm?.get('accessLevel')?.value as AccessLevel;
}
protected get accessType(): AccessType {
return this.accessForm?.get('type')?.value as AccessType;
}
protected get isPublicAccess() {
return this.accessForm?.get('type')?.value === 'PUBLIC';
return this.accessType === 'PUBLIC';
}
protected get showExpiresAtErrorMessage() {

15
libs/common/src/lib/helper.spec.ts

@ -3,6 +3,7 @@ import {
TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config';
import {
canApplyFiltersToAccess,
extractNumberFromString,
getCountryCodeFromCurrency,
getNumberFormatGroup,
@ -19,6 +20,20 @@ import {
import { UserSettings } from '@ghostfolio/common/interfaces';
describe('Helper', () => {
describe('Can apply filters to access', () => {
it('An access of the model context protocol', () => {
expect(canApplyFiltersToAccess({ type: 'MCP' })).toEqual(false);
});
it('A private access', () => {
expect(canApplyFiltersToAccess({ type: 'PRIVATE' })).toEqual(false);
});
it('A public access', () => {
expect(canApplyFiltersToAccess({ type: 'PUBLIC' })).toEqual(true);
});
});
describe('Extract number from string', () => {
it('Get decimal number', () => {
expect(extractNumberFromString({ value: '999.99' })).toEqual(999.99);

4
libs/common/src/lib/helper.ts

@ -174,6 +174,10 @@ export function calculateMovingAverage({
.toNumber();
}
export function canApplyFiltersToAccess({ type }: { type: AccessType }) {
return type === 'PUBLIC';
}
export function canDeleteAssetProfile({
activitiesCount,
isBenchmark,

3
libs/common/src/lib/types/impersonation-context.type.ts

@ -1,4 +1,4 @@
import { Filter, UserSettings } from '@ghostfolio/common/interfaces';
import { UserSettings } from '@ghostfolio/common/interfaces';
import { UserWithSettings } from '@ghostfolio/common/types';
/**
@ -10,7 +10,6 @@ import { UserWithSettings } from '@ghostfolio/common/types';
export interface ImpersonationContext {
accessId?: string;
authenticatedUserSubscription?: UserWithSettings['subscription'];
filters?: Filter[];
isActive: boolean;
scopes: string[];
userId: string;

Loading…
Cancel
Save