Repository navigation
Expand file tree
/
Copy pathPermissionBatchUtils.java
More file actions
2878 lines (2728 loc) · 126 KB
/
Copy pathPermissionBatchUtils.java
File metadata and controls
2878 lines (2728 loc) · 126 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/*******************************************************************************
* Copyright 2015 Defense Health Agency (DHA)
*
* If your use of this software does not include any GPLv2 components:
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* ----------------------------------------------------------------------------
* If your use of this software includes any GPLv2 components:
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*******************************************************************************/
package prerna.auth.utils;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.sql.Types;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.javatuples.Pair;
import com.google.gson.Gson;
import com.google.gson.JsonParseException;
import prerna.auth.AccessPermissionEnum;
import prerna.auth.AuthProvider;
import prerna.auth.User;
import prerna.auth.access.ModelUsageLimit;
import prerna.auth.access.ModelUsageLimitKey;
import prerna.auth.access.ModelUsageLimitRule;
import prerna.auth.access.PermissionBatch;
import prerna.auth.access.PermissionBatchException;
import prerna.auth.access.PermissionChange;
import prerna.auth.access.PermissionErrorRules;
import prerna.auth.access.PermissionGrant;
import prerna.auth.access.PermissionProblem;
import prerna.auth.access.PermissionSubject;
import prerna.auth.access.PermissionTargetResource;
import prerna.date.SemossDate;
import prerna.engine.api.IEngine;
import prerna.engine.api.IRDBMSEngine;
import prerna.engine.api.IRawSelectWrapper;
import prerna.engine.impl.model.usage.ModelUsageCost;
import prerna.io.connector.ms.MicrosoftGraphUserLookup;
import prerna.notifications.NotificationDbUtils;
import prerna.query.querystruct.SelectQueryStruct;
import prerna.query.querystruct.filters.SimpleQueryFilter;
import prerna.query.querystruct.selectors.QueryColumnSelector;
import prerna.rdf.engine.wrappers.WrapperManager;
import prerna.util.NotificationConstants;
import prerna.util.QueryExecutionUtility;
import prerna.util.SystemEngineRegistry;
import prerna.util.Utility;
import prerna.util.ValueUtils;
import prerna.util.gson.GsonUtility;
/**
* Validates and saves permission batches: changes to who can use a project, an
* engine or an insight, or to who is in or manages a custom group. Every
* permission change goes through here, and nothing outside this package can
* call it: Bulk Access Management through
* {@link SecurityAccessPermissionBatchUtils}, and the single and bulk
* permission methods, such as {@code SecurityProjectUtils.addProjectUser},
* through
* {@link #saveChanges(User, PermissionTargetResource, List, PermissionBatchOptions, PermissionErrorRules)}.
*
* <p>
* A save goes through these steps:
* </p>
* <ol>
* <li>{@code validateBatch} validates each {@link PermissionBatch}: this is
* where it is decided what can be saved. It makes a
* {@link PermissionBatchValidation} with a {@link PermissionChangeValidation}
* for each change, reads the user's {@link PermissionAllowedChanges} on the
* target once, reads each change, and validates it against the permissions and
* the current rows. A change, or the whole batch, that cannot be made is
* blocked with a reason.</li>
* <li>{@link #save} stops with a {@link PermissionBatchException} holding every
* {@link PermissionProblem} when anything is blocked, so nothing is saved.
* {@link #saveChanges} turns those problems into the method's own exception
* with its {@link PermissionErrorRules}.</li>
* <li>People picked from the Microsoft directory get an account, and the
* batches are validated again.</li>
* <li>{@code writeChange} writes every change of every batch in one
* transaction, from the values its validation holds.</li>
* <li>Notifications go out after the save.</li>
* </ol>
*
* <p>
* Every change follows the same rules: editors change people's access, only
* owners give or change Author access, only owners change which groups have
* access, and only a group's managers change its members and its managers.
* These are the user's own permissions, even when the user is an admin. Admin
* mode, where every change the batch's shape and the current rows allow is
* allowed, is on only in {@link PermissionBatchOptions}, which only callers
* that hold the admin utilities set.
* </p>
*
* <p>
* A change sets the whole access: its level, its end date, and on a model its
* usage limit. Leaving the end date or the limit out clears it, unless the
* options keep the limit ({@link PermissionBatchOptions#keepingModelLimits()}).
* The limits of people's and teams' grants, and of a model's public use and
* total, are written through {@link SecurityModelUsageLimitUtils} in the
* batch's own transaction, so a grant and its limit are saved together or not
* at all, and removing a grant removes its limit.
* </p>
*
* <p>
* Only a model's owners, and admin mode, set usage limits: a person's Direct
* Limit, a team's limit, the Public Use Limit and the Model Total. Editors add
* people with no limit and move them between Read-Only and Editor with the
* limit kept as it is ({@link #validateGrantUsageLimit}).
* </p>
*/
final class PermissionBatchUtils extends AbstractSecurityUtils {
private static final Logger classLogger = LogManager.getLogger(PermissionBatchUtils.class);
private static final String INSERT_PROJECT_USER = "INSERT INTO PROJECTPERMISSION (USERID, PROJECTID, PERMISSION, "
+ "PERMISSIONGRANTEDBY, PERMISSIONGRANTEDBYTYPE, DATEADDED, ENDDATE) VALUES (?,?,?,?,?,?,?)";
private static final String UPDATE_PROJECT_USER = "UPDATE PROJECTPERMISSION SET PERMISSION=?, "
+ "PERMISSIONGRANTEDBY=?, PERMISSIONGRANTEDBYTYPE=?, DATEADDED=?, ENDDATE=? WHERE USERID=? AND PROJECTID=?";
private static final String DELETE_PROJECT_USER = "DELETE FROM PROJECTPERMISSION WHERE USERID=? AND PROJECTID=?";
private static final String DELETE_PROJECT_USER_INSIGHTS = "DELETE FROM USERINSIGHTPERMISSION WHERE USERID=? "
+ "AND PROJECTID=?";
private static final String INSERT_ENGINE_USER = "INSERT INTO ENGINEPERMISSION (USERID, ENGINEID, PERMISSION, "
+ "PERMISSIONGRANTEDBY, PERMISSIONGRANTEDBYTYPE, DATEADDED, ENDDATE) VALUES (?,?,?,?,?,?,?)";
private static final String UPDATE_ENGINE_USER = "UPDATE ENGINEPERMISSION SET PERMISSION=?, PERMISSIONGRANTEDBY=?, "
+ "PERMISSIONGRANTEDBYTYPE=?, DATEADDED=?, ENDDATE=? WHERE USERID=? AND ENGINEID=?";
private static final String DELETE_ENGINE_USER = "DELETE FROM ENGINEPERMISSION WHERE USERID=? AND ENGINEID=?";
private static final String INSERT_INSIGHT_USER = "INSERT INTO USERINSIGHTPERMISSION (USERID, PROJECTID, INSIGHTID, "
+ "PERMISSION, PERMISSIONGRANTEDBY, PERMISSIONGRANTEDBYTYPE, DATEADDED, ENDDATE) VALUES (?,?,?,?,?,?,?,?)";
private static final String UPDATE_INSIGHT_USER = "UPDATE USERINSIGHTPERMISSION SET PERMISSION=?, "
+ "PERMISSIONGRANTEDBY=?, PERMISSIONGRANTEDBYTYPE=?, DATEADDED=?, ENDDATE=? WHERE USERID=? AND PROJECTID=? "
+ "AND INSIGHTID=?";
private static final String DELETE_INSIGHT_USER = "DELETE FROM USERINSIGHTPERMISSION WHERE USERID=? AND PROJECTID=? "
+ "AND INSIGHTID=?";
// a statement on access requests reads or marks only the target's own, so a
// request of another project, engine or insight is left as it is
private static final String PROJECT_REQUEST_TARGET = " AND PROJECTID = ?";
private static final String ENGINE_REQUEST_TARGET = " AND ENGINEID = ?";
private static final String INSIGHT_REQUEST_TARGET = " AND PROJECTID = ? AND INSIGHTID = ?";
private static final String APPROVE_REQUEST_COLUMNS = " SET PERMISSION = ?, APPROVER_USERID = ?, APPROVER_TYPE = ?, "
+ "APPROVER_DECISION = ?, APPROVER_TIMESTAMP = ? WHERE ID = ?";
private static final String APPROVE_PROJECT_REQUEST = "UPDATE PROJECTACCESSREQUEST" + APPROVE_REQUEST_COLUMNS
+ PROJECT_REQUEST_TARGET;
private static final String APPROVE_ENGINE_REQUEST = "UPDATE ENGINEACCESSREQUEST" + APPROVE_REQUEST_COLUMNS
+ ENGINE_REQUEST_TARGET;
private static final String APPROVE_INSIGHT_REQUEST = "UPDATE INSIGHTACCESSREQUEST" + APPROVE_REQUEST_COLUMNS
+ INSIGHT_REQUEST_TARGET;
private static final String DENY_REQUEST_COLUMNS = " SET APPROVER_USERID = ?, APPROVER_TYPE = ?, "
+ "APPROVER_DECISION = ?, APPROVER_TIMESTAMP = ? WHERE ID = ?";
private static final String DENY_PROJECT_REQUEST = "UPDATE PROJECTACCESSREQUEST" + DENY_REQUEST_COLUMNS
+ PROJECT_REQUEST_TARGET;
private static final String DENY_ENGINE_REQUEST = "UPDATE ENGINEACCESSREQUEST" + DENY_REQUEST_COLUMNS
+ ENGINE_REQUEST_TARGET;
private static final String DENY_INSIGHT_REQUEST = "UPDATE INSIGHTACCESSREQUEST" + DENY_REQUEST_COLUMNS
+ INSIGHT_REQUEST_TARGET;
private static final String REQUESTER_COLUMNS = "SELECT ID, REQUEST_USERID, REQUEST_TYPE, PERMISSION FROM ";
private static final String PROJECT_REQUESTER = REQUESTER_COLUMNS + "PROJECTACCESSREQUEST WHERE ID = ?"
+ PROJECT_REQUEST_TARGET;
private static final String ENGINE_REQUESTER = REQUESTER_COLUMNS + "ENGINEACCESSREQUEST WHERE ID = ?"
+ ENGINE_REQUEST_TARGET;
private static final String INSIGHT_REQUESTER = REQUESTER_COLUMNS + "INSIGHTACCESSREQUEST WHERE ID = ?"
+ INSIGHT_REQUEST_TARGET;
private static final String INSERT_PROJECT_GROUP = "INSERT INTO GROUPPROJECTPERMISSION (ID, TYPE, PROJECTID, "
+ "PERMISSION, DATEADDED, ENDDATE, PERMISSIONGRANTEDBY, PERMISSIONGRANTEDBYTYPE) VALUES (?,?,?,?,?,?,?,?)";
private static final String UPDATE_PROJECT_GROUP = "UPDATE GROUPPROJECTPERMISSION SET PERMISSION=?, DATEADDED=?, "
+ "ENDDATE=?, PERMISSIONGRANTEDBY=?, PERMISSIONGRANTEDBYTYPE=? WHERE ID=? AND TYPE=? AND PROJECTID=?";
private static final String DELETE_PROJECT_GROUP = "DELETE FROM GROUPPROJECTPERMISSION WHERE ID=? AND TYPE=? "
+ "AND PROJECTID=?";
private static final String INSERT_ENGINE_GROUP = "INSERT INTO GROUPENGINEPERMISSION (ID, TYPE, ENGINEID, "
+ "PERMISSION, DATEADDED, ENDDATE, PERMISSIONGRANTEDBY, PERMISSIONGRANTEDBYTYPE) VALUES (?,?,?,?,?,?,?,?)";
/**
* A team's grant on an engine keeps the date it was first given, which orders
* the teams a person's model calls are billed to; a grant with no date gets the
* date of the change
*/
private static final String UPDATE_ENGINE_GROUP = "UPDATE GROUPENGINEPERMISSION SET PERMISSION=?, "
+ "DATEADDED=COALESCE(DATEADDED, ?), ENDDATE=?, PERMISSIONGRANTEDBY=?, PERMISSIONGRANTEDBYTYPE=? "
+ "WHERE ID=? AND TYPE=? AND ENGINEID=?";
private static final String DELETE_ENGINE_GROUP = "DELETE FROM GROUPENGINEPERMISSION WHERE ID=? AND TYPE=? "
+ "AND ENGINEID=?";
private static final String INSERT_INSIGHT_GROUP = "INSERT INTO GROUPINSIGHTPERMISSION (ID, TYPE, PROJECTID, "
+ "INSIGHTID, PERMISSION, DATEADDED, ENDDATE, PERMISSIONGRANTEDBY, PERMISSIONGRANTEDBYTYPE) "
+ "VALUES (?,?,?,?,?,?,?,?,?)";
private static final String UPDATE_INSIGHT_GROUP = "UPDATE GROUPINSIGHTPERMISSION SET PERMISSION=?, DATEADDED=?, "
+ "ENDDATE=?, PERMISSIONGRANTEDBY=?, PERMISSIONGRANTEDBYTYPE=? WHERE ID=? AND TYPE=? AND PROJECTID=? "
+ "AND INSIGHTID=?";
private static final String DELETE_INSIGHT_GROUP = "DELETE FROM GROUPINSIGHTPERMISSION WHERE ID=? AND TYPE=? "
+ "AND PROJECTID=? AND INSIGHTID=?";
private static final String INSERT_TEAM_MEMBER = "INSERT INTO CUSTOMGROUPASSIGNMENT (GROUPID, USERID, TYPE, "
+ "DATEADDED, ENDDATE, PERMISSIONGRANTEDBY, PERMISSIONGRANTEDBYTYPE) VALUES (?,?,?,?,?,?,?)";
private static final String DELETE_TEAM_MEMBER = "DELETE FROM CUSTOMGROUPASSIGNMENT WHERE GROUPID=? AND USERID=? "
+ "AND TYPE=?";
private static final String DELETE_TEAM_MANAGER = "DELETE FROM GROUPMANAGERS WHERE GROUPID=? AND USERID=? AND TYPE=?";
/** The key of a usage limit in the rows of the bulk engine methods */
private static final String USAGE_LIMIT_KEY = "usageLimit";
/** Every access level, by name, the highest first */
private static final List<String> ALL_LEVELS = List.of(AccessPermissionEnum.OWNER.getPermission(),
AccessPermissionEnum.EDIT.getPermission(), AccessPermissionEnum.READ_ONLY.getPermission());
/** Why someone who is not an owner of a model cannot set a usage limit on it */
static final String OWNERS_SET_LIMITS = "Only owners can set usage limits";
/**
* Why someone who is not an owner of a model cannot end or shorten the access
* of a person with a usage limit on it
*/
static final String OWNERS_END_LIMITED_ACCESS = "Only owners can end or shorten the access of someone "
+ "with a usage limit";
/**
* Why a public use limit or a total cannot be set on something that is not a
* model
*/
private static final String MODEL_LIMITS_ONLY_ON_MODELS = "Only a model has a public use limit and a total";
private PermissionBatchUtils() {
}
/*
* Checking and saving batches
*/
/**
* Validates batches without saving anything.
*
* @return for each batch, its target and each change's index, whether it is
* allowed, and why not when it is not
*/
static List<Map<String, Object>> validate(User user, List<PermissionBatch> batches,
PermissionBatchOptions options) {
requireBatches(batches);
List<Map<String, Object>> results = new ArrayList<>();
for (PermissionBatchValidation validation : validateBatches(user, batches, options)) {
results.add(validation.toResult());
}
return results;
}
/**
* Checks and saves the access changes one of the single or bulk permission
* methods makes, such as {@code addProjectUser}. The options keep what the
* method does, such as who it notifies, and the errors keep the exceptions and
* messages it throws.
*
* @param user the user making the changes
* @param batches the changes, grouped by what they change
* @param options how the changes are checked and saved
* @param errors the exceptions the method throws for the problems found
* @return how many targets and changes were saved
* @throws IllegalAccessException when the errors say so, or when a person
* picked from the Microsoft directory cannot be
* looked up
*/
static Map<String, Object> saveChanges(User user, List<PermissionBatch> batches, PermissionBatchOptions options,
PermissionErrorRules errors) throws IllegalAccessException {
try {
return save(user, batches, options);
} catch (PermissionBatchException e) {
throw exceptionFor(errors, e.getProblems(), e);
} catch (IllegalAccessException | RuntimeException e) {
throw e;
} catch (Exception e) {
classLogger.error("Could not save the access changes", e);
PermissionProblem problem = new PermissionProblem(PermissionProblem.ErrorCode.WRITE_FAILED,
"The access changes could not be saved", batches.get(0).getTarget(), null);
throw exceptionFor(errors, List.of(problem), new IllegalArgumentException(problem.getMessage(), e));
}
}
/**
* {@link #saveChanges(User, List, PermissionBatchOptions, PermissionErrorRules)}
* for one target.
*/
static Map<String, Object> saveChanges(User user, PermissionTargetResource target, List<PermissionChange> changes,
PermissionBatchOptions options, PermissionErrorRules errors) throws IllegalAccessException {
return saveChanges(user, List.of(new PermissionBatch(target, changes)), options, errors);
}
/**
* {@link #saveChanges(User, List, PermissionBatchOptions, PermissionErrorRules)}
* for the methods that do not declare {@link IllegalAccessException}: it is
* thrown as an {@link IllegalArgumentException} with the same message.
*/
static Map<String, Object> saveChangesNoCheckedException(User user, List<PermissionBatch> batches,
PermissionBatchOptions options, PermissionErrorRules errors) {
try {
return saveChanges(user, batches, options, errors);
} catch (IllegalAccessException e) {
throw new IllegalArgumentException(e.getMessage(), e);
}
}
/**
* {@link #saveChangesNoCheckedException(User, List, PermissionBatchOptions, PermissionErrorRules)}
* for one target.
*/
static Map<String, Object> saveChangesNoCheckedException(User user, PermissionTargetResource target,
List<PermissionChange> changes, PermissionBatchOptions options, PermissionErrorRules errors) {
return saveChangesNoCheckedException(user, List.of(new PermissionBatch(target, changes)), options, errors);
}
/**
* Turns the problems a save found into the exception the method's errors give
* for them. An {@link IllegalAccessException} is returned for the caller to
* throw; an unchecked one is thrown here, and so is the fallback when the
* errors give neither.
*
* @param fallback the batch's own exception, thrown when the errors give none
* @return the {@link IllegalAccessException} the errors give for the problems,
* for the caller to throw; an unchecked one is thrown here
*/
private static IllegalAccessException exceptionFor(PermissionErrorRules errors, List<PermissionProblem> problems,
RuntimeException fallback) {
Exception exception = errors == null ? null : errors.toException(problems);
if (exception instanceof IllegalAccessException illegalAccess) {
return illegalAccess;
}
if (exception instanceof RuntimeException runtime) {
throw runtime;
}
throw fallback;
}
/**
* Validates batches and saves every change of every batch in one transaction:
* either all of them are saved or none is. It runs six steps, each marked in
* the code:
* <ol>
* <li>Can every change be made ({@link #validateBatches})? If not, nothing is
* saved and every problem is reported.</li>
* <li>People picked from the Microsoft directory who have no account get
* one.</li>
* <li>If any did, the batches are checked again.</li>
* <li>Who the rows say gave the access, and when.</li>
* <li>Every change is saved in one transaction ({@link #writeChange}).</li>
* <li>People are notified, and the counts are returned.</li>
* </ol>
*
* @return how many targets and changes were saved
* @throws PermissionBatchException when any change cannot be made, with every
* problem
* @throws IllegalAccessException when a person picked from the Microsoft
* directory cannot be looked up
* @throws Exception when the save fails, after rolling it back
*/
static Map<String, Object> save(User user, List<PermissionBatch> batches, PermissionBatchOptions options)
throws Exception {
// Step 1: can every change be made? Every batch is validated against the rows
// as they are now. If any change is refused, nothing is saved, and the
// caller gets every problem at once so they can fix them all.
requireBatches(batches);
List<PermissionBatchValidation> validations = validateBatches(user, batches, options);
List<PermissionProblem> problems = new ArrayList<>();
for (PermissionBatchValidation validation : validations) {
problems.addAll(validation.problems());
}
if (!problems.isEmpty()) {
throw new PermissionBatchException(describeProblems(problems), problems);
}
// Step 2: people picked from the Microsoft directory who have no account get
// one, since a row can only name an account. This is the one change made
// before the save, and only once step 1 allowed every change. The accounts
// stay even if the save does not happen.
boolean addedAccounts = false;
for (PermissionBatchValidation validation : validations) {
for (PermissionChangeValidation change : validation.getChanges()) {
if (change.needsDirectoryAccount()) {
MicrosoftGraphUserLookup.addMissingUser(user, change.getSubjectId());
addedAccounts = true;
}
}
}
// Step 3: if any account was added, the batches are checked again, since step
// 1 checked them before those accounts existed and someone else may have
// changed the access meanwhile. A person who still has no account is
// refused, so no row is saved for them.
if (addedAccounts) {
validations = validateBatches(user, batches, options);
for (PermissionBatchValidation validation : validations) {
problems.addAll(validation.problems());
for (PermissionChangeValidation change : validation.getChanges()) {
if (change.needsDirectoryAccount()) {
problems.add(new PermissionProblem(PermissionProblem.ErrorCode.SUBJECT_MISSING,
change.getChange().getSubject().describe() + " does not exist", validation.getTarget(),
change.getIndex(), change.getSubjectId()));
}
}
}
if (!problems.isEmpty()) {
throw new PermissionBatchException(describeProblems(problems), problems);
}
}
// the validations of step 1, or of step 3 when it ran, in a variable the save
// can use
List<PermissionBatchValidation> toSave = validations;
// Step 4: who the rows say gave the access, and when. That is the grantor the
// options name, such as the admin approving a request, or else the user. An
// admin method that only takes access away may have no user to name.
Pair<String, String> grantor = options.getGrantor() != null ? options.getGrantor()
: user == null ? new Pair<>(null, null) : User.getPrimaryUserIdAndTypePair(user);
Timestamp now = Utility.getCurrentSqlTimestampUTC();
// Step 5: save. Every change of every batch, and the access requests the
// batches approve, are written in one transaction, so either all of them are
// saved or none is. Only connection based helpers run inside, so nothing
// commits on its own.
IRDBMSEngine securityDb = SystemEngineRegistry.getSecurityDb();
QueryExecutionUtility.write(securityDb, conn -> {
for (PermissionBatchValidation validation : toSave) {
for (PermissionChangeValidation change : validation.getChanges()) {
writeChange(conn, validation.getTarget(), change, grantor, now, options);
}
}
if (options.getApprovedRequests() != null) {
markApproved(conn, options.getApprovedTarget(), options.getApprovedRequests(), grantor, now);
}
return null;
});
// a grant or a team membership decides which usage limit applies to a person,
// so the limits cached on this server are read again. This runs after the
// commit, not inside it, or another thread could read the rows before the
// commit and cache them under the new version.
SecurityModelUsageLimitUtils.markChanged();
PermissionBatchAudit.recordChanges(user, toSave, options);
// Step 6: after the save, the people given access to a project or an engine,
// or whose access to one changed, are notified as the options say, so nobody
// hears of access that was not saved. Then the save is logged and its counts
// returned.
int changeCount = 0;
for (PermissionBatchValidation validation : toSave) {
changeCount += validation.getChanges().size();
sendNotifications(user, validation, options);
}
classLogger.info("User {} applied a permission batch with {} changes to {} targets",
User.getSingleLogginName(user), changeCount, toSave.size());
Map<String, Object> result = new LinkedHashMap<>();
result.put("targets", toSave.size());
result.put("changes", changeCount);
return result;
}
/**
* Joins the problems found into the message of the
* {@link PermissionBatchException} a save throws.
*
* @return the problems as one message: "Some changes cannot be made: " and the
* first five, each with where it is
*/
private static String describeProblems(List<PermissionProblem> problems) {
List<String> lines = new ArrayList<>();
for (PermissionProblem problem : problems) {
String where = problem.getTarget() == null ? "the batch" : problem.getTarget().describe();
lines.add(where + ": " + problem.getMessage());
}
int shown = Math.min(lines.size(), 5);
String more = lines.size() > shown ? " (and " + (lines.size() - shown) + " more)" : "";
return "Some changes cannot be made: " + String.join("; ", lines.subList(0, shown)) + more;
}
/**
* Checks that there is at least one batch to validate or save, and that none of
* them is null.
*
* @throws IllegalArgumentException when there are no batches, or one is empty
*/
static void requireBatches(List<PermissionBatch> batches) {
if (batches == null || batches.isEmpty()) {
throw new IllegalArgumentException("Must define at least one permission batch");
}
for (PermissionBatch batch : batches) {
if (batch == null) {
throw new IllegalArgumentException("A permission batch is empty");
}
}
}
/**
* Reads the access one row of the bulk engine methods gives: its level, its end
* date and, when the row has the key, its usage limit.
*
* @param row a row of the bulk engine methods: {@code permission},
* {@code endDate}, and on a model {@code usageLimit} (a map or JSON
* text). A {@code usageLimit} key, null included, is the grant's
* limit ({@link PermissionGrant#withUsageLimit}). A row without the
* key does not give its limit, so an add gives none and a change
* with {@link PermissionBatchOptions#keepingModelLimits()} keeps the
* current one.
* @return the access the row gives
* @throws IllegalArgumentException when {@code usageLimit} is not a limit
*/
static PermissionGrant engineGrant(Map<String, ?> row) {
PermissionGrant grant = new PermissionGrant(ValueUtils.toStringOrNull(row.get("permission")),
ValueUtils.toStringOrNull(row.get("endDate")));
// the key, not its value, says whether the limit is given: a key with null
// removes an edited row's limit, while a row without the key gives none
if (row.containsKey(USAGE_LIMIT_KEY)) {
grant.withUsageLimit(readUsageLimit(row.get(USAGE_LIMIT_KEY)));
}
return grant;
}
/**
* Reads a usage limit from any of the forms callers send it in.
*
* @param value a usage limit as a caller sends it: a {@link ModelUsageLimit}, a
* map read from JSON, JSON text
* ({@link SecurityModelUsageLimitUtils#readJson}), or null
* @return the limit, not yet normalized, or null for no limit defined
* @throws IllegalArgumentException when the value is not a limit
*/
static ModelUsageLimit readUsageLimit(Object value) {
if (value == null) {
return null;
}
if (value instanceof ModelUsageLimit limit) {
return limit;
}
if (value instanceof String json) {
return SecurityModelUsageLimitUtils.readJson(json);
}
if (value instanceof Map<?, ?> map) {
Gson gson = GsonUtility.getDefaultGson();
try {
return gson.fromJson(gson.toJsonTree(map), ModelUsageLimit.class);
} catch (JsonParseException e) {
throw new IllegalArgumentException("The usage limit is not valid: " + e.getMessage());
}
}
throw new IllegalArgumentException("The usage limit is not valid: send it as a JSON object");
}
/**
* Makes a change with the given action for each person in the rows, all with
* the same end date.
*
* @param rows the people, each with {@code userid}, {@code permission} and,
* when known, {@code type}
* @param endDate when the access ends, the same for every row, or null
* @return a change for each row
*/
static List<PermissionChange> peopleChanges(PermissionChange.Action action, List<? extends Map<String, ?>> rows,
String endDate) {
List<PermissionChange> changes = new ArrayList<>();
for (Map<String, ?> row : rows) {
PermissionSubject person = PermissionSubject.ofUser(ValueUtils.toStringOrNull(row.get("userid")),
ValueUtils.toStringOrNull(row.get("type")));
changes.add(new PermissionChange(action, person,
new PermissionGrant(ValueUtils.toStringOrNull(row.get("permission")), endDate)));
}
return changes;
}
/**
* Makes a change with the given action for each person in the rows of the bulk
* engine methods, each with the access its own row gives.
*
* @param rows the people on an engine, each with {@code userid} and the access
* {@link #engineGrant(Map)} reads, and optionally {@code type}
* @return a change for each row
*/
static List<PermissionChange> engineChanges(PermissionChange.Action action, List<? extends Map<String, ?>> rows) {
List<PermissionChange> changes = new ArrayList<>();
for (Map<String, ?> row : rows) {
PermissionSubject person = PermissionSubject.ofUser(ValueUtils.toStringOrNull(row.get("userid")),
ValueUtils.toStringOrNull(row.get("type")));
changes.add(new PermissionChange(action, person, engineGrant(row)));
}
return changes;
}
/**
* The changes that approve access requests: each person gets the level they
* asked for, by a change when they have access already and an add when they do
* not. {@link PermissionBatchOptions#approving} marks the requests approved in
* the same save.
*
* @param target the project, engine or insight
* @param requests the requests, each with {@code userid} and {@code permission}
* @param endDate when the access ends, or null
* @return the changes
*/
static List<PermissionChange> approvalChanges(PermissionTargetResource target,
List<? extends Map<String, ?>> requests, String endDate) {
List<String> userIds = new ArrayList<>();
for (Map<String, ?> request : requests) {
userIds.add(ValueUtils.toStringOrNull(request.get("userid")));
}
Map<String, Integer> current = readExistingLevels(target, userIds);
List<PermissionChange> changes = new ArrayList<>();
for (Map<String, ?> request : requests) {
String userId = ValueUtils.toStringOrNull(request.get("userid"));
PermissionChange.Action action = current.get(userId) != null ? PermissionChange.Action.CHANGE
: PermissionChange.Action.ADD;
changes.add(new PermissionChange(action, PermissionSubject.ofUser(userId, null),
new PermissionGrant(ValueUtils.toStringOrNull(request.get("permission")), endDate)));
}
return changes;
}
/**
* Marks access requests approved, each with the level it asked for. Only the
* target's own requests are marked; a request of another project, engine or
* insight is left as it is.
*
* @param approver who the rows say gave the access: an id and a login type
*/
private static void markApproved(Connection conn, PermissionTargetResource target,
List<? extends Map<String, ?>> requests, Pair<String, String> approver, Timestamp now) throws Exception {
String sql = requestStatement(target, APPROVE_PROJECT_REQUEST, APPROVE_ENGINE_REQUEST, APPROVE_INSIGHT_REQUEST);
QueryExecutionUtility.executeBatch(conn, sql, requests, (ps, request) -> {
int index = 1;
ps.setInt(index++,
AccessPermissionEnum.getIdByPermission(ValueUtils.toStringOrNull(request.get("permission"))));
ps.setString(index++, approver.getValue0());
ps.setString(index++, approver.getValue1());
ps.setString(index++, "APPROVED");
ps.setTimestamp(index++, now);
ps.setString(index++, ValueUtils.toStringOrNull(request.get("requestid")));
bindRequestTarget(ps, index, target);
});
}
/**
* Picks the statement on access requests for the target's type.
*
* @return the project's, the engine's or the insight's statement
*/
private static String requestStatement(PermissionTargetResource target, String project, String engine,
String insight) {
switch (target.getResourceType()) {
case ENGINE:
return engine;
case INSIGHT:
return insight;
default:
return project;
}
}
/**
* Binds the target that keeps a statement on access requests to the target's
* own: the engine id, the project id, or the project id and the insight id.
*
* @param index the first parameter the target binds
*/
private static void bindRequestTarget(PreparedStatement ps, int index, PermissionTargetResource target)
throws SQLException {
if (target.getResourceType() == PermissionTargetResource.TargetResourceType.INSIGHT) {
ps.setString(index++, target.getProjectId());
}
ps.setString(index, target.getId());
}
/*
* Denying access requests
*/
/**
* Denies access requests to a project, an engine or an insight: each request is
* marked denied, by the user, and its requester is read back, in one
* transaction. Only the target's own requests are marked; a request of another
* project, engine or insight is left as it is and is not returned. Who can use
* the target does not change, so there is no batch to validate, but the user's
* permissions are read as they are for a batch
* ({@link #readPermissionAllowedChanges}): outside admin mode, only the
* target's owners can deny, by their current level, given to them directly or
* through a team. A user who is not an editor is blocked as
* {@code NO_PERMISSION}, and an editor who is not an owner as
* {@code OWNERS_ONLY}.
*
* <p>
* Nothing is sent from here: the caller tells the requesters after this
* returns, once the denials are committed, from the rows it returns.
* </p>
*
* @param user the user denying; in admin mode, null for a method that
* names who denied them with
* {@link PermissionBatchOptions#grantedBy}
* @param target the project, engine or insight
* @param requestIds the requests' ids
* @param options {@link PermissionBatchOptions#userMode()} or
* {@link PermissionBatchOptions#adminMode()}
* @param errors the exceptions the method throws for the problems found
* @return the requests denied, in the order of their ids, each with
* {@code requestId}, the requester's {@code userId} and login
* {@code type}, and the {@code permission} it asked for, as a level id
* or null
* @throws IllegalAccessException when the errors say so
*/
static List<Map<String, Object>> denyRequests(User user, PermissionTargetResource target, List<String> requestIds,
PermissionBatchOptions options, PermissionErrorRules errors) throws IllegalAccessException {
String missingField = findMissingTargetField(target);
if (missingField != null) {
throw new IllegalArgumentException(missingField);
}
PermissionAllowedChanges allowed = readPermissionAllowedChanges(user, target, options.isAdminMode());
PermissionProblem refused = null;
if (!allowed.canChangePeople()) {
refused = new PermissionProblem(allowed.getPeopleCode(), allowed.getPeopleReason(), target, null);
} else if (!allowed.isOwner()) {
refused = new PermissionProblem(PermissionProblem.ErrorCode.OWNERS_ONLY,
"Only this " + nounFor(target) + "'s owners can deny access requests", target, null);
}
if (refused != null) {
throw exceptionFor(errors, List.of(refused),
new PermissionBatchException(refused.getMessage(), List.of(refused)));
}
Pair<String, String> denier = options.getGrantor() != null ? options.getGrantor()
: User.getPrimaryUserIdAndTypePair(user);
String deny = requestStatement(target, DENY_PROJECT_REQUEST, DENY_ENGINE_REQUEST, DENY_INSIGHT_REQUEST);
String requester = requestStatement(target, PROJECT_REQUESTER, ENGINE_REQUESTER, INSIGHT_REQUESTER);
Timestamp now = Utility.getCurrentSqlTimestampUTC();
List<Map<String, Object>> denied;
try {
// the denials and the requesters the caller tells afterwards are one
// transaction, so only requests that were denied are returned
denied = QueryExecutionUtility.write(SystemEngineRegistry.getSecurityDb(), conn -> {
QueryExecutionUtility.executeBatch(conn, deny, requestIds, (ps, requestId) -> {
int index = 1;
ps.setString(index++, denier.getValue0());
ps.setString(index++, denier.getValue1());
ps.setString(index++, "DENIED");
ps.setTimestamp(index++, now);
ps.setString(index++, requestId);
bindRequestTarget(ps, index, target);
});
List<Map<String, Object>> requests = new ArrayList<>();
for (String requestId : requestIds) {
QueryExecutionUtility.queryList(conn, requester, ps -> {
ps.setString(1, requestId);
bindRequestTarget(ps, 2, target);
}, PermissionBatchUtils::readRequester, requests);
}
return requests;
});
} catch (Exception e) {
classLogger.error("Could not deny the access requests to {}", target.describe(), e);
PermissionProblem failed = new PermissionProblem(PermissionProblem.ErrorCode.WRITE_FAILED,
"The access requests could not be denied", target, null);
throw exceptionFor(errors, List.of(failed), new IllegalArgumentException(failed.getMessage(), e));
}
List<String> deniedIds = new ArrayList<>();
for (Map<String, Object> request : denied) {
deniedIds.add((String) request.get("requestId"));
}
PermissionBatchAudit.recordDenials(user, target, deniedIds, options);
classLogger.info("User {} denied {} access requests to {}", User.getSingleLogginName(user), deniedIds.size(),
target.describe());
return denied;
}
/**
* Reads the requester of the access request at the result's current row.
*
* @return the request's {@code requestId}, the requester's {@code userId} and
* login {@code type}, and the {@code permission} it asked for, as a
* level id or null
*/
private static Map<String, Object> readRequester(ResultSet rs) throws SQLException {
Map<String, Object> request = new HashMap<>();
request.put("requestId", rs.getString(1));
request.put("userId", rs.getString(2));
request.put("type", rs.getString(3));
int permission = rs.getInt(4);
request.put("permission", rs.wasNull() ? null : permission);
return request;
}
/**
* {@link #denyRequests} for the methods that do not declare
* {@link IllegalAccessException}: it is thrown as an
* {@link IllegalArgumentException} with the same message.
*
* @return the requests denied, as {@link #denyRequests} returns them
*/
static List<Map<String, Object>> denyRequestsNoCheckedException(User user, PermissionTargetResource target,
List<String> requestIds, PermissionBatchOptions options, PermissionErrorRules errors) {
try {
return denyRequests(user, target, requestIds, options, errors);
} catch (IllegalAccessException e) {
throw new IllegalArgumentException(e.getMessage(), e);
}
}
/**
* The table that holds the access given directly to people on the target, which
* is read for their current levels.
*
* @return the table of the people given access to a project, an engine or an
* insight directly, or null for a team's members or managers
*/
private static String permissionTable(PermissionTargetResource target) {
switch (target.getResourceType()) {
case PROJECT:
return "PROJECTPERMISSION";
case ENGINE:
return "ENGINEPERMISSION";
case INSIGHT:
return "USERINSIGHTPERMISSION";
default:
return null;
}
}
/*
* Validating batches: deciding, for each change, whether it can be made
*/
/**
* Validates every batch. A target can be in only one batch, so all the changes
* to it are validated together.
*
* @return the validation of each batch, in order
*/
private static List<PermissionBatchValidation> validateBatches(User user, List<PermissionBatch> batches,
PermissionBatchOptions options) {
List<PermissionBatchValidation> validations = new ArrayList<>();
Set<String> targets = new HashSet<>();
for (PermissionBatch batch : batches) {
PermissionBatchValidation validation = validateBatch(user, batch, options);
if (!validation.isBlocked() && !targets.add(targetKey(validation.getTarget()))) {
validation.block(PermissionProblem.ErrorCode.DUPLICATE,
"This " + nounFor(validation.getTarget()) + " is in more than one batch");
}
validations.add(validation);
}
return validations;
}
/**
* Validates one batch: decides, for each of its changes, whether it can be
* saved and, if not, why. Nothing is saved here. It runs six steps, each marked
* in the code, and the first three can end the validation early:
* <ol>
* <li>Is the target fully defined ({@link #findMissingTargetField})?</li>
* <li>In admin mode, does the target exist ({@link #targetExists})?</li>
* <li>What can the user change on the target
* ({@link #readPermissionAllowedChanges})? Someone who can change nothing is
* refused on every change ({@link #blockForNoPermission}).</li>
* <li>What does each change ask for: who, which level, and until when
* ({@link #readChangeFields})? A subject in the batch twice is refused.</li>
* <li>What access does everyone in the batch have now
* ({@link #readExistingLevels})? On an engine, is it a model, and which usage
* limits are defined on it ({@link EngineUsageLimits})?</li>
* <li>Can each change be made, given the user's permissions and that access?
* Each type of change has its own rules: {@link #validatePersonChange},
* {@link #validateTeamChange}, {@link #validateModelLimitChange},
* {@link #validateMemberChange} and {@link #validateManagerChange}.</li>
* </ol>
*
* @return the batch's validation: why the whole batch is blocked, or the result
* of each change
*/
private static PermissionBatchValidation validateBatch(User user, PermissionBatch batch,
PermissionBatchOptions options) {
PermissionTargetResource target = batch.getTarget();
PermissionBatchValidation validation = new PermissionBatchValidation(batch);
// Step 1: is the target fully defined? A project, engine or team needs its
// id; an insight needs its id and its project's id. This reads only the
// request.
String missingField = findMissingTargetField(target);
if (missingField != null) {
validation.block(PermissionProblem.ErrorCode.NOT_WELL_FORMED, missingField);
return validation;
}
// Step 2: in admin mode, does the target exist? Admin mode gives every
// permission
// without reading the target, so this is where a missing project, engine or
// insight is caught; a team is read with its permissions in step 3. Outside
// admin
// mode a missing target simply gives no permission in step 3, so someone
// without
// access cannot tell a missing project or team from one they are not allowed
// to change, and learns nothing about the people in the batch.
if (options.isAdminMode() && options.checksTargetExists() && !targetExists(target)) {
validation.block(PermissionProblem.ErrorCode.INVALID_TARGET, notFoundMessage(target));
return validation;
}
// Step 3: what can the user change on the target? Their permissions come from
// their current level there, given to them directly or through a team, and
// are read once for the whole batch. Someone who can change nothing is
// refused on every change before the changes are even read, unless all they
// do is leave.
PermissionAllowedChanges allowed;
try {
allowed = readPermissionAllowedChanges(user, target, options.isAdminMode());
} catch (IllegalArgumentException e) {
// a target whose permissions cannot be read
validation.block(PermissionProblem.ErrorCode.INVALID_TARGET, e.getMessage());
return validation;
}
if (!allowed.canChangePeople() && !allowed.canChangeTeams() && !isOnlyLeaving(user, validation, options)) {
blockForNoPermission(target, allowed, validation);
return validation;
}
// Step 4: what does each change ask for? Its subject, the level it gives and
// its end date are read from the request, and a change missing one of them
// is refused. A subject that is in the batch twice is refused too. The people
// in the batch are collected for step 5.
Set<String> seen = new HashSet<>();
List<String> userIds = new ArrayList<>();
for (PermissionChangeValidation change : validation.getChanges()) {
readChangeFields(target, change, options);
if (change.isBlocked()) {
continue;
}
if (!seen.add(subjectKey(target, change))) {
change.block(PermissionProblem.ErrorCode.DUPLICATE,
change.getChange().getSubject().describe() + " is in the batch more than once");
continue;
}
if (change.getChange().getSubject().getCategory() == PermissionSubject.SubjectCategory.USER) {
userIds.add(change.getSubjectId());
}
}
// Step 5: what access do the people in the batch have now? One query for all
// of them, so step 6 knows whether each change adds, changes or removes a
// row that is really there. On an engine, whether it is a model and the
// usage limits defined on it now are read once too, so step 6 knows which
// limit each change leaves and whether it changes.
Map<String, Integer> existingLevels = readExistingLevels(target, userIds);
EngineUsageLimits limits = target.getResourceType() == PermissionTargetResource.TargetResourceType.ENGINE
? EngineUsageLimits.read(target.getId())
: null;
// Step 6: can each change be made, given the user's permissions from step 3 and
// the access from step 5? Each type of change has its own rules: a team's
// members, a team's managers, a model's public use or total, a team's access
// to the target, or a person's access to the target.
for (PermissionChangeValidation change : validation.getChanges()) {
if (change.isBlocked()) {
continue;
}
PermissionSubject subject = change.getChange().getSubject();
if (target.getResourceType() == PermissionTargetResource.TargetResourceType.TEAM) {
validateMemberChange(target, allowed, change);
} else if (target.getResourceType() == PermissionTargetResource.TargetResourceType.TEAM_MANAGERS) {
validateManagerChange(target, allowed, change);
} else if (isModelLimitSubject(subject)) {
validateModelLimitChange(target, allowed, change, limits);
} else if (subject.getCategory() == PermissionSubject.SubjectCategory.TEAM) {
validateTeamChange(target, allowed, change, options, limits);
} else {
validatePersonChange(user, target, allowed, change, existingLevels, options, limits);
}
}
return validation;
}