View Javadoc
1   /*
2    * Copyright (c) 2002-2024, City of Paris
3    * All rights reserved.
4    *
5    * Redistribution and use in source and binary forms, with or without
6    * modification, are permitted provided that the following conditions
7    * are met:
8    *
9    *  1. Redistributions of source code must retain the above copyright notice
10   *     and the following disclaimer.
11   *
12   *  2. Redistributions in binary form must reproduce the above copyright notice
13   *     and the following disclaimer in the documentation and/or other materials
14   *     provided with the distribution.
15   *
16   *  3. Neither the name of 'Mairie de Paris' nor 'Lutece' nor the names of its
17   *     contributors may be used to endorse or promote products derived from
18   *     this software without specific prior written permission.
19   *
20   * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21   * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22   * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
23   * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
24   * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
25   * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
26   * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
27   * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
28   * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
29   * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
30   * POSSIBILITY OF SUCH DAMAGE.
31   *
32   * License 1.0
33   */
34  package fr.paris.lutece.plugins.identitystore.service.attribute;
35  
36  import java.text.ParseException;
37  import java.util.ArrayList;
38  import java.util.Comparator;
39  import java.util.Date;
40  import java.util.HashMap;
41  import java.util.List;
42  import java.util.Map;
43  import java.util.Optional;
44  import java.util.function.Function;
45  import java.util.regex.Pattern;
46  import java.util.stream.Collectors;
47  
48  import org.apache.commons.lang3.StringUtils;
49  import org.apache.commons.lang3.time.DateUtils;
50  
51  import fr.paris.lutece.plugins.geocodes.business.City;
52  import fr.paris.lutece.plugins.geocodes.business.Country;
53  import fr.paris.lutece.plugins.geocodes.service.GeoCodesService;
54  import fr.paris.lutece.plugins.identitystore.business.attribute.AttributeKey;
55  import fr.paris.lutece.plugins.identitystore.cache.IdentityAttributeValidationCache;
56  import fr.paris.lutece.plugins.identitystore.service.contract.AttributeCertificationDefinitionService;
57  import fr.paris.lutece.plugins.identitystore.service.identity.IdentityAttributeNotFoundException;
58  import fr.paris.lutece.plugins.identitystore.v3.web.rs.dto.common.AttributeChangeStatus;
59  import fr.paris.lutece.plugins.identitystore.v3.web.rs.dto.common.AttributeDto;
60  import fr.paris.lutece.plugins.identitystore.v3.web.rs.dto.common.AttributeStatus;
61  import fr.paris.lutece.plugins.identitystore.v3.web.rs.dto.common.ChangeResponse;
62  import fr.paris.lutece.plugins.identitystore.v3.web.rs.dto.common.IdentityDto;
63  import fr.paris.lutece.plugins.identitystore.v3.web.rs.util.Constants;
64  import fr.paris.lutece.plugins.identitystore.v3.web.rs.util.ResponseStatusFactory;
65  import fr.paris.lutece.plugins.identitystore.web.exception.IdentityStoreException;
66  import fr.paris.lutece.portal.service.spring.SpringContextService;
67  import fr.paris.lutece.portal.service.util.AppPropertiesService;
68  
69  /**
70   * Service class used to validate attribute values in requests
71   */
72  public class IdentityAttributeValidationService
73  {
74  
75      private final IdentityAttributeValidationCache _cache = SpringContextService.getBean( "identitystore.identityAttributeValidationCache" );
76      private final int pivotCertificationLevelThreshold = AppPropertiesService
77              .getPropertyInt( "identitystore.identity.attribute.pivot.certification.level.threshold", 400 );
78      private static IdentityAttributeValidationService _instance;
79  
80      public static IdentityAttributeValidationService instance( )
81      {
82          if ( _instance == null )
83          {
84              _instance = new IdentityAttributeValidationService( );
85              _instance._cache.refresh( );
86          }
87          return _instance;
88      }
89  
90      public boolean validateAttribute( final String key, final String value) throws IdentityAttributeNotFoundException {
91          return _cache.get( key ).matcher( value ).matches( );
92      }
93  
94      /**
95       * Validates all attribute values stored in the provided identity, according to each attribute validation regex. Adds validation error statuses in the
96       * response in case of invalid values, and put the status to FAILURE.
97       *
98       * @param identity
99       *            the identity
100      * @param response
101      *            the response
102      */
103     public void validateIdentityAttributeValues( final IdentityDto identity, final ChangeResponse response ) throws IdentityAttributeNotFoundException
104     {
105         final List<AttributeStatus> attrStatusList = new ArrayList<>( );
106         if ( identity != null )
107         {
108             for ( final AttributeDto attribute : identity.getAttributes( ) )
109             {
110                 if ( StringUtils.isNotBlank( attribute.getValue( ) ) )
111                 {
112                     final Pattern validationPattern = _cache.get( attribute.getKey( ) );
113                     if ( validationPattern != null )
114                     {
115                         if ( !validationPattern.matcher( attribute.getValue( ) ).matches( ) )
116                         {
117                             attrStatusList.add( this.buildAttributeValueValidationErrorStatus( attribute.getKey( ) ) );
118                         }
119                     }
120                 }
121             }
122         }
123         if ( !attrStatusList.isEmpty( ) )
124         {
125             response.setStatus( ResponseStatusFactory.failure( ).setAttributeStatuses( attrStatusList )
126                     .setMessage( "Some attribute values are not passing validation. Please check in the attribute statuses for details." )
127                     .setMessageKey( Constants.PROPERTY_REST_ERROR_FAIL_ATTRIBUTE_VALIDATION ) );
128         }
129     }
130 
131     /**
132      * Builds an attribute status for invalid value.
133      *
134      * @param attrStrKey
135      *            the attribute key
136      * @return the status
137      */
138     private AttributeStatus buildAttributeValueValidationErrorStatus( final String attrStrKey ) throws IdentityAttributeNotFoundException
139     {
140         final AttributeKey attributeKey = IdentityAttributeService.instance( ).getAttributeKey( attrStrKey );
141         final AttributeStatus attributeStatus = new AttributeStatus( );
142         attributeStatus.setKey( attrStrKey );
143         attributeStatus.setStatus( AttributeChangeStatus.INVALID_VALUE );
144         attributeStatus.setMessage( attributeKey.getValidationErrorMessage( ) );
145         attributeStatus.setMessageKey( attributeKey.getValidationErrorMessageKey( ) );
146 
147         return attributeStatus;
148     }
149 
150     /**
151      * Validates the integrity of pivot attributes stored in the provided identity.<br/>
152      * 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
153      * attributes need to be present and certified with the same certification process.<br/>
154      * If the birthcountry code attribute is set and doesn't correspond to FRANCE, then the birthplace code attribute is allowed to be missing.<br/>
155      * If the above rules are not fullfiled, the response status is put to FAILURE.
156      *
157      * @param cuid
158      *            the cuid
159      * @param identityRequest
160      *            the identity request
161      * @param response
162      *            the response
163      */
164     public void validatePivotAttributesIntegrity( final IdentityDto existingIdentityDto, final String clientCode, final IdentityDto identity,
165             boolean geocodesCheck, final ChangeResponse response ) throws IdentityStoreException
166     {
167 
168         // get pivot attributes
169         final List<String> pivotKeys = IdentityAttributeService.instance( ).getPivotAttributeKeys( ).stream( ).map( AttributeKey::getKeyName )
170                 .collect( Collectors.toList( ) );
171 
172         // get attributes to update, and add certification levels
173         final Map<String, AttributeDto> pivotUpdatedAttrs = identity.getAttributes( ).stream( ).filter( a -> pivotKeys.contains( a.getKey( ) ) )
174                 .peek( a -> a.setCertificationLevel( AttributeCertificationDefinitionService.instance( ).getLevelAsInteger( a.getCertifier( ), a.getKey( ) ) ) )
175                 .collect( Collectors.toMap( AttributeDto::getKey, Function.identity( ) ) );
176 
177         // If the request does not contains at least one pivot attribute, we skip this validation rule
178         if ( pivotUpdatedAttrs.isEmpty( ) )
179         {
180             return;
181         }
182 
183         // in case of update, we get the existing attributes for the check
184         final Map<String, AttributeDto> pivotExistingAttrs = new HashMap<>( );
185         if ( existingIdentityDto != null )
186         {
187             pivotExistingAttrs.putAll( existingIdentityDto.getAttributes( ).stream( ).filter( a -> pivotKeys.contains( a.getKey( ) ) )
188                     .collect( Collectors.toMap( AttributeDto::getKey, Function.identity( ) ) ) );
189         }
190 
191         // *** Geocode checks ***
192 
193         // get birth date for Geocode checks
194         final List<AttributeStatus> geocodeStatuses = new ArrayList<>( );
195         Date birthdate = null;
196         AttributeDto birthdateAttr = pivotUpdatedAttrs.get( Constants.PARAM_BIRTH_DATE );
197         if ( birthdateAttr == null && existingIdentityDto != null )
198         {
199             birthdateAttr = pivotExistingAttrs.get( Constants.PARAM_BIRTH_DATE );
200         }
201 
202         try
203         {
204             if ( birthdateAttr != null )
205             {
206                 birthdate = DateUtils.parseDate( birthdateAttr.getValue( ), "dd/MM/yyyy" );
207             }
208         }
209         catch( final ParseException e )
210         {
211             final AttributeStatus birthplaceCodeStatus = new AttributeStatus( );
212             birthplaceCodeStatus.setKey( Constants.PARAM_BIRTH_DATE );
213             birthplaceCodeStatus.setStatus( AttributeChangeStatus.INVALID_VALUE );
214             birthplaceCodeStatus.setMessageKey( Constants.PROPERTY_ATTRIBUTE_STATUS_NOT_UPDATED );
215             geocodeStatuses.add( birthplaceCodeStatus );
216         }
217 
218         if ( birthdate == null )
219         {
220             pivotUpdatedAttrs.remove( Constants.PARAM_BIRTH_DATE );
221 
222             // birthdate is mandatory for birthplace and birthcountry codes
223             // we won't consider those values either (if present)
224             pivotUpdatedAttrs.remove( Constants.PARAM_BIRTH_PLACE_CODE );
225             pivotUpdatedAttrs.remove( Constants.PARAM_BIRTH_COUNTRY_CODE );
226 
227         }
228 
229         // Birthplace codes checks
230         // If invalid, we consider them as missing
231         if ( geocodesCheck && pivotUpdatedAttrs.containsKey( Constants.PARAM_BIRTH_PLACE_CODE ) && birthdate != null )
232         {
233             final AttributeDto birthPlaceCodeAttr = pivotUpdatedAttrs.get( Constants.PARAM_BIRTH_PLACE_CODE );
234             if ( StringUtils.isNotBlank( birthPlaceCodeAttr.getValue( ) ) )
235             {
236                 final Optional<City> city = GeoCodesService.getInstance( ).getCityByDateAndCode( birthdate, birthPlaceCodeAttr.getValue( ) );
237                 if ( city == null || !city.isPresent( ) )
238                 {
239                     pivotUpdatedAttrs.remove( Constants.PARAM_BIRTH_PLACE_CODE );
240                     final AttributeStatus birthplaceCodeStatus = new AttributeStatus( );
241                     birthplaceCodeStatus.setKey( Constants.PARAM_BIRTH_PLACE_CODE );
242                     birthplaceCodeStatus.setStatus( AttributeChangeStatus.UNKNOWN_GEOCODES_CODE );
243                     birthplaceCodeStatus.setMessageKey( Constants.PROPERTY_ATTRIBUTE_STATUS_VALIDATION_ERROR_UNKNOWN_GEOCODES_CODE );
244                     geocodeStatuses.add( birthplaceCodeStatus );
245                 }
246             }
247         }
248 
249         if ( geocodesCheck && pivotUpdatedAttrs.containsKey( Constants.PARAM_BIRTH_COUNTRY_CODE ) && birthdate != null )
250         {
251             final AttributeDto birthcountryCodeAttr = pivotUpdatedAttrs.get( Constants.PARAM_BIRTH_COUNTRY_CODE );
252             if ( StringUtils.isNotBlank( birthcountryCodeAttr.getValue( ) ) )
253             {
254                 // TODO : use GeoCodesService.getInstance( ).getCountryByDateAndCode() when available ...
255                 final Optional<Country> country = GeoCodesService.getInstance( ).getCountryByCode( birthcountryCodeAttr.getValue( ) );
256                 if ( country == null || !country.isPresent( ) )
257                 {
258                     pivotUpdatedAttrs.remove( Constants.PARAM_BIRTH_COUNTRY_CODE );
259                     final AttributeStatus birthcountryCodeStatus = new AttributeStatus( );
260                     birthcountryCodeStatus.setKey( Constants.PARAM_BIRTH_COUNTRY_CODE );
261                     birthcountryCodeStatus.setStatus( AttributeChangeStatus.UNKNOWN_GEOCODES_CODE );
262                     birthcountryCodeStatus.setMessageKey( Constants.PROPERTY_ATTRIBUTE_STATUS_VALIDATION_ERROR_UNKNOWN_GEOCODES_CODE );
263                     geocodeStatuses.add( birthcountryCodeStatus );
264                 }
265             }
266         }
267 
268         // ** consolidate targeted list of pivot attributes with new or updated valid attributes, and existing attributes **
269 
270         final Map<String, AttributeDto> pivotTargetAttrs = new HashMap<>( );
271         pivotTargetAttrs.putAll( pivotUpdatedAttrs );
272 
273         // in case of update, we include the existing attributes for the check
274         if ( existingIdentityDto != null )
275         {
276             // Get the targeted pivot attributes from updated pivot attributes
277             // and pivot attributes that exist and are not present in request.
278             // If there is an existing attribute with a higher certification level, we ignore the request attribute
279             pivotTargetAttrs.putAll( pivotExistingAttrs.keySet( ).stream( )
280                     .filter( a -> ( !pivotUpdatedAttrs.containsKey( a )
281                             || pivotUpdatedAttrs.get( a ).getCertificationLevel( ) < pivotExistingAttrs.get( a ).getCertificationLevel( ) ) )
282                     .collect( Collectors.toMap( Function.identity( ), pivotExistingAttrs::get ) ) );
283         }
284 
285         // if the birth country is not the main Geocode country, the birth place code is ignored
286         if ( pivotTargetAttrs.containsKey( Constants.PARAM_BIRTH_COUNTRY_CODE )
287                 && !pivotTargetAttrs.get( Constants.PARAM_BIRTH_COUNTRY_CODE ).getValue( ).equals( Constants.GEOCODE_MAIN_COUNTRY_CODE ) )
288         {
289             pivotKeys.remove( Constants.PARAM_BIRTH_PLACE_CODE );
290             pivotUpdatedAttrs.remove( Constants.PARAM_BIRTH_PLACE_CODE );
291             pivotTargetAttrs.remove( Constants.PARAM_BIRTH_PLACE_CODE );
292         }
293 
294         // ** Main checks on target Attributes **
295 
296         // get highest certification level for pivot attributes (as reference)
297         final AttributeDto highestCertifiedPivot = pivotTargetAttrs.values( ).stream( ).max( Comparator.comparing( AttributeDto::getCertificationLevel ) )
298                 .orElse( null );
299 
300         // if the highest certification level is below the treshold, we wont carry out the check
301         if ( highestCertifiedPivot.getCertificationLevel( ) < pivotCertificationLevelThreshold )
302         {
303             return;
304         }
305 
306         // check that we have the 6 pivot attributes (or 5 if birth country is not the geocode main country)
307         if ( !( ( pivotTargetAttrs.size( ) == pivotKeys.size( ) ) || ( pivotTargetAttrs.size( ) >= pivotKeys.size( )
308                 && !Constants.GEOCODE_MAIN_COUNTRY_CODE.equals( pivotTargetAttrs.get( Constants.PARAM_BIRTH_COUNTRY_CODE ).getValue( ) ) ) ) )
309         {
310             response.setStatus( ResponseStatusFactory.failure( ).setMessageKey( Constants.PROPERTY_REST_ERROR_IDENTITY_ALL_PIVOT_ATTRIBUTE_SAME_CERTIFICATION )
311                     .setMessage( "Above level " + pivotCertificationLevelThreshold
312                             + ", all pivot attributes must be present and have the same certification level." ) );
313             response.getStatus( ).getAttributeStatuses( ).addAll( geocodeStatuses );
314             return;
315         }
316 
317         // we check that each pivot attribute has the same certification level
318         if ( pivotTargetAttrs.values( ).stream( ).anyMatch( a -> !a.getCertifier( ).equals( highestCertifiedPivot.getCertifier( ) ) ) )
319         {
320             response.setStatus( ResponseStatusFactory.failure( ).setMessageKey( Constants.PROPERTY_REST_ERROR_IDENTITY_ALL_PIVOT_ATTRIBUTE_SAME_CERTIFICATION )
321                     .setMessage( "All pivot attributes must be set and certified with the '" + highestCertifiedPivot.getCertifier( ) + "' certifier" ) );
322             response.getStatus( ).getAttributeStatuses( ).addAll( geocodeStatuses );
323             return;
324         }
325 
326         // Check of empty values
327         // (deletion of pivot attributes is not allowed above the pivotCertificationLevelThreshold)
328 
329         if ( pivotTargetAttrs.values( ).stream( ).anyMatch( a -> StringUtils.isBlank( a.getValue( ) ) ) )
330         {
331             response.setStatus( ResponseStatusFactory.failure( ).setMessageKey( Constants.PROPERTY_REST_ERROR_IDENTITY_FORBIDDEN_PIVOT_ATTRIBUTE_DELETION )
332                     .setMessage( "Deleting pivot attribute is forbidden for this identity." ) );
333             response.getStatus( ).getAttributeStatuses( ).addAll( geocodeStatuses );
334         }
335 
336     }
337 
338 }