IdentityAttributeValidationService.java

/*
 * Copyright (c) 2002-2024, City of Paris
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions
 * are met:
 *
 *  1. Redistributions of source code must retain the above copyright notice
 *     and the following disclaimer.
 *
 *  2. Redistributions in binary form must reproduce the above copyright notice
 *     and the following disclaimer in the documentation and/or other materials
 *     provided with the distribution.
 *
 *  3. Neither the name of 'Mairie de Paris' nor 'Lutece' nor the names of its
 *     contributors may be used to endorse or promote products derived from
 *     this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
 * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
 * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
 * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
 * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
 * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
 * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
 * POSSIBILITY OF SUCH DAMAGE.
 *
 * License 1.0
 */
package fr.paris.lutece.plugins.identitystore.service.attribute;

import java.text.ParseException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.time.DateUtils;

import fr.paris.lutece.plugins.geocodes.business.City;
import fr.paris.lutece.plugins.geocodes.business.Country;
import fr.paris.lutece.plugins.geocodes.service.GeoCodesService;
import fr.paris.lutece.plugins.identitystore.business.attribute.AttributeKey;
import fr.paris.lutece.plugins.identitystore.cache.IdentityAttributeValidationCache;
import fr.paris.lutece.plugins.identitystore.service.contract.AttributeCertificationDefinitionService;
import fr.paris.lutece.plugins.identitystore.service.identity.IdentityAttributeNotFoundException;
import fr.paris.lutece.plugins.identitystore.v3.web.rs.dto.common.AttributeChangeStatus;
import fr.paris.lutece.plugins.identitystore.v3.web.rs.dto.common.AttributeDto;
import fr.paris.lutece.plugins.identitystore.v3.web.rs.dto.common.AttributeStatus;
import fr.paris.lutece.plugins.identitystore.v3.web.rs.dto.common.ChangeResponse;
import fr.paris.lutece.plugins.identitystore.v3.web.rs.dto.common.IdentityDto;
import fr.paris.lutece.plugins.identitystore.v3.web.rs.util.Constants;
import fr.paris.lutece.plugins.identitystore.v3.web.rs.util.ResponseStatusFactory;
import fr.paris.lutece.plugins.identitystore.web.exception.IdentityStoreException;
import fr.paris.lutece.portal.service.spring.SpringContextService;
import fr.paris.lutece.portal.service.util.AppPropertiesService;

/**
 * Service class used to validate attribute values in requests
 */
public class IdentityAttributeValidationService
{

    private final IdentityAttributeValidationCache _cache = SpringContextService.getBean( "identitystore.identityAttributeValidationCache" );
    private final int pivotCertificationLevelThreshold = AppPropertiesService
            .getPropertyInt( "identitystore.identity.attribute.pivot.certification.level.threshold", 400 );
    private static IdentityAttributeValidationService _instance;

    public static IdentityAttributeValidationService instance( )
    {
        if ( _instance == null )
        {
            _instance = new IdentityAttributeValidationService( );
            _instance._cache.refresh( );
        }
        return _instance;
    }

    public boolean validateAttribute( final String key, final String value) throws IdentityAttributeNotFoundException {
        return _cache.get( key ).matcher( value ).matches( );
    }

    /**
     * Validates all attribute values stored in the provided identity, according to each attribute validation regex. Adds validation error statuses in the
     * response in case of invalid values, and put the status to FAILURE.
     *
     * @param identity
     *            the identity
     * @param response
     *            the response
     */
    public void validateIdentityAttributeValues( final IdentityDto identity, final ChangeResponse response ) throws IdentityAttributeNotFoundException
    {
        final List<AttributeStatus> attrStatusList = new ArrayList<>( );
        if ( identity != null )
        {
            for ( final AttributeDto attribute : identity.getAttributes( ) )
            {
                if ( StringUtils.isNotBlank( attribute.getValue( ) ) )
                {
                    final Pattern validationPattern = _cache.get( attribute.getKey( ) );
                    if ( validationPattern != null )
                    {
                        if ( !validationPattern.matcher( attribute.getValue( ) ).matches( ) )
                        {
                            attrStatusList.add( this.buildAttributeValueValidationErrorStatus( attribute.getKey( ) ) );
                        }
                    }
                }
            }
        }
        if ( !attrStatusList.isEmpty( ) )
        {
            response.setStatus( ResponseStatusFactory.failure( ).setAttributeStatuses( attrStatusList )
                    .setMessage( "Some attribute values are not passing validation. Please check in the attribute statuses for details." )
                    .setMessageKey( Constants.PROPERTY_REST_ERROR_FAIL_ATTRIBUTE_VALIDATION ) );
        }
    }

    /**
     * Builds an attribute status for invalid value.
     *
     * @param attrStrKey
     *            the attribute key
     * @return the status
     */
    private AttributeStatus buildAttributeValueValidationErrorStatus( final String attrStrKey ) throws IdentityAttributeNotFoundException
    {
        final AttributeKey attributeKey = IdentityAttributeService.instance( ).getAttributeKey( attrStrKey );
        final AttributeStatus attributeStatus = new AttributeStatus( );
        attributeStatus.setKey( attrStrKey );
        attributeStatus.setStatus( AttributeChangeStatus.INVALID_VALUE );
        attributeStatus.setMessage( attributeKey.getValidationErrorMessage( ) );
        attributeStatus.setMessageKey( attributeKey.getValidationErrorMessageKey( ) );

        return attributeStatus;
    }

    /**
     * Validates the integrity of pivot attributes stored in the provided identity.<br/>
     * If a pivot attribute is present and certified with a process with a greater or equal level to the defined process in configuration, then ALL the pivot
     * attributes need to be present and certified with the same certification process.<br/>
     * If the birthcountry code attribute is set and doesn't correspond to FRANCE, then the birthplace code attribute is allowed to be missing.<br/>
     * If the above rules are not fullfiled, the response status is put to FAILURE.
     *
     * @param cuid
     *            the cuid
     * @param identityRequest
     *            the identity request
     * @param response
     *            the response
     */
    public void validatePivotAttributesIntegrity( final IdentityDto existingIdentityDto, final String clientCode, final IdentityDto identity,
            boolean geocodesCheck, final ChangeResponse response ) throws IdentityStoreException
    {

        // get pivot attributes
        final List<String> pivotKeys = IdentityAttributeService.instance( ).getPivotAttributeKeys( ).stream( ).map( AttributeKey::getKeyName )
                .collect( Collectors.toList( ) );

        // get attributes to update, and add certification levels
        final Map<String, AttributeDto> pivotUpdatedAttrs = identity.getAttributes( ).stream( ).filter( a -> pivotKeys.contains( a.getKey( ) ) )
                .peek( a -> a.setCertificationLevel( AttributeCertificationDefinitionService.instance( ).getLevelAsInteger( a.getCertifier( ), a.getKey( ) ) ) )
                .collect( Collectors.toMap( AttributeDto::getKey, Function.identity( ) ) );

        // If the request does not contains at least one pivot attribute, we skip this validation rule
        if ( pivotUpdatedAttrs.isEmpty( ) )
        {
            return;
        }

        // in case of update, we get the existing attributes for the check
        final Map<String, AttributeDto> pivotExistingAttrs = new HashMap<>( );
        if ( existingIdentityDto != null )
        {
            pivotExistingAttrs.putAll( existingIdentityDto.getAttributes( ).stream( ).filter( a -> pivotKeys.contains( a.getKey( ) ) )
                    .collect( Collectors.toMap( AttributeDto::getKey, Function.identity( ) ) ) );
        }

        // *** Geocode checks ***

        // get birth date for Geocode checks
        final List<AttributeStatus> geocodeStatuses = new ArrayList<>( );
        Date birthdate = null;
        AttributeDto birthdateAttr = pivotUpdatedAttrs.get( Constants.PARAM_BIRTH_DATE );
        if ( birthdateAttr == null && existingIdentityDto != null )
        {
            birthdateAttr = pivotExistingAttrs.get( Constants.PARAM_BIRTH_DATE );
        }

        try
        {
            if ( birthdateAttr != null )
            {
                birthdate = DateUtils.parseDate( birthdateAttr.getValue( ), "dd/MM/yyyy" );
            }
        }
        catch( final ParseException e )
        {
            final AttributeStatus birthplaceCodeStatus = new AttributeStatus( );
            birthplaceCodeStatus.setKey( Constants.PARAM_BIRTH_DATE );
            birthplaceCodeStatus.setStatus( AttributeChangeStatus.INVALID_VALUE );
            birthplaceCodeStatus.setMessageKey( Constants.PROPERTY_ATTRIBUTE_STATUS_NOT_UPDATED );
            geocodeStatuses.add( birthplaceCodeStatus );
        }

        if ( birthdate == null )
        {
            pivotUpdatedAttrs.remove( Constants.PARAM_BIRTH_DATE );

            // birthdate is mandatory for birthplace and birthcountry codes
            // we won't consider those values either (if present)
            pivotUpdatedAttrs.remove( Constants.PARAM_BIRTH_PLACE_CODE );
            pivotUpdatedAttrs.remove( Constants.PARAM_BIRTH_COUNTRY_CODE );

        }

        // Birthplace codes checks
        // If invalid, we consider them as missing
        if ( geocodesCheck && pivotUpdatedAttrs.containsKey( Constants.PARAM_BIRTH_PLACE_CODE ) && birthdate != null )
        {
            final AttributeDto birthPlaceCodeAttr = pivotUpdatedAttrs.get( Constants.PARAM_BIRTH_PLACE_CODE );
            if ( StringUtils.isNotBlank( birthPlaceCodeAttr.getValue( ) ) )
            {
                final Optional<City> city = GeoCodesService.getInstance( ).getCityByDateAndCode( birthdate, birthPlaceCodeAttr.getValue( ) );
                if ( city == null || !city.isPresent( ) )
                {
                    pivotUpdatedAttrs.remove( Constants.PARAM_BIRTH_PLACE_CODE );
                    final AttributeStatus birthplaceCodeStatus = new AttributeStatus( );
                    birthplaceCodeStatus.setKey( Constants.PARAM_BIRTH_PLACE_CODE );
                    birthplaceCodeStatus.setStatus( AttributeChangeStatus.UNKNOWN_GEOCODES_CODE );
                    birthplaceCodeStatus.setMessageKey( Constants.PROPERTY_ATTRIBUTE_STATUS_VALIDATION_ERROR_UNKNOWN_GEOCODES_CODE );
                    geocodeStatuses.add( birthplaceCodeStatus );
                }
            }
        }

        if ( geocodesCheck && pivotUpdatedAttrs.containsKey( Constants.PARAM_BIRTH_COUNTRY_CODE ) && birthdate != null )
        {
            final AttributeDto birthcountryCodeAttr = pivotUpdatedAttrs.get( Constants.PARAM_BIRTH_COUNTRY_CODE );
            if ( StringUtils.isNotBlank( birthcountryCodeAttr.getValue( ) ) )
            {
                // TODO : use GeoCodesService.getInstance( ).getCountryByDateAndCode() when available ...
                final Optional<Country> country = GeoCodesService.getInstance( ).getCountryByCode( birthcountryCodeAttr.getValue( ) );
                if ( country == null || !country.isPresent( ) )
                {
                    pivotUpdatedAttrs.remove( Constants.PARAM_BIRTH_COUNTRY_CODE );
                    final AttributeStatus birthcountryCodeStatus = new AttributeStatus( );
                    birthcountryCodeStatus.setKey( Constants.PARAM_BIRTH_COUNTRY_CODE );
                    birthcountryCodeStatus.setStatus( AttributeChangeStatus.UNKNOWN_GEOCODES_CODE );
                    birthcountryCodeStatus.setMessageKey( Constants.PROPERTY_ATTRIBUTE_STATUS_VALIDATION_ERROR_UNKNOWN_GEOCODES_CODE );
                    geocodeStatuses.add( birthcountryCodeStatus );
                }
            }
        }

        // ** consolidate targeted list of pivot attributes with new or updated valid attributes, and existing attributes **

        final Map<String, AttributeDto> pivotTargetAttrs = new HashMap<>( );
        pivotTargetAttrs.putAll( pivotUpdatedAttrs );

        // in case of update, we include the existing attributes for the check
        if ( existingIdentityDto != null )
        {
            // Get the targeted pivot attributes from updated pivot attributes
            // and pivot attributes that exist and are not present in request.
            // If there is an existing attribute with a higher certification level, we ignore the request attribute
            pivotTargetAttrs.putAll( pivotExistingAttrs.keySet( ).stream( )
                    .filter( a -> ( !pivotUpdatedAttrs.containsKey( a )
                            || pivotUpdatedAttrs.get( a ).getCertificationLevel( ) < pivotExistingAttrs.get( a ).getCertificationLevel( ) ) )
                    .collect( Collectors.toMap( Function.identity( ), pivotExistingAttrs::get ) ) );
        }

        // if the birth country is not the main Geocode country, the birth place code is ignored
        if ( pivotTargetAttrs.containsKey( Constants.PARAM_BIRTH_COUNTRY_CODE )
                && !pivotTargetAttrs.get( Constants.PARAM_BIRTH_COUNTRY_CODE ).getValue( ).equals( Constants.GEOCODE_MAIN_COUNTRY_CODE ) )
        {
            pivotKeys.remove( Constants.PARAM_BIRTH_PLACE_CODE );
            pivotUpdatedAttrs.remove( Constants.PARAM_BIRTH_PLACE_CODE );
            pivotTargetAttrs.remove( Constants.PARAM_BIRTH_PLACE_CODE );
        }

        // ** Main checks on target Attributes **

        // get highest certification level for pivot attributes (as reference)
        final AttributeDto highestCertifiedPivot = pivotTargetAttrs.values( ).stream( ).max( Comparator.comparing( AttributeDto::getCertificationLevel ) )
                .orElse( null );

        // if the highest certification level is below the treshold, we wont carry out the check
        if ( highestCertifiedPivot.getCertificationLevel( ) < pivotCertificationLevelThreshold )
        {
            return;
        }

        // check that we have the 6 pivot attributes (or 5 if birth country is not the geocode main country)
        if ( !( ( pivotTargetAttrs.size( ) == pivotKeys.size( ) ) || ( pivotTargetAttrs.size( ) >= pivotKeys.size( )
                && !Constants.GEOCODE_MAIN_COUNTRY_CODE.equals( pivotTargetAttrs.get( Constants.PARAM_BIRTH_COUNTRY_CODE ).getValue( ) ) ) ) )
        {
            response.setStatus( ResponseStatusFactory.failure( ).setMessageKey( Constants.PROPERTY_REST_ERROR_IDENTITY_ALL_PIVOT_ATTRIBUTE_SAME_CERTIFICATION )
                    .setMessage( "Above level " + pivotCertificationLevelThreshold
                            + ", all pivot attributes must be present and have the same certification level." ) );
            response.getStatus( ).getAttributeStatuses( ).addAll( geocodeStatuses );
            return;
        }

        // we check that each pivot attribute has the same certification level
        if ( pivotTargetAttrs.values( ).stream( ).anyMatch( a -> !a.getCertifier( ).equals( highestCertifiedPivot.getCertifier( ) ) ) )
        {
            response.setStatus( ResponseStatusFactory.failure( ).setMessageKey( Constants.PROPERTY_REST_ERROR_IDENTITY_ALL_PIVOT_ATTRIBUTE_SAME_CERTIFICATION )
                    .setMessage( "All pivot attributes must be set and certified with the '" + highestCertifiedPivot.getCertifier( ) + "' certifier" ) );
            response.getStatus( ).getAttributeStatuses( ).addAll( geocodeStatuses );
            return;
        }

        // Check of empty values
        // (deletion of pivot attributes is not allowed above the pivotCertificationLevelThreshold)

        if ( pivotTargetAttrs.values( ).stream( ).anyMatch( a -> StringUtils.isBlank( a.getValue( ) ) ) )
        {
            response.setStatus( ResponseStatusFactory.failure( ).setMessageKey( Constants.PROPERTY_REST_ERROR_IDENTITY_FORBIDDEN_PIVOT_ATTRIBUTE_DELETION )
                    .setMessage( "Deleting pivot attribute is forbidden for this identity." ) );
            response.getStatus( ).getAttributeStatuses( ).addAll( geocodeStatuses );
        }

    }

}