diff --git a/apps/api/src/app/access/access.controller.ts b/apps/api/src/app/access/access.controller.ts index f1c73231e..8bd22fc25 100644 --- a/apps/api/src/app/access/access.controller.ts +++ b/apps/api/src/app/access/access.controller.ts @@ -99,7 +99,10 @@ export class AccessController { ? { connect: { id: data.granteeUserId } } : undefined, permissions: data.permissions, - scopes: getScopesOfAccess({ permissions: data.permissions }), + scopes: getScopesOfAccess({ + granteeUserId: data.granteeUserId, + permissions: data.permissions + }), settings: this.accessService.buildSettings(data.filters), user: { connect: { id: this.request.user.id } } }); @@ -170,6 +173,7 @@ export class AccessController { : { disconnect: true }, permissions: data.permissions, scopes: getScopesOfAccess({ + granteeUserId: data.granteeUserId, permissions: data.permissions ?? originalAccess.permissions }), settings: this.accessService.buildSettings(data.filters) diff --git a/libs/common/src/lib/scopes.spec.ts b/libs/common/src/lib/scopes.spec.ts index 1bb998de9..88b2ffffb 100644 --- a/libs/common/src/lib/scopes.spec.ts +++ b/libs/common/src/lib/scopes.spec.ts @@ -11,6 +11,7 @@ describe('Scopes', () => { it('Scopes take precedence over the permissions', () => { expect( getScopesOfAccess({ + granteeUserId: 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d', permissions: ['READ'], scopes: [scopes.portfolioRead] }) @@ -20,18 +21,65 @@ describe('Scopes', () => { it('Derive from the permission to read', () => { // An access created before the scopes have been introduced has no scopes expect( - getScopesOfAccess({ permissions: ['READ'], scopes: [] }) + getScopesOfAccess({ + granteeUserId: 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d', + permissions: ['READ'], + scopes: [] + }) ).toContain(scopes.portfolioReadValues); }); it('Derive from the permission to read restricted', () => { expect( - getScopesOfAccess({ permissions: ['READ_RESTRICTED'], scopes: [] }) + getScopesOfAccess({ + granteeUserId: 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d', + permissions: ['READ_RESTRICTED'], + scopes: [] + }) ).not.toContain(scopes.portfolioReadValues); }); it('Without permissions and scopes', () => { - expect(getScopesOfAccess({})).not.toContain(scopes.portfolioReadValues); + 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 + ); + }); + + it('Excludes the accounts and the watchlist', () => { + const scopesOfAccess = getScopesOfAccess({ + permissions: ['READ_RESTRICTED'] + }); + + expect(scopesOfAccess).not.toContain(scopes.accountRead); + expect(scopesOfAccess).not.toContain(scopes.watchlistRead); + }); + + it('Cannot be widened by the scopes', () => { + expect( + getScopesOfAccess({ + scopes: [ + scopes.portfolioRead, + scopes.portfolioReadValues, + scopes.watchlistRead + ] + }) + ).toEqual([scopes.portfolioRead]); + }); + + it('Cannot be widened by the permission to read', () => { + expect(getScopesOfAccess({ permissions: ['READ'] })).not.toContain( + scopes.portfolioReadValues + ); }); }); diff --git a/libs/common/src/lib/scopes.ts b/libs/common/src/lib/scopes.ts index 20b04d342..5fe97d4a6 100644 --- a/libs/common/src/lib/scopes.ts +++ b/libs/common/src/lib/scopes.ts @@ -14,6 +14,11 @@ export const scopes = { watchlistRead: 'watchlist:read' } as const; +const SCOPES_OF_PUBLIC_ACCESS: string[] = [ + scopes.activityRead, + scopes.portfolioRead +]; + const SCOPES_OF_READ_RESTRICTED_ACCESS = [ scopes.accountRead, scopes.activityRead, @@ -27,21 +32,33 @@ const SCOPES_OF_READ_ACCESS = [ ]; export function getScopesOfAccess({ + granteeUserId, permissions, scopes }: { + granteeUserId?: string | null; permissions?: AccessPermission[]; scopes?: string[]; }): string[] { - if (scopes?.length) { - return scopes; + let scopesOfAccess = scopes; + + if (!scopesOfAccess?.length) { + // TODO: Remove the derivation from the permissions once they have been + // dropped from the access + scopesOfAccess = permissions?.includes('READ') + ? SCOPES_OF_READ_ACCESS + : SCOPES_OF_READ_RESTRICTED_ACCESS; + } + + if (granteeUserId) { + return scopesOfAccess; } - // TODO: Remove the derivation from the permissions once they have been - // dropped from the access - return permissions?.includes('READ') - ? SCOPES_OF_READ_ACCESS - : SCOPES_OF_READ_RESTRICTED_ACCESS; + // An access which has not been granted to a user is public, hence it is + // narrowed to the scopes exposed by the public endpoints + return scopesOfAccess.filter((scope) => { + return SCOPES_OF_PUBLIC_ACCESS.includes(scope); + }); } /** diff --git a/prisma/migrations/20260815120000_added_scopes_to_access/migration.sql b/prisma/migrations/20260815120000_added_scopes_to_access/migration.sql index 751c47ca8..11f344421 100644 --- a/prisma/migrations/20260815120000_added_scopes_to_access/migration.sql +++ b/prisma/migrations/20260815120000_added_scopes_to_access/migration.sql @@ -4,6 +4,10 @@ ALTER TABLE "Access" ADD COLUMN "scopes" TEXT[] DEFAULT ARRAY[]::TEXT[]; -- Derive the scopes from the permissions of the existing accesses UPDATE "Access" SET "scopes" = CASE + WHEN "granteeUserId" IS NULL THEN ARRAY[ + 'activity:read', + 'portfolio:read' + ] WHEN 'READ' = ANY("permissions") THEN ARRAY[ 'account:read', 'activity:read',