diff --git a/apps/services/auth/ids-api/src/app/delegations/test/delegations-filters-test-cases.ts b/apps/services/auth/ids-api/src/app/delegations/test/delegations-filters-test-cases.ts index 314f953fb345..f18686a29cc6 100644 --- a/apps/services/auth/ids-api/src/app/delegations/test/delegations-filters-test-cases.ts +++ b/apps/services/auth/ids-api/src/app/delegations/test/delegations-filters-test-cases.ts @@ -287,4 +287,17 @@ export const testCases: Record = { ], }, ), + // Returns available delegations for legal representatives + legalRepresentative1: new TestCase( + createClient({ + clientId: clientId, + supportedDelegationTypes: [AuthDelegationType.LegalRepresentative], + }), + { + fromLegalRepresentative: [person1, person2], + protectedScopes: [], + expectedFrom: [person1, person2], + expectedTypes: [AuthDelegationType.LegalRepresentative], + }, + ), } diff --git a/apps/services/auth/ids-api/src/app/delegations/test/delegations-filters-types.ts b/apps/services/auth/ids-api/src/app/delegations/test/delegations-filters-types.ts index 977e14fb8e86..99bebf40c432 100644 --- a/apps/services/auth/ids-api/src/app/delegations/test/delegations-filters-types.ts +++ b/apps/services/auth/ids-api/src/app/delegations/test/delegations-filters-types.ts @@ -29,6 +29,8 @@ const customScope2 = 'cu2' const customScopeOtherDomain = 'cu-od1' const representativeScope1 = 'pr1' const representativeScope2 = 'pr2' +const legalRepresentativeScope1 = 'lr1' +const legalRepresentativeScope2 = 'lr2' export const legalGuardianScopes = [legalGuardianScope1, legalGuardianScope2] export const procurationHolderScopes = [ @@ -37,12 +39,17 @@ export const procurationHolderScopes = [ ] export const customScopes = [customScope1, customScope2, customScopeOtherDomain] export const representativeScopes = [representativeScope1, representativeScope2] +export const legalRepresentativeScopes = [ + legalRepresentativeScope1, + legalRepresentativeScope2, +] export interface ITestCaseOptions { fromChildren?: string[] fromCompanies?: string[] fromCustom?: string[] fromRepresentative?: string[] + fromLegalRepresentative?: string[] scopes?: string[] protectedScopes?: string[] scopeAccess?: [string, string][] @@ -59,6 +66,7 @@ export class TestCase { fromCompanies: string[] fromCustom: string[] fromRepresentative: string[] + fromLegalRepresentative: string[] scopes: string[] protectedScopes: string[] scopeAccess: [string, string][] @@ -71,11 +79,13 @@ export class TestCase { this.fromCompanies = options.fromCompanies ?? [] this.fromCustom = options.fromCustom ?? [] this.fromRepresentative = options.fromRepresentative ?? [] + this.fromLegalRepresentative = options.fromLegalRepresentative ?? [] this.scopes = options.scopes ?? [ ...legalGuardianScopes, ...procurationHolderScopes, ...customScopes, ...representativeScopes, + ...legalRepresentativeScopes, ] this.protectedScopes = options.protectedScopes ?? [] this.scopeAccess = options.scopeAccess ?? [] @@ -160,6 +170,9 @@ export class TestCase { if (representativeScopes.includes(scopeName)) { result.push(AuthDelegationType.PersonalRepresentative) } + if (legalRepresentativeScopes.includes(scopeName)) { + result.push(AuthDelegationType.LegalRepresentative) + } return result } } diff --git a/apps/services/auth/ids-api/src/app/delegations/test/delegations-filters.spec.ts b/apps/services/auth/ids-api/src/app/delegations/test/delegations-filters.spec.ts index 6538841fba40..c6032e1def00 100644 --- a/apps/services/auth/ids-api/src/app/delegations/test/delegations-filters.spec.ts +++ b/apps/services/auth/ids-api/src/app/delegations/test/delegations-filters.spec.ts @@ -8,6 +8,10 @@ import { MergedDelegationDTO } from '@island.is/auth-api-lib' import { RskRelationshipsClient } from '@island.is/clients-rsk-relationships' import { NationalRegistryClientService } from '@island.is/clients/national-registry-v2' import { FixtureFactory } from '@island.is/services/auth/testing' +import { + AuthDelegationProvider, + AuthDelegationType, +} from '@island.is/shared/types' import { createNationalRegistryUser } from '@island.is/testing/fixtures' import { TestApp, truncate } from '@island.is/testing/nest' @@ -86,6 +90,17 @@ describe('DelegationsController', () => { ), ) + await Promise.all( + testCase.fromLegalRepresentative.map((nationalId) => + factory.createDelegationIndexRecord({ + fromNationalId: nationalId, + toNationalId: testCase.user.nationalId, + type: AuthDelegationType.LegalRepresentative, + provider: AuthDelegationProvider.DistrictCommissionersRegistry, + }), + ), + ) + jest .spyOn(nationalRegistryApi, 'getCustodyChildren') .mockImplementation(async () => testCase.fromChildren) diff --git a/apps/services/auth/ids-api/test/setup.ts b/apps/services/auth/ids-api/test/setup.ts index 7212ed9a56a2..c74ba39f1faa 100644 --- a/apps/services/auth/ids-api/test/setup.ts +++ b/apps/services/auth/ids-api/test/setup.ts @@ -133,9 +133,7 @@ export const setupWithAuth = async ({ .useValue({ getValue: (feature: Features) => !features || features.includes(feature), - }) - .overrideProvider(DelegationsIndexService) - .useClass(MockDelegationsIndexService), + }), hooks: [ useAuth({ auth: user }), useDatabase({ type: 'postgres', provider: SequelizeConfigService }), diff --git a/libs/auth-api-lib/seeders/20240829140032-add-delegation-type-legal-representative.js b/libs/auth-api-lib/seeders/20240829140032-add-delegation-type-legal-representative.js new file mode 100644 index 000000000000..2b6d40797bdb --- /dev/null +++ b/libs/auth-api-lib/seeders/20240829140032-add-delegation-type-legal-representative.js @@ -0,0 +1,49 @@ +'use strict' + +module.exports = { + up(queryInterface) { + return queryInterface.sequelize.query(` + BEGIN; + INSERT INTO delegation_provider + (id, name, description) + VALUES + ('syslumenn', 'Sýslumenn', 'Provider for district commissioners registry'); + + INSERT INTO delegation_type + (id, provider, name, description) + VALUES + ('LegalRepresentative', 'syslumenn', 'Legal Representative', 'Legal Representative delegation type'); + + INSERT INTO api_scope_delegation_types + (api_scope_name, delegation_type) + VALUES + ('@island.is/documents', 'LegalRepresentative'); + + INSERT INTO client_delegation_types + (client_id, delegation_type) + VALUES + ('@island.is/web', 'LegalRepresentative'); + + COMMIT; + `) + }, + + down(queryInterface) { + return queryInterface.sequelize.query(` + BEGIN; + DELETE FROM client_delegation_types + WHERE client_id = '@island.is/web' AND delegation_type = 'LegalRepresentative'; + + DELETE FROM api_scope_delegation_types + WHERE api_scope_name = '@island.is/documents' AND delegation_type = 'LegalRepresentative'; + + DELETE FROM delegation_type + WHERE id = 'LegalRepresentative'; + + DELETE FROM delegation_provider + WHERE id = 'syslumenn'; + + COMMIT; + `) + }, +} diff --git a/libs/auth-api-lib/src/lib/delegations/delegation-dto.mapper.ts b/libs/auth-api-lib/src/lib/delegations/delegation-dto.mapper.ts index e44e2dbea28c..4e3e149f64ac 100644 --- a/libs/auth-api-lib/src/lib/delegations/delegation-dto.mapper.ts +++ b/libs/auth-api-lib/src/lib/delegations/delegation-dto.mapper.ts @@ -1,3 +1,6 @@ +import { AuthDelegationType } from '@island.is/shared/types' + +import { DelegationRecordDTO } from './dto/delegation-index.dto' import { DelegationDTO } from './dto/delegation.dto' import { MergedDelegationDTO } from './dto/merged-delegation.dto' @@ -13,4 +16,14 @@ export class DelegationDTOMapper { scopes: dto.scopes, } } + + public static recordToMergedDelegationDTO( + dto: DelegationRecordDTO, + ): MergedDelegationDTO { + return { + fromNationalId: dto.fromNationalId, + toNationalId: dto.toNationalId, + types: [dto.type 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 014468c3ba9e..8776416617ef 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 @@ -2,10 +2,13 @@ import { BadRequestException, Injectable } from '@nestjs/common' import { InjectModel } from '@nestjs/sequelize' import { User } from '@island.is/auth-nest-tools' +import { NationalRegistryClientService } from '@island.is/clients/national-registry-v2' +import { FeatureFlagService, Features } from '@island.is/nest/feature-flags' import { AuthDelegationProvider, AuthDelegationType, } from '@island.is/shared/types' +import { isDefined } from '@island.is/shared/utils' import { ClientAllowedScope } from '../clients/models/client-allowed-scope.model' import { ClientDelegationType } from '../clients/models/client-delegation-type.model' @@ -22,6 +25,8 @@ import { DelegationsIndexService } from './delegations-index.service' import { DelegationDTO } from './dto/delegation.dto' import { MergedDelegationDTO } from './dto/merged-delegation.dto' +const UNKNOWN_NAME = 'Óþekkt nafn' + type ClientDelegationInfo = Pick< Client, 'supportedDelegationTypes' | 'requireApiScopes' @@ -58,6 +63,8 @@ export class DelegationsIncomingService { private delegationsIncomingWardService: DelegationsIncomingWardService, private delegationsIndexService: DelegationsIndexService, private delegationProviderService: DelegationProviderService, + private nationalRegistryClient: NationalRegistryClientService, + private readonly featureFlagService: FeatureFlagService, ) {} async findAllValid( @@ -116,20 +123,20 @@ export class DelegationsIncomingService { const client = await this.getClientDelegationInfo(user) if (!client?.supportedDelegationTypes) return [] - const types: ClientDelegationType[] = - client.supportedDelegationTypes.filter( + const types: AuthDelegationType[] = client.supportedDelegationTypes + .filter( (dt) => !delegationTypes || delegationTypes.includes(dt.delegationType as AuthDelegationType), ) + .map((t) => t.delegationType as AuthDelegationType) if (types.length == 0) return [] - const providers = await this.delegationProviderService.findProviders( - types.map((t) => t.delegationType), - ) + const providers = await this.delegationProviderService.findProviders(types) - const clientAllowedApiScopes = await this.getClientAllowedApiScopes(user) + const clientAllowedApiScopes: ApiScopeInfo[] = + await this.getClientAllowedApiScopes(user) const delegationPromises = [] @@ -187,6 +194,27 @@ export class DelegationsIncomingService { ) } + if ( + providers.includes(AuthDelegationProvider.DistrictCommissionersRegistry) + ) { + const isLegalRepresentativeDelegationEnabled = + await this.featureFlagService.getValue( + Features.isLegalRepresentativeDelegationEnabled, + true, + user, + ) + if (isLegalRepresentativeDelegationEnabled) { + delegationPromises.push( + this.getAvailableDistrictCommissionersRegistryDelegations( + user, + types, + clientAllowedApiScopes, + client.requireApiScopes, + ), + ) + } + } + const delegationSets = await Promise.all(delegationPromises) let delegations = ([] as MergedDelegationDTO[]) @@ -224,6 +252,56 @@ export class DelegationsIncomingService { return [...mergedDelegationMap.values()] } + private async getAvailableDistrictCommissionersRegistryDelegations( + user: User, + types: AuthDelegationType[], + clientAllowedApiScopes: ApiScopeInfo[], + requireApiScopes?: boolean, + ): Promise { + const records = + await this.delegationsIndexService.getAvailableDistrictCommissionersRegistryRecords( + user, + types, + clientAllowedApiScopes, + requireApiScopes, + ) + const merged = records.map((d) => + DelegationDTOMapper.recordToMergedDelegationDTO(d), + ) + + const persons = ( + await Promise.all( + merged.map((d) => + this.nationalRegistryClient + .getIndividual(d.fromNationalId) + .catch((error) => error), + ), + ) + ) + .filter(this.isNotError) + .filter(isDefined) + .map((individual) => ({ + nationalId: individual.nationalId, + name: individual.name ?? UNKNOWN_NAME, + })) + + merged.forEach((d) => { + const person = persons.find((p) => p.nationalId === d.fromNationalId) + if (person) { + d.fromName = person.name + } + }) + + return merged + } + + /** + * Checks if item is not an instance of Error + */ + private isNotError(item: T | Error): item is T { + return item instanceof Error === false + } + private getClientDelegationInfo( user: User, ): Promise { diff --git a/libs/auth-api-lib/src/lib/delegations/delegations-index.service.ts b/libs/auth-api-lib/src/lib/delegations/delegations-index.service.ts index 5fac1d7c9def..fb382c183b49 100644 --- a/libs/auth-api-lib/src/lib/delegations/delegations-index.service.ts +++ b/libs/auth-api-lib/src/lib/delegations/delegations-index.service.ts @@ -19,6 +19,7 @@ import { IncomingDelegationsCompanyService } from './delegations-incoming-compan import { DelegationsIncomingCustomService } from './delegations-incoming-custom.service' import { DelegationsIncomingRepresentativeService } from './delegations-incoming-representative.service' import { DelegationsIncomingWardService } from './delegations-incoming-ward.service' +import { ApiScopeInfo } from './delegations-incoming.service' import { DelegationRecordDTO, DelegationRecordInputDTO, @@ -302,6 +303,36 @@ export class DelegationsIndexService { }) } + async getAvailableDistrictCommissionersRegistryRecords( + user: User, + types: AuthDelegationType[], + clientAllowedApiScopes: ApiScopeInfo[], + requireApiScopes?: boolean, + ): Promise { + if (requireApiScopes) { + const noSupportedScope = !clientAllowedApiScopes.some( + (s) => + s.supportedDelegationTypes?.some( + (dt) => dt.delegationType == AuthDelegationType.LegalRepresentative, + ) && !s.isAccessControlled, + ) + if (noSupportedScope) { + return [] + } + } + + return await this.delegationIndexModel + .findAll({ + where: { + toNationalId: user.nationalId, + provider: AuthDelegationProvider.DistrictCommissionersRegistry, + type: types, + validTo: { [Op.or]: [{ [Op.gte]: new Date() }, { [Op.is]: null }] }, + }, + }) + .then((d) => d.map((d) => d.toDTO())) + } + /* * Private methods * */ diff --git a/libs/auth-api-lib/src/lib/delegations/dto/delegation-index.dto.ts b/libs/auth-api-lib/src/lib/delegations/dto/delegation-index.dto.ts index c0da33c52460..bc8028468eac 100644 --- a/libs/auth-api-lib/src/lib/delegations/dto/delegation-index.dto.ts +++ b/libs/auth-api-lib/src/lib/delegations/dto/delegation-index.dto.ts @@ -1,10 +1,11 @@ -import { IsDateString, IsNumber, IsOptional, IsString } from 'class-validator' import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' +import { IsDateString, IsNumber, IsOptional, IsString } from 'class-validator' + +import { PageInfoDto } from '@island.is/nest/pagination' import { AuthDelegationProvider, AuthDelegationType, } from '@island.is/shared/types' -import { PageInfoDto } from '@island.is/nest/pagination' export class DelegationRecordDTO { @IsString() @@ -19,6 +20,11 @@ export class DelegationRecordDTO { @IsString() @ApiProperty({ type: String, nullable: true }) subjectId?: string | null + + @IsOptional() + @IsString() + @ApiProperty({ type: String, nullable: true }) + type?: string | null } export class PaginatedDelegationRecordDTO { diff --git a/libs/auth-api-lib/src/lib/delegations/models/delegation-index.model.ts b/libs/auth-api-lib/src/lib/delegations/models/delegation-index.model.ts index a5d8a878043b..3039e2248d08 100644 --- a/libs/auth-api-lib/src/lib/delegations/models/delegation-index.model.ts +++ b/libs/auth-api-lib/src/lib/delegations/models/delegation-index.model.ts @@ -1,3 +1,8 @@ +import { + CreationOptional, + InferAttributes, + InferCreationAttributes, +} from 'sequelize' import { Column, CreatedAt, @@ -6,11 +11,7 @@ import { Table, UpdatedAt, } from 'sequelize-typescript' -import { - type CreationOptional, - InferAttributes, - InferCreationAttributes, -} from 'sequelize' + import { DelegationRecordDTO } from '../dto/delegation-index.dto' @Table({ @@ -78,6 +79,7 @@ export class DelegationIndex extends Model< fromNationalId: this.fromNationalId, toNationalId: this.toNationalId, subjectId: this.subjectId, + type: this.type, } } } diff --git a/libs/auth-api-lib/src/lib/delegations/utils/delegations.ts b/libs/auth-api-lib/src/lib/delegations/utils/delegations.ts index ec0ede8e35c4..f4f36b46df77 100644 --- a/libs/auth-api-lib/src/lib/delegations/utils/delegations.ts +++ b/libs/auth-api-lib/src/lib/delegations/utils/delegations.ts @@ -1,14 +1,16 @@ -import { User } from '@island.is/auth-nest-tools' +import kennitala from 'kennitala' import { Op, WhereOptions } from 'sequelize' + +import { User } from '@island.is/auth-nest-tools' import { AuthDelegationProvider, AuthDelegationType, } from '@island.is/shared/types' + import { DelegationRecordType, PersonalRepresentativeDelegationType, } from '../types/delegationRecord' -import kennitala from 'kennitala' export const delegationProviderTypeMap: Record< AuthDelegationProvider, @@ -23,6 +25,9 @@ export const delegationProviderTypeMap: Record< PersonalRepresentativeDelegationType.PersonalRepresentativePostholf, ], [AuthDelegationProvider.Custom]: [AuthDelegationType.Custom], + [AuthDelegationProvider.DistrictCommissionersRegistry]: [ + AuthDelegationType.LegalRepresentative, + ], } export const getDelegationNoActorWhereClause = (user: User): WhereOptions => { diff --git a/libs/feature-flags/src/lib/features.ts b/libs/feature-flags/src/lib/features.ts index a8cff7fd970c..c2dc640ae9f6 100644 --- a/libs/feature-flags/src/lib/features.ts +++ b/libs/feature-flags/src/lib/features.ts @@ -104,6 +104,9 @@ export enum Features { // Single sign on passkeys isPasskeyRegistrationEnabled = 'isPasskeyRegistrationEnabled', isPasskeyAuthEnabled = 'isPasskeyAuthEnabled', + + // Legal represantative delegation type + isLegalRepresentativeDelegationEnabled = 'isLegalRepresentativeDelegationEnabled', } export enum ServerSideFeature { diff --git a/libs/services/auth/testing/src/fixtures/fixture-factory.ts b/libs/services/auth/testing/src/fixtures/fixture-factory.ts index 41db8c762f87..9e6f3acaf428 100644 --- a/libs/services/auth/testing/src/fixtures/fixture-factory.ts +++ b/libs/services/auth/testing/src/fixtures/fixture-factory.ts @@ -321,6 +321,8 @@ export class FixtureFactory { return AuthDelegationProvider.CompanyRegistry case AuthDelegationType.PersonalRepresentative: return AuthDelegationProvider.PersonalRepresentativeRegistry + case AuthDelegationType.LegalRepresentative: + return AuthDelegationProvider.DistrictCommissionersRegistry default: return '' } diff --git a/libs/shared/types/src/lib/delegation.ts b/libs/shared/types/src/lib/delegation.ts index d33e6a311f2e..ee100d6b4b5c 100644 --- a/libs/shared/types/src/lib/delegation.ts +++ b/libs/shared/types/src/lib/delegation.ts @@ -3,6 +3,7 @@ export enum AuthDelegationType { LegalGuardian = 'LegalGuardian', Custom = 'Custom', PersonalRepresentative = 'PersonalRepresentative', + LegalRepresentative = 'LegalRepresentative', } export enum AuthDelegationProvider { @@ -10,4 +11,5 @@ export enum AuthDelegationProvider { CompanyRegistry = 'fyrirtaekjaskra', PersonalRepresentativeRegistry = 'talsmannagrunnur', Custom = 'delegationdb', + DistrictCommissionersRegistry = 'syslumenn', }