From d5d438371c72772cd483c33d261ff14c5737c836 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gunnlaugur=20Gu=C3=B0mundsson?= <34029342+GunnlaugurG@users.noreply.github.com> Date: Thu, 5 Dec 2024 13:49:19 +0000 Subject: [PATCH] feat(ids-admin): General Mandate delegation for companies (#17026) * initial push for company general mandate * added tests for company general mandate * merge conflict with main resolved * chore: nx format:write update dirty files * fix executor command for dev --------- Co-authored-by: andes-it Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- apps/services/auth/admin-api/project.json | 11 + .../services/auth/delegation-api/project.json | 11 + apps/services/auth/ids-api/project.json | 11 + .../test/delegations.controller.spec.ts | 263 +++++++++++++++--- .../auth/ids-api/test/stubs/genericStubs.ts | 3 + .../admin/delegation-admin-custom.service.ts | 13 +- .../delegations/delegation-scope.service.ts | 10 +- .../delegations-incoming-custom.service.ts | 35 ++- .../delegations-incoming.service.ts | 24 ++ 9 files changed, 339 insertions(+), 42 deletions(-) diff --git a/apps/services/auth/admin-api/project.json b/apps/services/auth/admin-api/project.json index 2866faa0019a..e5d91fd92127 100644 --- a/apps/services/auth/admin-api/project.json +++ b/apps/services/auth/admin-api/project.json @@ -57,6 +57,17 @@ }, "docker-express": { "executor": "Intentionally left blank, only so this target is valid when using `nx show projects --with-target docker-express`" + }, + "dev": { + "executor": "nx:run-commands", + "options": { + "commands": [ + { + "command": "yarn start --project services-auth-admin-api" + } + ], + "parallel": true + } } } } diff --git a/apps/services/auth/delegation-api/project.json b/apps/services/auth/delegation-api/project.json index 366532998b91..66890516fc3c 100644 --- a/apps/services/auth/delegation-api/project.json +++ b/apps/services/auth/delegation-api/project.json @@ -28,6 +28,17 @@ "buildTarget": "services-auth-delegation-api:build" } }, + "dev": { + "executor": "nx:run-commands", + "options": { + "commands": [ + { + "command": "yarn start services-auth-delegation-api" + } + ], + "parallel": true + } + }, "lint": { "executor": "@nx/eslint:lint" }, diff --git a/apps/services/auth/ids-api/project.json b/apps/services/auth/ids-api/project.json index cfefdd77acda..b64e70508680 100644 --- a/apps/services/auth/ids-api/project.json +++ b/apps/services/auth/ids-api/project.json @@ -175,6 +175,17 @@ ], "parallel": true } + }, + "dev": { + "executor": "nx:run-commands", + "options": { + "commands": [ + { + "command": "yarn start services-auth-ids-api" + } + ], + "parallel": true + } } } } diff --git a/apps/services/auth/ids-api/src/app/delegations/test/delegations.controller.spec.ts b/apps/services/auth/ids-api/src/app/delegations/test/delegations.controller.spec.ts index f2dee16513d6..faee06788837 100644 --- a/apps/services/auth/ids-api/src/app/delegations/test/delegations.controller.spec.ts +++ b/apps/services/auth/ids-api/src/app/delegations/test/delegations.controller.spec.ts @@ -1,7 +1,7 @@ import { getModelToken } from '@nestjs/sequelize' -import addDays from 'date-fns/addDays' import request from 'supertest' import { uuid } from 'uuidv4' +import addDays from 'date-fns/addDays' import { ApiScope, @@ -27,12 +27,16 @@ import { } from '@island.is/shared/types' import { createCurrentUser, + createNationalId, createNationalRegistryUser, } from '@island.is/testing/fixtures' import { TestApp } from '@island.is/testing/nest' import { defaultScopes, setupWithAuth } from '../../../../test/setup' -import { getFakeNationalId } from '../../../../test/stubs/genericStubs' +import { + getFakeCompanyNationalId, + getFakeNationalId, +} from '../../../../test/stubs/genericStubs' describe('DelegationsController', () => { describe.each([false, true])( @@ -57,6 +61,8 @@ describe('DelegationsController', () => { clientId: '@island.is/webapp', }) + const representeeNationalId = createNationalId('person') + const scopeValid1 = 'scope/valid1' const scopeValid2 = 'scope/valid2' const scopeValid1and2 = 'scope/valid1and2' @@ -76,7 +82,7 @@ describe('DelegationsController', () => { scopeName: s, })) - const userNationalId = getFakeNationalId() + const userNationalId = createNationalId('person') const user = createCurrentUser({ nationalId: userNationalId, @@ -85,6 +91,8 @@ describe('DelegationsController', () => { }) const domain = createDomain() + let nationalRegistryApiSpy: jest.SpyInstance + let nationalRegistryV3ApiSpy: jest.SpyInstance beforeAll(async () => { app = await setupWithAuth({ @@ -117,36 +125,214 @@ describe('DelegationsController', () => { >(getModelToken(DelegationDelegationType)) nationalRegistryApi = app.get(NationalRegistryClientService) nationalRegistryV3Api = app.get(NationalRegistryV3ClientService) + factory = new FixtureFactory(app) + + client.supportedDelegationTypes = [ + AuthDelegationType.GeneralMandate, + AuthDelegationType.LegalGuardian, + AuthDelegationType.ProcurationHolder, + ] + await factory.createClient(client) + + nationalRegistryApiSpy = jest + .spyOn(nationalRegistryApi, 'getIndividual') + .mockImplementation(async (id) => { + const user = createNationalRegistryUser({ + nationalId: representeeNationalId, + }) + + return user ?? null + }) + + nationalRegistryV3ApiSpy = jest + .spyOn(nationalRegistryV3Api, 'getAllDataIndividual') + .mockImplementation(async () => { + const user = createNationalRegistryUser({ + nationalId: representeeNationalId, + }) + + return { kennitala: user.nationalId, nafn: user.name } + }) + const nationalRegistryV3FeatureService = app.get( NationalRegistryV3FeatureService, ) jest .spyOn(nationalRegistryV3FeatureService, 'getValue') .mockImplementation(async () => featureFlag) - factory = new FixtureFactory(app) }) afterAll(async () => { await app.cleanUp() + nationalRegistryV3ApiSpy.mockClear() + nationalRegistryApiSpy.mockClear() }) - describe('GET with general mandate delegation type', () => { - const representeeNationalId = getFakeNationalId() - let nationalRegistryApiSpy: jest.SpyInstance - let nationalRegistryV3ApiSpy: jest.SpyInstance + describe('GET with general mandate delegation type for company', () => { + const companyNationalId = getFakeCompanyNationalId() + const scopeNames = [ 'api-scope/generalMandate1', 'api-scope/generalMandate2', 'api-scope/generalMandate3', + 'api-scope/procuration1', + 'api-scope/procuration2', ] beforeAll(async () => { - client.supportedDelegationTypes = [ - AuthDelegationType.GeneralMandate, - AuthDelegationType.LegalGuardian, + const delegations = await delegationModel.create({ + id: uuid(), + fromDisplayName: 'Company', + fromNationalId: companyNationalId, + toNationalId: userNationalId, + toName: 'Person', + }) + + await delegationDelegationTypeModel.create({ + delegationId: delegations.id, + delegationTypeId: AuthDelegationType.GeneralMandate, + }) + + await apiScopeModel.bulkCreate( + scopeNames.map((name) => ({ + name, + domainName: domain.name, + enabled: true, + description: `${name}: description`, + displayName: `${name}: display name`, + })), + ) + + await apiScopeDelegationTypeModel.bulkCreate([ + { + apiScopeName: scopeNames[0], + delegationType: AuthDelegationType.GeneralMandate, + }, + { + apiScopeName: scopeNames[1], + delegationType: AuthDelegationType.GeneralMandate, + }, + { + apiScopeName: scopeNames[3], + delegationType: AuthDelegationType.ProcurationHolder, + }, + { + apiScopeName: scopeNames[4], + delegationType: AuthDelegationType.ProcurationHolder, + }, + ]) + }) + + afterAll(async () => { + await apiScopeDelegationTypeModel.destroy({ + where: {}, + cascade: true, + truncate: true, + force: true, + }) + await apiScopeModel.destroy({ + where: {}, + cascade: true, + truncate: true, + force: true, + }) + await delegationDelegationTypeModel.destroy({ + where: {}, + cascade: true, + truncate: true, + force: true, + }) + await delegationModel.destroy({ + where: {}, + cascade: true, + truncate: true, + force: true, + }) + }) + + it('should return mergedDelegationDTO with the generalMandate', async () => { + const response = await server.get('/v2/delegations') + + expect(response.status).toEqual(200) + expect(response.body).toHaveLength(1) + }) + + it('should return all general mandate scopes and other preset scopes', async () => { + const response = await server.get('/delegations/scopes').query({ + fromNationalId: companyNationalId, + delegationType: [ + AuthDelegationType.GeneralMandate, + AuthDelegationType.ProcurationHolder, + ], + }) + + const expected = [ + scopeNames[0], + scopeNames[1], + scopeNames[3], + scopeNames[4], + ] + + expect(response.status).toEqual(200) + expect(response.body).toEqual(expect.arrayContaining(expected)) + expect(response.body).toHaveLength(expected.length) + }) + + it('should return all general mandate scopes and all procuration scopes', async () => { + const response = await server.get('/delegations/scopes').query({ + fromNationalId: companyNationalId, + delegationType: [AuthDelegationType.GeneralMandate], + }) + + const expected = [ + scopeNames[0], + scopeNames[1], + scopeNames[3], + scopeNames[4], ] - await factory.createClient(client) + expect(response.status).toEqual(200) + expect(response.body).toEqual(expect.arrayContaining(expected)) + expect(response.body).toHaveLength(expected.length) + }) + + it('should return all general mandate scopes, and not procuration scopes since from nationalId is person', async () => { + // Assert + const delegation = await delegationModel.create({ + id: uuid(), + fromDisplayName: 'FromPersonPerson', + fromNationalId: representeeNationalId, + toNationalId: userNationalId, + toName: 'Person', + }) + + await delegationDelegationTypeModel.create({ + delegationId: delegation.id, + delegationTypeId: AuthDelegationType.GeneralMandate, + }) + + // Act + const response = await server.get('/delegations/scopes').query({ + fromNationalId: representeeNationalId, + delegationType: [AuthDelegationType.GeneralMandate], + }) + + const expected = [scopeNames[0], scopeNames[1]] + + expect(response.status).toEqual(200) + expect(response.body).toEqual(expect.arrayContaining(expected)) + expect(response.body).toHaveLength(expected.length) + }) + }) + + describe('GET with general mandate delegation type', () => { + const scopeNames = [ + 'api-scope/generalMandate1', + 'api-scope/generalMandate2', + 'api-scope/generalMandate3', + ] + + beforeAll(async () => { const delegations = await delegationModel.create({ id: uuid(), fromDisplayName: 'Test', @@ -187,32 +373,39 @@ describe('DelegationsController', () => { delegationType: AuthDelegationType.GeneralMandate, }, ]) - - nationalRegistryApiSpy = jest - .spyOn(nationalRegistryApi, 'getIndividual') - .mockImplementation(async (id) => { - const user = createNationalRegistryUser({ - nationalId: representeeNationalId, - }) - - return user ?? null - }) - - nationalRegistryV3ApiSpy = jest - .spyOn(nationalRegistryV3Api, 'getAllDataIndividual') - .mockImplementation(async () => { - const user = createNationalRegistryUser({ - nationalId: representeeNationalId, - }) - - return { kennitala: user.nationalId, nafn: user.name } - }) }) afterAll(async () => { - await app.cleanUp() - nationalRegistryApiSpy.mockClear() - nationalRegistryV3ApiSpy.mockClear() + await apiScopeDelegationTypeModel.destroy({ + where: {}, + cascade: true, + truncate: true, + force: true, + }) + await apiScopeModel.destroy({ + where: {}, + cascade: true, + truncate: true, + force: true, + }) + await delegationProviderModel.destroy({ + where: {}, + cascade: true, + truncate: true, + force: true, + }) + await delegationDelegationTypeModel.destroy({ + where: {}, + cascade: true, + truncate: true, + force: true, + }) + await delegationModel.destroy({ + where: {}, + cascade: true, + truncate: true, + force: true, + }) }) it('should return mergedDelegationDTO with the generalMandate', async () => { diff --git a/apps/services/auth/ids-api/test/stubs/genericStubs.ts b/apps/services/auth/ids-api/test/stubs/genericStubs.ts index 91956b00fcc5..177ad31ae023 100644 --- a/apps/services/auth/ids-api/test/stubs/genericStubs.ts +++ b/apps/services/auth/ids-api/test/stubs/genericStubs.ts @@ -6,6 +6,8 @@ export type NameIdTuple = [name: string, id: string] export const getFakeNationalId = () => faker.helpers.replaceSymbolWithNumber('##########') +export const getFakeCompanyNationalId = () => createNationalId('company') + export const getFakeName = () => faker.fake('{{name.firstName}} {{name.lastName}}') @@ -18,4 +20,5 @@ export default { getFakeNationalId, getFakeName, getFakePerson, + getFakeCompanyNationalId, } diff --git a/libs/auth-api-lib/src/lib/delegations/admin/delegation-admin-custom.service.ts b/libs/auth-api-lib/src/lib/delegations/admin/delegation-admin-custom.service.ts index 5cbb11d913b5..e1de9e93df1a 100644 --- a/libs/auth-api-lib/src/lib/delegations/admin/delegation-admin-custom.service.ts +++ b/libs/auth-api-lib/src/lib/delegations/admin/delegation-admin-custom.service.ts @@ -12,6 +12,7 @@ import { TicketStatus, ZendeskService, } from '@island.is/clients/zendesk' +import { CompanyRegistryClientService } from '@island.is/clients/rsk/company-registry' import { Delegation } from '../models/delegation.model' import { DelegationAdminCustomDto } from '../dto/delegation-admin-custom.dto' @@ -45,6 +46,7 @@ export class DelegationAdminCustomService { private delegationScopeService: DelegationScopeService, private namesService: NamesService, private sequelize: Sequelize, + private rskCompanyInfoService: CompanyRegistryClientService, ) {} private getZendeskCustomFields(ticket: Ticket): { @@ -276,9 +278,7 @@ export class DelegationAdminCustomService { }) } - if ( - !(kennitala.isPerson(fromNationalId) && kennitala.isPerson(toNationalId)) - ) { + if (!kennitala.isPerson(toNationalId)) { throw new BadRequestException({ message: 'National Ids are not valid', error: ErrorCodes.INPUT_VALIDATION_INVALID_PERSON, @@ -300,7 +300,11 @@ export class DelegationAdminCustomService { }, ): Promise { const [fromDisplayName, toName] = await Promise.all([ - this.namesService.getPersonName(delegation.fromNationalId), + kennitala.isPerson(delegation.fromNationalId) + ? this.namesService.getPersonName(delegation.fromNationalId) + : this.rskCompanyInfoService + .getCompany(delegation.fromNationalId) + .then((company) => company?.name ?? ''), this.namesService.getPersonName(delegation.toNationalId), ]) @@ -310,6 +314,7 @@ export class DelegationAdminCustomService { { fromNationalId: delegation.fromNationalId, toNationalId: delegation.toNationalId, + domainName: null, }, { referenceId: delegation.referenceId, diff --git a/libs/auth-api-lib/src/lib/delegations/delegation-scope.service.ts b/libs/auth-api-lib/src/lib/delegations/delegation-scope.service.ts index d12b1d8af8e4..bb74ed0a4d6e 100644 --- a/libs/auth-api-lib/src/lib/delegations/delegation-scope.service.ts +++ b/libs/auth-api-lib/src/lib/delegations/delegation-scope.service.ts @@ -5,6 +5,7 @@ import addDays from 'date-fns/addDays' import startOfDay from 'date-fns/startOfDay' import { Op, Transaction } from 'sequelize' import { uuid } from 'uuidv4' +import * as kennitala from 'kennitala' import { SyslumennService } from '@island.is/clients/syslumenn' import { logger } from '@island.is/logging' @@ -209,7 +210,14 @@ export class DelegationScopeService { { model: ApiScopeDelegationType, where: { - delegationType: AuthDelegationType.GeneralMandate, + delegationType: { + [Op.or]: kennitala.isCompany(fromNationalId) + ? [ + AuthDelegationType.GeneralMandate, + AuthDelegationType.ProcurationHolder, + ] + : [AuthDelegationType.GeneralMandate], + }, }, }, ], diff --git a/libs/auth-api-lib/src/lib/delegations/delegations-incoming-custom.service.ts b/libs/auth-api-lib/src/lib/delegations/delegations-incoming-custom.service.ts index ee60fb61c692..e11e509095bd 100644 --- a/libs/auth-api-lib/src/lib/delegations/delegations-incoming-custom.service.ts +++ b/libs/auth-api-lib/src/lib/delegations/delegations-incoming-custom.service.ts @@ -196,17 +196,48 @@ export class DelegationsIncomingCustomService { ) } + /** + * Finds all companies that have a general mandate for the user. + * @param user + * @param clientAllowedApiScopes + * @param requireApiScopes + */ + async findCompanyGeneralMandate( + user: User, + clientAllowedApiScopes: ApiScopeInfo[], + requireApiScopes: boolean, + ): Promise { + const delegations = await this.findAllAvailableGeneralMandate( + user, + clientAllowedApiScopes, + requireApiScopes, + [AuthDelegationType.ProcurationHolder], + ) + + return delegations.filter((d) => kennitala.isCompany(d.fromNationalId)) + } + + /** + * Finds all individuals that have a general mandate for the user. + * @param user + * @param clientAllowedApiScopes + * @param requireApiScopes + * @param supportedDelegationTypes + */ async findAllAvailableGeneralMandate( user: User, clientAllowedApiScopes: ApiScopeInfo[], requireApiScopes: boolean, + supportedDelegationTypes = [AuthDelegationType.GeneralMandate], ): Promise { const customApiScopes = clientAllowedApiScopes.filter( (s) => !s.isAccessControlled && this.filterByCustomScopeRule(s) && - s.supportedDelegationTypes?.some( - (dt) => dt.delegationType === AuthDelegationType.GeneralMandate, + s.supportedDelegationTypes?.some((dt) => + supportedDelegationTypes.includes( + dt.delegationType as AuthDelegationType, + ), ), ) diff --git a/libs/auth-api-lib/src/lib/delegations/delegations-incoming.service.ts b/libs/auth-api-lib/src/lib/delegations/delegations-incoming.service.ts index b2e0775d3b48..05ddc050ee0f 100644 --- a/libs/auth-api-lib/src/lib/delegations/delegations-incoming.service.ts +++ b/libs/auth-api-lib/src/lib/delegations/delegations-incoming.service.ts @@ -176,6 +176,25 @@ export class DelegationsIncomingService { ) } + // If procuration holder is enabled, we need to get the general mandate delegations + if (types?.includes(AuthDelegationType.ProcurationHolder)) { + const isGeneralMandateDelegationEnabled = + await this.featureFlagService.getValue( + Features.isGeneralMandateDelegationEnabled, + false, + user, + ) + if (isGeneralMandateDelegationEnabled) { + delegationPromises.push( + this.delegationsIncomingCustomService.findCompanyGeneralMandate( + user, + clientAllowedApiScopes, + client.requireApiScopes, + ), + ) + } + } + if (providers.includes(AuthDelegationProvider.CompanyRegistry)) { delegationPromises.push( this.incomingDelegationsCompanyService @@ -293,6 +312,11 @@ export class DelegationsIncomingService { new Map(), ) + // Remove duplicate delegationTypes.. + mergedDelegationMap.forEach((delegation) => { + delegation.types = Array.from(new Set(delegation.types)) + }) + return [...mergedDelegationMap.values()] }