diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/UpdateAction.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/UpdateAction.java index 6a92cf80..95d4badc 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/UpdateAction.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/UpdateAction.java @@ -68,6 +68,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.Association; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage; import com.kingsrook.qqq.backend.core.model.statusmessages.NotFoundStatusMessage; +import com.kingsrook.qqq.backend.core.model.statusmessages.QErrorMessage; import com.kingsrook.qqq.backend.core.model.statusmessages.QWarningMessage; import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleDispatcher; import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleInterface; @@ -393,7 +394,12 @@ public class UpdateAction QRecord oldRecord = lookedUpRecords.get(value); QFieldType fieldType = table.getField(lock.getFieldName()).getType(); Serializable lockValue = ValueUtils.getValueAsFieldType(fieldType, oldRecord.getValue(lock.getFieldName())); - ValidateRecordSecurityLockHelper.validateRecordSecurityValue(table, record, lock, lockValue, fieldType, ValidateRecordSecurityLockHelper.Action.UPDATE); + + List errors = ValidateRecordSecurityLockHelper.validateRecordSecurityValue(table, lock, lockValue, fieldType, ValidateRecordSecurityLockHelper.Action.UPDATE); + if(CollectionUtils.nullSafeHasContents(errors)) + { + errors.forEach(e -> record.addError(e)); + } } } } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelper.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelper.java index 50a1f730..aa16b4bd 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelper.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelper.java @@ -24,6 +24,7 @@ package com.kingsrook.qqq.backend.core.actions.tables.helpers; import java.io.Serializable; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -49,7 +50,9 @@ import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLockFilters; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.model.statusmessages.PermissionDeniedMessage; +import com.kingsrook.qqq.backend.core.model.statusmessages.QErrorMessage; import com.kingsrook.qqq.backend.core.utils.CollectionUtils; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; import com.kingsrook.qqq.backend.core.utils.ListingHash; import com.kingsrook.qqq.backend.core.utils.StringUtils; import com.kingsrook.qqq.backend.core.utils.ValueUtils; @@ -82,160 +85,273 @@ public class ValidateRecordSecurityLockHelper *******************************************************************************/ public static void validateSecurityFields(QTableMetaData table, List records, Action action) throws QException { - List locksToCheck = getRecordSecurityLocks(table, action); - if(CollectionUtils.nullSafeIsEmpty(locksToCheck)) + MultiRecordSecurityLock locksToCheck = getRecordSecurityLocks(table, action); + if(locksToCheck == null || CollectionUtils.nullSafeIsEmpty(locksToCheck.getLocks())) + { + return; + } + + ////////////////////////////////////////////////////////////////////////////////////////// + // we will be relying on primary keys being set in records - but (at least for inserts) // + // we might not have pkeys - so make them up (and clear them out at the end) // + ////////////////////////////////////////////////////////////////////////////////////////// + Map madeUpPrimaryKeys = makeUpPrimaryKeysIfNeeded(records, table); + + //////////////////////////////// + // actually check lock values // + //////////////////////////////// + Map errorRecords = new HashMap<>(); + evaluateRecordLocks(table, records, action, locksToCheck, errorRecords, new ArrayList<>()); + + ///////////////////////////////// + // propagate errors to records // + ///////////////////////////////// + for(RecordWithErrors recordWithErrors : errorRecords.values()) + { + recordWithErrors.propagateErrorsToRecord(locksToCheck); + } + + ///////////////////////////////// + // remove made-up primary keys // + ///////////////////////////////// + String primaryKeyField = table.getPrimaryKeyField(); + for(QRecord record : madeUpPrimaryKeys.values()) + { + record.setValue(primaryKeyField, null); + } + } + + + + /******************************************************************************* + ** For a list of `records` from a `table`, and a given `action`, evaluate a + ** `recordSecurityLock` (which may be a multi-lock) - populating the input map + ** of `errorRecords` - key'ed by primary key value (real or made up), with + ** error messages existing in a tree, with positions matching the multi-lock + ** tree that we're navigating, as tracked by `treePosition`. + ** + ** Recursively processes multi-locks (and the top-level call is always with a + ** multi-lock - as the table's recordLocks list converted to an AND-multi-lock). + ** + ** Of note - for the case of READ_WRITE locks, we're only evaluating the values + ** on the record, to see if they're allowed for us to store (because if we didn't + ** have the key, we wouldn't have been able to read the value (which is verified + ** outside of here, in UpdateAction/DeleteAction). + ** + ** BUT - WRITE locks - in their case, we read the record no matter what, and in + ** here we need to verify we have a key that allows us to WRITE the record. + *******************************************************************************/ + private static void evaluateRecordLocks(QTableMetaData table, List records, Action action, RecordSecurityLock recordSecurityLock, Map errorRecords, List treePosition) throws QException + { + if(recordSecurityLock instanceof MultiRecordSecurityLock multiRecordSecurityLock) + { + ///////////////////////////////////////////// + // for multi-locks, make recursive descent // + ///////////////////////////////////////////// + int i = 0; + for(RecordSecurityLock childLock : CollectionUtils.nonNullList(multiRecordSecurityLock.getLocks())) + { + treePosition.add(i); + evaluateRecordLocks(table, records, action, childLock, errorRecords, treePosition); + treePosition.remove(treePosition.size() - 1); + i++; + } + + return; + } + + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // if this lock has an all-access key, and the user has that key, then there can't be any errors here, so return early // + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + QSecurityKeyType securityKeyType = QContext.getQInstance().getSecurityKeyType(recordSecurityLock.getSecurityKeyType()); + if(StringUtils.hasContent(securityKeyType.getAllAccessKeyName()) && QContext.getQSession().hasSecurityKeyValue(securityKeyType.getAllAccessKeyName(), true, QFieldType.BOOLEAN)) { return; } //////////////////////////////// - // actually check lock values // + // proceed w/ non-multi locks // //////////////////////////////// - for(RecordSecurityLock recordSecurityLock : locksToCheck) + String primaryKeyField = table.getPrimaryKeyField(); + if(CollectionUtils.nullSafeIsEmpty(recordSecurityLock.getJoinNameChain())) { - if(CollectionUtils.nullSafeIsEmpty(recordSecurityLock.getJoinNameChain())) + ////////////////////////////////////////////////////////////////////////////////// + // handle the value being in the table we're inserting/updating (e.g., no join) // + ////////////////////////////////////////////////////////////////////////////////// + QFieldMetaData field = table.getField(recordSecurityLock.getFieldName()); + + for(QRecord record : records) { - ////////////////////////////////////////////////////////////////////////////////// - // handle the value being in the table we're inserting/updating (e.g., no join) // - ////////////////////////////////////////////////////////////////////////////////// - QFieldMetaData field = table.getField(recordSecurityLock.getFieldName()); - - for(QRecord record : records) + if(action.equals(Action.UPDATE) && !record.getValues().containsKey(field.getName()) && RecordSecurityLock.LockScope.READ_AND_WRITE.equals(recordSecurityLock.getLockScope())) { - if(action.equals(Action.UPDATE) && !record.getValues().containsKey(field.getName()) && RecordSecurityLock.LockScope.READ_AND_WRITE.equals(recordSecurityLock.getLockScope())) - { - ///////////////////////////////////////////////////////////////////////////////////////////////////////// - // if this is a read-write lock, then if we have the record, it means we were able to read the record. // - // So if we're not updating the security field, then no error can come from it! // - ///////////////////////////////////////////////////////////////////////////////////////////////////////// - continue; - } + ///////////////////////////////////////////////////////////////////////////////////////////////////////// + // if this is a read-write lock, then if we have the record, it means we were able to read the record. // + // So if we're not updating the security field, then no error can come from it! // + ///////////////////////////////////////////////////////////////////////////////////////////////////////// + continue; + } - Serializable recordSecurityValue = record.getValue(field.getName()); - validateRecordSecurityValue(table, record, recordSecurityLock, recordSecurityValue, field.getType(), action); + Serializable recordSecurityValue = record.getValue(field.getName()); + List recordErrors = validateRecordSecurityValue(table, recordSecurityLock, recordSecurityValue, field.getType(), action); + if(CollectionUtils.nullSafeHasContents(recordErrors)) + { + errorRecords.computeIfAbsent(record.getValue(primaryKeyField), (k) -> new RecordWithErrors(record)).addAll(recordErrors, treePosition); } } - else + } + else + { + //////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // else look for the joined record - if it isn't found, assume a fail - else validate security value if found // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////// + QJoinMetaData leftMostJoin = QContext.getQInstance().getJoin(recordSecurityLock.getJoinNameChain().get(0)); + QJoinMetaData rightMostJoin = QContext.getQInstance().getJoin(recordSecurityLock.getJoinNameChain().get(recordSecurityLock.getJoinNameChain().size() - 1)); + + //////////////////////////////// + // todo probably, but more... // + //////////////////////////////// + // if(leftMostJoin.getLeftTable().equals(table.getName())) + // { + // leftMostJoin = leftMostJoin.flip(); + // } + + QTableMetaData rightMostJoinTable = QContext.getQInstance().getTable(rightMostJoin.getRightTable()); + QTableMetaData leftMostJoinTable = QContext.getQInstance().getTable(leftMostJoin.getLeftTable()); + + for(List inputRecordPage : CollectionUtils.getPages(records, 500)) { - //////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // else look for the joined record - if it isn't found, assume a fail - else validate security value if found // - //////////////////////////////////////////////////////////////////////////////////////////////////////////////// - QJoinMetaData leftMostJoin = QContext.getQInstance().getJoin(recordSecurityLock.getJoinNameChain().get(0)); - QJoinMetaData rightMostJoin = QContext.getQInstance().getJoin(recordSecurityLock.getJoinNameChain().get(recordSecurityLock.getJoinNameChain().size() - 1)); - QTableMetaData rightMostJoinTable = QContext.getQInstance().getTable(rightMostJoin.getRightTable()); - QTableMetaData leftMostJoinTable = QContext.getQInstance().getTable(leftMostJoin.getLeftTable()); + //////////////////////////////////////////////////////////////////////////////////////////////// + // set up a query for joined records // + // query will be like (fkey1=? and fkey2=?) OR (fkey1=? and fkey2=?) OR (fkey1=? and fkey2=?) // + //////////////////////////////////////////////////////////////////////////////////////////////// + QueryInput queryInput = new QueryInput(); + queryInput.setTableName(leftMostJoin.getLeftTable()); + QQueryFilter filter = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR); + queryInput.setFilter(filter); - for(List inputRecordPage : CollectionUtils.getPages(records, 500)) + for(String joinName : recordSecurityLock.getJoinNameChain()) { - //////////////////////////////////////////////////////////////////////////////////////////////// - // set up a query for joined records // - // query will be like (fkey1=? and fkey2=?) OR (fkey1=? and fkey2=?) OR (fkey1=? and fkey2=?) // - //////////////////////////////////////////////////////////////////////////////////////////////// - QueryInput queryInput = new QueryInput(); - queryInput.setTableName(leftMostJoin.getLeftTable()); - QQueryFilter filter = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR); - queryInput.setFilter(filter); - - for(String joinName : recordSecurityLock.getJoinNameChain()) + /////////////////////////////////////// + // we don't need the right-most join // + /////////////////////////////////////// + if(!joinName.equals(rightMostJoin.getName())) { - /////////////////////////////////////// - // we don't need the right-most join // - /////////////////////////////////////// - if(!joinName.equals(rightMostJoin.getName())) + queryInput.withQueryJoin(new QueryJoin().withJoinMetaData(QContext.getQInstance().getJoin(joinName)).withSelect(true)); + } + } + + /////////////////////////////////////////////////////////////////////////////////////////////////// + // foreach input record (in this page), put it in a listing hash, with key = list of join-values // + // e.g., (17,47)=(QRecord1), (18,48)=(QRecord2,QRecord3) // + // also build up the query's sub-filters here (only adding them if they're unique). // + // e.g., 2 order-lines referencing the same orderId don't need to be added to the query twice // + /////////////////////////////////////////////////////////////////////////////////////////////////// + ListingHash, QRecord> inputRecordMapByJoinFields = new ListingHash<>(); + for(QRecord inputRecord : inputRecordPage) + { + List inputRecordJoinValues = new ArrayList<>(); + QQueryFilter subFilter = new QQueryFilter(); + + boolean updatingAnyLockJoinFields = false; + for(JoinOn joinOn : rightMostJoin.getJoinOns()) + { + QFieldType type = rightMostJoinTable.getField(joinOn.getRightField()).getType(); + Serializable inputRecordValue = ValueUtils.getValueAsFieldType(type, inputRecord.getValue(joinOn.getRightField())); + inputRecordJoinValues.add(inputRecordValue); + + // if we have a value in this field (and it's not the primary key), then it means we're updating part of the lock + if(inputRecordValue != null && !joinOn.getRightField().equals(table.getPrimaryKeyField())) { - queryInput.withQueryJoin(new QueryJoin().withJoinMetaData(QContext.getQInstance().getJoin(joinName)).withSelect(true)); + updatingAnyLockJoinFields = true; } + + subFilter.addCriteria(inputRecordValue == null + ? new QFilterCriteria(rightMostJoin.getLeftTable() + "." + joinOn.getLeftField(), QCriteriaOperator.IS_BLANK) + : new QFilterCriteria(rightMostJoin.getLeftTable() + "." + joinOn.getLeftField(), QCriteriaOperator.EQUALS, inputRecordValue)); } - /////////////////////////////////////////////////////////////////////////////////////////////////// - // foreach input record (in this page), put it in a listing hash, with key = list of join-values // - // e.g., (17,47)=(QRecord1), (18,48)=(QRecord2,QRecord3) // - // also build up the query's sub-filters here (only adding them if they're unique). // - // e.g., 2 order-lines referencing the same orderId don't need to be added to the query twice // - /////////////////////////////////////////////////////////////////////////////////////////////////// - ListingHash, QRecord> inputRecordMapByJoinFields = new ListingHash<>(); - for(QRecord inputRecord : inputRecordPage) + ////////////////////////////////// + // todo maybe, some version of? // + ////////////////////////////////// + // if(action.equals(Action.UPDATE) && !updatingAnyLockJoinFields && RecordSecurityLock.LockScope.READ_AND_WRITE.equals(recordSecurityLock.getLockScope())) + // { + // ///////////////////////////////////////////////////////////////////////////////////////////////////////// + // // if this is a read-write lock, then if we have the record, it means we were able to read the record. // + // // So if we're not updating the security field, then no error can come from it! // + // ///////////////////////////////////////////////////////////////////////////////////////////////////////// + // continue; + // } + + if(!inputRecordMapByJoinFields.containsKey(inputRecordJoinValues)) { - List inputRecordJoinValues = new ArrayList<>(); - QQueryFilter subFilter = new QQueryFilter(); - - for(JoinOn joinOn : rightMostJoin.getJoinOns()) - { - QFieldType type = rightMostJoinTable.getField(joinOn.getRightField()).getType(); - Serializable inputRecordValue = ValueUtils.getValueAsFieldType(type, inputRecord.getValue(joinOn.getRightField())); - inputRecordJoinValues.add(inputRecordValue); - - subFilter.addCriteria(inputRecordValue == null - ? new QFilterCriteria(rightMostJoin.getLeftTable() + "." + joinOn.getLeftField(), QCriteriaOperator.IS_BLANK) - : new QFilterCriteria(rightMostJoin.getLeftTable() + "." + joinOn.getLeftField(), QCriteriaOperator.EQUALS, inputRecordValue)); - } - - if(!inputRecordMapByJoinFields.containsKey(inputRecordJoinValues)) - { - //////////////////////////////////////////////////////////////////////////////// - // only add this sub-filter if it's for a list of keys we haven't seen before // - //////////////////////////////////////////////////////////////////////////////// - filter.addSubFilter(subFilter); - } - - inputRecordMapByJoinFields.add(inputRecordJoinValues, inputRecord); + //////////////////////////////////////////////////////////////////////////////// + // only add this sub-filter if it's for a list of keys we haven't seen before // + //////////////////////////////////////////////////////////////////////////////// + filter.addSubFilter(subFilter); } - ////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // execute the query for joined records - then put them in a map with keys corresponding to the join values // - // e.g., (17,47)=(JoinRecord), (18,48)=(JoinRecord) // - ////////////////////////////////////////////////////////////////////////////////////////////////////////////// - QueryOutput queryOutput = new QueryAction().execute(queryInput); - Map, QRecord> joinRecordMapByJoinFields = new HashMap<>(); - for(QRecord joinRecord : queryOutput.getRecords()) - { - List joinRecordValues = new ArrayList<>(); - for(JoinOn joinOn : rightMostJoin.getJoinOns()) - { - Serializable joinValue = joinRecord.getValue(rightMostJoin.getLeftTable() + "." + joinOn.getLeftField()); - if(joinValue == null && joinRecord.getValues().keySet().stream().anyMatch(n -> !n.contains("."))) - { - joinValue = joinRecord.getValue(joinOn.getLeftField()); - } - joinRecordValues.add(joinValue); - } + inputRecordMapByJoinFields.add(inputRecordJoinValues, inputRecord); + } - joinRecordMapByJoinFields.put(joinRecordValues, joinRecord); + ////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // execute the query for joined records - then put them in a map with keys corresponding to the join values // + // e.g., (17,47)=(JoinRecord), (18,48)=(JoinRecord) // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////// + QueryOutput queryOutput = new QueryAction().execute(queryInput); + Map, QRecord> joinRecordMapByJoinFields = new HashMap<>(); + for(QRecord joinRecord : queryOutput.getRecords()) + { + List joinRecordValues = new ArrayList<>(); + for(JoinOn joinOn : rightMostJoin.getJoinOns()) + { + Serializable joinValue = joinRecord.getValue(rightMostJoin.getLeftTable() + "." + joinOn.getLeftField()); + if(joinValue == null && joinRecord.getValues().keySet().stream().anyMatch(n -> !n.contains("."))) + { + joinValue = joinRecord.getValue(joinOn.getLeftField()); + } + joinRecordValues.add(joinValue); } - ////////////////////////////////////////////////////////////////////////////////////////////////// - // now for each input record, look for its joinRecord - if it isn't found, then this insert // - // isn't allowed. if it is found, then validate its value matches this session's security keys // - ////////////////////////////////////////////////////////////////////////////////////////////////// - for(Map.Entry, List> entry : inputRecordMapByJoinFields.entrySet()) + joinRecordMapByJoinFields.put(joinRecordValues, joinRecord); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // now for each input record, look for its joinRecord - if it isn't found, then this insert // + // isn't allowed. if it is found, then validate its value matches this session's security keys // + ////////////////////////////////////////////////////////////////////////////////////////////////// + for(Map.Entry, List> entry : inputRecordMapByJoinFields.entrySet()) + { + List inputRecordJoinValues = entry.getKey(); + List inputRecords = entry.getValue(); + if(joinRecordMapByJoinFields.containsKey(inputRecordJoinValues)) { - List inputRecordJoinValues = entry.getKey(); - List inputRecords = entry.getValue(); - if(joinRecordMapByJoinFields.containsKey(inputRecordJoinValues)) + QRecord joinRecord = joinRecordMapByJoinFields.get(inputRecordJoinValues); + + String fieldName = recordSecurityLock.getFieldName().replaceFirst(".*\\.", ""); + QFieldMetaData field = leftMostJoinTable.getField(fieldName); + Serializable recordSecurityValue = joinRecord.getValue(fieldName); + if(recordSecurityValue == null && joinRecord.getValues().keySet().stream().anyMatch(n -> n.contains("."))) { - QRecord joinRecord = joinRecordMapByJoinFields.get(inputRecordJoinValues); + recordSecurityValue = joinRecord.getValue(recordSecurityLock.getFieldName()); + } - String fieldName = recordSecurityLock.getFieldName().replaceFirst(".*\\.", ""); - QFieldMetaData field = leftMostJoinTable.getField(fieldName); - Serializable recordSecurityValue = joinRecord.getValue(fieldName); - if(recordSecurityValue == null && joinRecord.getValues().keySet().stream().anyMatch(n -> n.contains("."))) + for(QRecord inputRecord : inputRecords) + { + List recordErrors = validateRecordSecurityValue(table, recordSecurityLock, recordSecurityValue, field.getType(), action); + if(CollectionUtils.nullSafeHasContents(recordErrors)) { - recordSecurityValue = joinRecord.getValue(recordSecurityLock.getFieldName()); - } - - for(QRecord inputRecord : inputRecords) - { - validateRecordSecurityValue(table, inputRecord, recordSecurityLock, recordSecurityValue, field.getType(), action); + errorRecords.computeIfAbsent(inputRecord.getValue(primaryKeyField), (k) -> new RecordWithErrors(inputRecord)).addAll(recordErrors, treePosition); } } - else + } + else + { + for(QRecord inputRecord : inputRecords) { - for(QRecord inputRecord : inputRecords) + if(RecordSecurityLock.NullValueBehavior.DENY.equals(NullValueBehaviorUtil.getEffectiveNullValueBehavior(recordSecurityLock))) { - if(RecordSecurityLock.NullValueBehavior.DENY.equals(NullValueBehaviorUtil.getEffectiveNullValueBehavior(recordSecurityLock))) - { - inputRecord.addError(new PermissionDeniedMessage("You do not have permission to " + action.name().toLowerCase() + " this record - the referenced " + leftMostJoinTable.getLabel() + " was not found.")); - } + PermissionDeniedMessage error = new PermissionDeniedMessage("You do not have permission to " + action.name().toLowerCase() + " this record - the referenced " + leftMostJoinTable.getLabel() + " was not found."); + errorRecords.computeIfAbsent(inputRecord.getValue(primaryKeyField), (k) -> new RecordWithErrors(inputRecord)).add(error, treePosition); } } } @@ -247,52 +363,77 @@ public class ValidateRecordSecurityLockHelper /******************************************************************************* - ** + ** for tracking errors, we use primary keys. add "made up" ones to records + ** if needed (e.g., insert use-case). *******************************************************************************/ - private static List getRecordSecurityLocks(QTableMetaData table, Action action) + private static Map makeUpPrimaryKeysIfNeeded(List records, QTableMetaData table) { - List recordSecurityLocks = CollectionUtils.nonNullList(table.getRecordSecurityLocks()); - List locksToCheck = new ArrayList<>(); - - recordSecurityLocks = switch(action) + String primaryKeyField = table.getPrimaryKeyField(); + Map madeUpPrimaryKeys = new HashMap<>(); + Integer madeUpPrimaryKey = -1; + for(QRecord record : records) { - case INSERT, UPDATE, DELETE -> RecordSecurityLockFilters.filterForWriteLocks(recordSecurityLocks); - case SELECT -> RecordSecurityLockFilters.filterForReadLocks(recordSecurityLocks); + if(record.getValue(primaryKeyField) == null) + { + madeUpPrimaryKeys.put(madeUpPrimaryKey, record); + record.setValue(primaryKeyField, madeUpPrimaryKey); + madeUpPrimaryKey--; + } + } + return madeUpPrimaryKeys; + } + + + + /******************************************************************************* + ** For a given table & action type, convert the table's record locks to a + ** MultiRecordSecurityLock, with only the appropriate lock-scopes being included + ** (e.g., read-locks for selects, write-locks for insert/update/delete). + *******************************************************************************/ + static MultiRecordSecurityLock getRecordSecurityLocks(QTableMetaData table, Action action) + { + List allLocksOnTable = CollectionUtils.nonNullList(table.getRecordSecurityLocks()); + MultiRecordSecurityLock locksOfType = switch(action) + { + case INSERT, UPDATE, DELETE -> RecordSecurityLockFilters.filterForWriteLockTree(allLocksOnTable); + case SELECT -> RecordSecurityLockFilters.filterForReadLockTree(allLocksOnTable); default -> throw (new IllegalArgumentException("Unsupported action: " + action)); }; + if(action.equals(Action.UPDATE)) + { + //////////////////////////////////////////////////////// + // when doing an update, convert all OR's to AND's... // + //////////////////////////////////////////////////////// + updateOperators(locksOfType, MultiRecordSecurityLock.BooleanOperator.AND); + } + //////////////////////////////////////// // if there are no locks, just return // //////////////////////////////////////// - if(CollectionUtils.nullSafeIsEmpty(recordSecurityLocks)) + if(locksOfType == null || CollectionUtils.nullSafeIsEmpty(locksOfType.getLocks())) { return (null); } - //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // decide if any locks need checked - where one may not need checked if it has an all-access key, and the user has all-access // - //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - for(RecordSecurityLock recordSecurityLock : recordSecurityLocks) - { - if(recordSecurityLock instanceof MultiRecordSecurityLock multiRecordSecurityLock) - { - // todo do do! - LOG.warn("Totally not ready to handle multiRecordSecurityLock in here!!", new Throwable()); - continue; - } + return (locksOfType); + } - QSecurityKeyType securityKeyType = QContext.getQInstance().getSecurityKeyType(recordSecurityLock.getSecurityKeyType()); - if(StringUtils.hasContent(securityKeyType.getAllAccessKeyName()) && QContext.getQSession().hasSecurityKeyValue(securityKeyType.getAllAccessKeyName(), true, QFieldType.BOOLEAN)) + + + /******************************************************************************* + ** for a full multi-lock tree, set all of the boolean operators to the specified one. + *******************************************************************************/ + private static void updateOperators(MultiRecordSecurityLock multiLock, MultiRecordSecurityLock.BooleanOperator operator) + { + multiLock.setOperator(operator); + for(RecordSecurityLock childLock : multiLock.getLocks()) + { + if(childLock instanceof MultiRecordSecurityLock childMultiLock) { - LOG.trace("Session has " + securityKeyType.getAllAccessKeyName() + " - not checking this lock."); - } - else - { - locksToCheck.add(recordSecurityLock); + updateOperators(childMultiLock, operator); } } - - return (locksToCheck); } @@ -300,7 +441,7 @@ public class ValidateRecordSecurityLockHelper /******************************************************************************* ** *******************************************************************************/ - public static void validateRecordSecurityValue(QTableMetaData table, QRecord record, RecordSecurityLock recordSecurityLock, Serializable recordSecurityValue, QFieldType fieldType, Action action) + public static List validateRecordSecurityValue(QTableMetaData table, RecordSecurityLock recordSecurityLock, Serializable recordSecurityValue, QFieldType fieldType, Action action) { if(recordSecurityValue == null) { @@ -310,7 +451,7 @@ public class ValidateRecordSecurityLockHelper if(RecordSecurityLock.NullValueBehavior.DENY.equals(NullValueBehaviorUtil.getEffectiveNullValueBehavior(recordSecurityLock))) { String lockLabel = CollectionUtils.nullSafeHasContents(recordSecurityLock.getJoinNameChain()) ? recordSecurityLock.getSecurityKeyType() : table.getField(recordSecurityLock.getFieldName()).getLabel(); - record.addError(new PermissionDeniedMessage("You do not have permission to " + action.name().toLowerCase() + " a record without a value in the field: " + lockLabel)); + return (List.of(new PermissionDeniedMessage("You do not have permission to " + action.name().toLowerCase() + " a record without a value in the field: " + lockLabel))); } } else @@ -322,15 +463,305 @@ public class ValidateRecordSecurityLockHelper /////////////////////////////////////////////////////////////////////////////////////////////// // avoid telling the user a value from a foreign record that they didn't pass in themselves. // /////////////////////////////////////////////////////////////////////////////////////////////// - record.addError(new PermissionDeniedMessage("You do not have permission to " + action.name().toLowerCase() + " this record.")); + return (List.of(new PermissionDeniedMessage("You do not have permission to " + action.name().toLowerCase() + " this record."))); } else { QFieldMetaData field = table.getField(recordSecurityLock.getFieldName()); - record.addError(new PermissionDeniedMessage("You do not have permission to " + action.name().toLowerCase() + " a record with a value of " + recordSecurityValue + " in the field: " + field.getLabel())); + return (List.of(new PermissionDeniedMessage("You do not have permission to " + action.name().toLowerCase() + " a record with a value of " + recordSecurityValue + " in the field: " + field.getLabel()))); } } } + return (Collections.emptyList()); + } + + + + /******************************************************************************* + ** Class to track errors that we're associating with a record. + ** + ** More complex than it first seems to be needed, because as we're evaluating + ** locks, we might find some, but based on the boolean condition associated with + ** them, they might not actually be record-level errors. + ** + ** e.g., two locks with an OR relationship - as long as one passes, the record + ** should have no errors. And so-on through the tree of locks/multi-locks. + ** + ** Stores the errors in a tree of ErrorTreeNode objects. + ** + ** References into that tree are achieved via a List of Integer called "tree positions" + ** where each entry in the list denotes the index of the tree node at that level. + ** + ** e.g., given this tree: + **
+    **   A      B
+    **  / \    /|\
+    ** C   D  E F G
+    **     |
+    **     H
+    ** 
+ ** + ** The positions of each node would be: + **
+    ** A: [0]
+    ** B: [1]
+    ** C: [0,0]
+    ** D: [0,1]
+    ** E: [1,0]
+    ** F: [1,1]
+    ** G: [1,2]
+    ** H: [0,1,0]
+    ** 
+ *******************************************************************************/ + static class RecordWithErrors + { + private QRecord record; + private ErrorTreeNode errorTree; + + + + /******************************************************************************* + ** Constructor + ** + *******************************************************************************/ + public RecordWithErrors(QRecord record) + { + this.record = record; + } + + + + /******************************************************************************* + ** add a list of errors, for a given list of tree positions + *******************************************************************************/ + public void addAll(List recordErrors, List treePositions) + { + if(errorTree == null) + { + errorTree = new ErrorTreeNode(); + } + + ErrorTreeNode node = errorTree; + for(Integer treePosition : treePositions) + { + if(node.children == null) + { + node.children = new ArrayList<>(treePosition); + } + + while(treePosition >= node.children.size()) + { + node.children.add(null); + } + + if(node.children.get(treePosition) == null) + { + node.children.set(treePosition, new ErrorTreeNode()); + } + + node = node.children.get(treePosition); + } + + if(node.errors == null) + { + node.errors = new ArrayList<>(); + } + node.errors.addAll(recordErrors); + } + + + + /******************************************************************************* + ** add a single error to a given tree-position + *******************************************************************************/ + public void add(QErrorMessage error, List treePositions) + { + addAll(List.of(error), treePositions); + } + + + + /******************************************************************************* + ** after the tree of errors has been built - walk a lock-tree (locksToCheck) + ** and resolve boolean operations, to get a final list of errors (possibly empty) + ** to put on the record. + *******************************************************************************/ + public void propagateErrorsToRecord(MultiRecordSecurityLock locksToCheck) + { + List errors = recursivePropagation(locksToCheck, new ArrayList<>()); + + if(CollectionUtils.nullSafeHasContents(errors)) + { + errors.forEach(e -> record.addError(e)); + } + } + + + + /******************************************************************************* + ** recursive implementation of the propagation method - e.g., walk tree applying + ** boolean logic. + *******************************************************************************/ + private List recursivePropagation(MultiRecordSecurityLock locksToCheck, List treePositions) + { + ////////////////////////////////////////////////////////////////// + // build a list of errors at this level (and deeper levels too) // + ////////////////////////////////////////////////////////////////// + List errorsFromThisLevel = new ArrayList<>(); + + int i = 0; + for(RecordSecurityLock lock : locksToCheck.getLocks()) + { + List errorsFromThisLock; + + treePositions.add(i); + if(lock instanceof MultiRecordSecurityLock childMultiLock) + { + errorsFromThisLock = recursivePropagation(childMultiLock, treePositions); + } + else + { + errorsFromThisLock = getErrorsFromTree(treePositions); + } + + errorsFromThisLevel.addAll(errorsFromThisLock); + + treePositions.remove(treePositions.size() - 1); + i++; + } + + if(MultiRecordSecurityLock.BooleanOperator.AND.equals(locksToCheck.getOperator())) + { + ////////////////////////////////////////////////////////////// + // for an AND - if there were ANY errors, then return them. // + ////////////////////////////////////////////////////////////// + if(!errorsFromThisLevel.isEmpty()) + { + return (errorsFromThisLevel); + } + } + else // OR + { + ////////////////////////////////////////////////////////// + // for an OR - only return if ALL conditions had errors // + ////////////////////////////////////////////////////////// + if(errorsFromThisLevel.size() == locksToCheck.getLocks().size()) + { + return (errorsFromThisLevel); // todo something smarter? + } + } + + /////////////////////////////////// + // else - no errors - empty list // + /////////////////////////////////// + return Collections.emptyList(); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private List getErrorsFromTree(List treePositions) + { + ErrorTreeNode node = errorTree; + + for(Integer treePosition : treePositions) + { + if(node.children == null) + { + return Collections.emptyList(); + } + + if(treePosition >= node.children.size()) + { + return Collections.emptyList(); + } + + if(node.children.get(treePosition) == null) + { + return Collections.emptyList(); + } + + node = node.children.get(treePosition); + } + + if(node.errors == null) + { + return Collections.emptyList(); + } + + return node.errors; + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public String toString() + { + try + { + return JsonUtils.toPrettyJson(this); + } + catch(Exception e) + { + return "error in toString"; + } + } + } + + + + /******************************************************************************* + ** tree node used by RecordWithErrors + *******************************************************************************/ + static class ErrorTreeNode + { + private List errors; + private ArrayList children; + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public String toString() + { + try + { + return JsonUtils.toPrettyJson(this); + } + catch(Exception e) + { + return "error in toString"; + } + } + + + + /******************************************************************************* + ** Getter for errors - only here for Jackson/toString + ** + *******************************************************************************/ + public List getErrors() + { + return errors; + } + + + + /******************************************************************************* + ** Getter for children - only here for Jackson/toString + ** + *******************************************************************************/ + public ArrayList getChildren() + { + return children; + } } } diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelperTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelperTest.java new file mode 100644 index 00000000..6186ad4e --- /dev/null +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/helpers/ValidateRecordSecurityLockHelperTest.java @@ -0,0 +1,109 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.backend.core.actions.tables.helpers; + + +import java.util.List; +import com.kingsrook.qqq.backend.core.BaseTest; +import com.kingsrook.qqq.backend.core.actions.tables.helpers.ValidateRecordSecurityLockHelper.RecordWithErrors; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.security.MultiRecordSecurityLock; +import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock; +import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage; +import org.junit.jupiter.api.Test; +import static com.kingsrook.qqq.backend.core.model.metadata.security.MultiRecordSecurityLock.BooleanOperator.AND; + + +/******************************************************************************* + ** Unit test for ValidateRecordSecurityLockHelper + *******************************************************************************/ +class ValidateRecordSecurityLockHelperTest extends BaseTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testRecordWithErrors() + { + { + RecordWithErrors recordWithErrors = new RecordWithErrors(new QRecord()); + recordWithErrors.add(new BadInputStatusMessage("0"), List.of(0)); + System.out.println(recordWithErrors); + recordWithErrors.propagateErrorsToRecord(new MultiRecordSecurityLock().withOperator(AND).withLocks(List.of(new RecordSecurityLock()))); + System.out.println("----------------------------------------------------------------------------"); + } + + { + RecordWithErrors recordWithErrors = new RecordWithErrors(new QRecord()); + recordWithErrors.add(new BadInputStatusMessage("1"), List.of(1)); + System.out.println(recordWithErrors); + recordWithErrors.propagateErrorsToRecord(new MultiRecordSecurityLock().withLocks(List.of(new RecordSecurityLock(), new RecordSecurityLock()))); + System.out.println("----------------------------------------------------------------------------"); + } + + { + RecordWithErrors recordWithErrors = new RecordWithErrors(new QRecord()); + recordWithErrors.add(new BadInputStatusMessage("0"), List.of(0)); + recordWithErrors.add(new BadInputStatusMessage("1"), List.of(1)); + System.out.println(recordWithErrors); + recordWithErrors.propagateErrorsToRecord(new MultiRecordSecurityLock().withLocks(List.of(new RecordSecurityLock(), new RecordSecurityLock()))); + System.out.println("----------------------------------------------------------------------------"); + } + + { + RecordWithErrors recordWithErrors = new RecordWithErrors(new QRecord()); + recordWithErrors.add(new BadInputStatusMessage("1,1"), List.of(1, 1)); + System.out.println(recordWithErrors); + recordWithErrors.propagateErrorsToRecord(new MultiRecordSecurityLock().withLocks(List.of( + new MultiRecordSecurityLock().withLocks(List.of(new RecordSecurityLock(), new RecordSecurityLock())), + new MultiRecordSecurityLock().withLocks(List.of(new RecordSecurityLock(), new RecordSecurityLock())) + ))); + System.out.println("----------------------------------------------------------------------------"); + } + + { + RecordWithErrors recordWithErrors = new RecordWithErrors(new QRecord()); + recordWithErrors.add(new BadInputStatusMessage("0,0"), List.of(0, 0)); + recordWithErrors.add(new BadInputStatusMessage("1,1"), List.of(1, 1)); + System.out.println(recordWithErrors); + recordWithErrors.propagateErrorsToRecord(new MultiRecordSecurityLock().withLocks(List.of( + new MultiRecordSecurityLock().withLocks(List.of(new RecordSecurityLock(), new RecordSecurityLock())), + new MultiRecordSecurityLock().withLocks(List.of(new RecordSecurityLock(), new RecordSecurityLock())) + ))); + System.out.println("----------------------------------------------------------------------------"); + } + + { + RecordWithErrors recordWithErrors = new RecordWithErrors(new QRecord()); + recordWithErrors.add(new BadInputStatusMessage("0"), List.of(0)); + recordWithErrors.add(new BadInputStatusMessage("1,1"), List.of(1, 1)); + System.out.println(recordWithErrors); + recordWithErrors.propagateErrorsToRecord(new MultiRecordSecurityLock().withLocks(List.of( + new RecordSecurityLock(), + new MultiRecordSecurityLock().withLocks(List.of(new RecordSecurityLock(), new RecordSecurityLock())) + ))); + System.out.println("----------------------------------------------------------------------------"); + } + } + +} \ No newline at end of file