add-unix-user-hosting-asset-validation (#66)

Co-authored-by: Michael Hoennig <michael@hoennig.de>
Reviewed-on: #66
Reviewed-by: Marc Sandlus <marc.sandlus@hostsharing.net>
This commit is contained in:
Michael Hoennig 2024-06-27 12:39:44 +02:00
parent de88f1d842
commit 6167ef2221
28 changed files with 895 additions and 233 deletions

View File

@ -11,6 +11,7 @@ import lombok.NoArgsConstructor;
import lombok.Setter;
import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectEntity;
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetEntity;
import net.hostsharing.hsadminng.hs.validation.PropertiesProvider;
import net.hostsharing.hsadminng.mapper.PatchableMapWrapper;
import net.hostsharing.hsadminng.rbac.rbacdef.RbacView;
import net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL;
@ -42,6 +43,7 @@ import java.util.List;
import java.util.Map;
import java.util.UUID;
import static java.util.Collections.emptyMap;
import static java.util.Optional.ofNullable;
import static net.hostsharing.hsadminng.mapper.PostgresDateRange.lowerInclusiveFromPostgresDateRange;
import static net.hostsharing.hsadminng.mapper.PostgresDateRange.toPostgresDateRange;
@ -68,7 +70,7 @@ import static net.hostsharing.hsadminng.stringify.Stringify.stringify;
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class HsBookingItemEntity implements Stringifyable, RbacObject {
public class HsBookingItemEntity implements Stringifyable, RbacObject, PropertiesProvider {
private static Stringify<HsBookingItemEntity> stringify = stringify(HsBookingItemEntity.class)
.withProp(HsBookingItemEntity::getProject)
@ -146,6 +148,23 @@ public class HsBookingItemEntity implements Stringifyable, RbacObject {
return upperInclusiveFromPostgresDateRange(getValidity());
}
@Override
public Map<String, Object> directProps() {
return resources;
}
@Override
public Object getContextValue(final String propName) {
final var v = resources.get(propName);
if (v!= null) {
return v;
}
if (parentItem!=null) {
return parentItem.getResources().get(propName);
}
return emptyMap();
}
@Override
public String toString() {
return stringify.apply(this);

View File

@ -29,7 +29,7 @@ public class HsBookingItemEntityValidator extends HsEntityValidator<HsBookingIte
}
private List<String> validateProperties(final HsBookingItemEntity bookingItem) {
return enrich(prefix(bookingItem.toShortString(), "resources"), validateProperties(bookingItem.getResources()));
return enrich(prefix(bookingItem.toShortString(), "resources"), super.validateProperties(bookingItem));
}
private static List<String> optionallyValidate(final HsBookingItemEntity bookingItem) {

View File

@ -1,6 +1,7 @@
package net.hostsharing.hsadminng.hs.hosting.asset;
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRepository;
import net.hostsharing.hsadminng.hs.hosting.asset.validators.HsHostingAssetEntityValidatorRegistry;
import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.api.HsHostingAssetsApi;
import net.hostsharing.hsadminng.context.Context;
@ -78,7 +79,7 @@ public class HsHostingAssetController implements HsHostingAssetsApi {
.path("/api/hs/hosting/assets/{id}")
.buildAndExpand(saved.getUuid())
.toUri();
final var mapped = mapper.map(saved, HsHostingAssetResource.class);
final var mapped = mapper.map(saved, HsHostingAssetResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
return ResponseEntity.created(uri).body(mapped);
}
@ -94,7 +95,7 @@ public class HsHostingAssetController implements HsHostingAssetsApi {
final var result = assetRepo.findByUuid(assetUuid);
return result
.map(assetEntity -> ResponseEntity.ok(
mapper.map(assetEntity, HsHostingAssetResource.class)))
mapper.map(assetEntity, HsHostingAssetResource.class, ENTITY_TO_RESOURCE_POSTMAPPER)))
.orElseGet(() -> ResponseEntity.notFound().build());
}
@ -126,8 +127,17 @@ public class HsHostingAssetController implements HsHostingAssetsApi {
new HsHostingAssetEntityPatcher(em, current).apply(body);
// TODO.refa: draft for an alternative API
// validate(current) // self-validation, hashing passwords etc.
// .then(HsHostingAssetEntityValidatorRegistry::prepareForSave) // hashing passwords etc.
// .then(assetRepo::save)
// .then(HsHostingAssetEntityValidatorRegistry::validateInContext)
// // In this last step we need the entity and the mapped resource instance,
// // which is exactly what a postmapper takes as arguments.
// .then(this::mapToResource) using postProcessProperties to remove write-only + add read-only properties
final var saved = validated(assetRepo.save(current));
final var mapped = mapper.map(saved, HsHostingAssetResource.class);
final var mapped = mapper.map(saved, HsHostingAssetResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
return ResponseEntity.ok(mapped);
}
@ -144,4 +154,7 @@ public class HsHostingAssetController implements HsHostingAssetsApi {
resource.getParentAssetUuid()))));
}
};
final BiConsumer<HsHostingAssetEntity, HsHostingAssetResource> ENTITY_TO_RESOURCE_POSTMAPPER
= HsHostingAssetEntityValidatorRegistry::postprocessProperties;
}

View File

@ -9,6 +9,7 @@ import lombok.NoArgsConstructor;
import lombok.Setter;
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemEntity;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity;
import net.hostsharing.hsadminng.hs.validation.PropertiesProvider;
import net.hostsharing.hsadminng.mapper.PatchableMapWrapper;
import net.hostsharing.hsadminng.rbac.rbacdef.RbacView;
import net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL;
@ -39,6 +40,7 @@ import java.util.List;
import java.util.Map;
import java.util.UUID;
import static java.util.Collections.emptyMap;
import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Column.dependsOnColumn;
import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.ColumnValue.usingDefaultCase;
import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.GLOBAL;
@ -63,7 +65,7 @@ import static net.hostsharing.hsadminng.stringify.Stringify.stringify;
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class HsHostingAssetEntity implements Stringifyable, RbacObject {
public class HsHostingAssetEntity implements Stringifyable, RbacObject, PropertiesProvider {
private static Stringify<HsHostingAssetEntity> stringify = stringify(HsHostingAssetEntity.class)
.withProp(HsHostingAssetEntity::getType)
@ -122,7 +124,7 @@ public class HsHostingAssetEntity implements Stringifyable, RbacObject {
private PatchableMapWrapper<Object> configWrapper;
@Transient
private boolean isLoaded = false;
private boolean isLoaded;
@PostLoad
public void markAsLoaded() {
@ -137,6 +139,28 @@ public class HsHostingAssetEntity implements Stringifyable, RbacObject {
PatchableMapWrapper.of(configWrapper, (newWrapper) -> {configWrapper = newWrapper; }, config).assign(newConfig);
}
@Override
public Map<String, Object> directProps() {
return config;
}
@Override
public Object getContextValue(final String propName) {
final var v = config.get(propName);
if (v!= null) {
return v;
}
if (bookingItem!=null) {
return bookingItem.getResources().get(propName);
}
if (parentAsset!=null && parentAsset.getBookingItem()!=null) {
return parentAsset.getBookingItem().getResources().get(propName);
}
return emptyMap();
}
@Override
public String toString() {
return stringify.apply(this);

View File

@ -47,7 +47,7 @@ public abstract class HsHostingAssetEntityValidator extends HsEntityValidator<Hs
@Override
public List<String> validate(final HsHostingAssetEntity assetEntity) {
return sequentiallyValidate(
() -> validateEntityReferences(assetEntity),
() -> validateEntityReferencesAndProperties(assetEntity),
() -> validateIdentifierPattern(assetEntity), // might need proper parentAsset or billingItem
() -> optionallyValidate(assetEntity.getBookingItem()),
() -> optionallyValidate(assetEntity.getParentAsset()),
@ -55,7 +55,7 @@ public abstract class HsHostingAssetEntityValidator extends HsEntityValidator<Hs
);
}
private List<String> validateEntityReferences(final HsHostingAssetEntity assetEntity) {
private List<String> validateEntityReferencesAndProperties(final HsHostingAssetEntity assetEntity) {
return Stream.of(
validateReferencedEntity(assetEntity, "bookingItem", bookingItemValidation::validate),
validateReferencedEntity(assetEntity, "parentAsset", parentAssetValidation::validate),
@ -76,7 +76,7 @@ public abstract class HsHostingAssetEntityValidator extends HsEntityValidator<Hs
}
private List<String> validateProperties(final HsHostingAssetEntity assetEntity) {
return enrich(prefix(assetEntity.toShortString(), "config"), validateProperties(assetEntity.getConfig()));
return enrich(prefix(assetEntity.toShortString(), "config"), super.validateProperties(assetEntity));
}
private static List<String> optionallyValidate(final HsHostingAssetEntity assetEntity) {

View File

@ -2,6 +2,7 @@ package net.hostsharing.hsadminng.hs.hosting.asset.validators;
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetEntity;
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType;
import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.model.HsHostingAssetResource;
import net.hostsharing.hsadminng.hs.validation.HsEntityValidator;
import net.hostsharing.hsadminng.errors.MultiValidationException;
@ -40,7 +41,8 @@ public class HsHostingAssetEntityValidatorRegistry {
}
public static List<String> doValidate(final HsHostingAssetEntity hostingAsset) {
return HsHostingAssetEntityValidatorRegistry.forType(hostingAsset.getType()).validate(hostingAsset);
final var validator = HsHostingAssetEntityValidatorRegistry.forType(hostingAsset.getType());
return validator.validate(hostingAsset);
}
public static HsHostingAssetEntity validated(final HsHostingAssetEntity entityToSave) {
@ -48,4 +50,18 @@ public class HsHostingAssetEntityValidatorRegistry {
return entityToSave;
}
public static void postprocessProperties(final HsHostingAssetEntity entity, final HsHostingAssetResource resource) {
final var validator = HsHostingAssetEntityValidatorRegistry.forType(entity.getType());
final var config = validator.postProcess(entity, asMap(resource));
resource.setConfig(config);
}
@SuppressWarnings("unchecked")
private static Map<String, Object> asMap(final HsHostingAssetResource resource) {
if (resource.getConfig() instanceof Map map) {
return map;
}
throw new IllegalArgumentException("expected a Map, but got a " + resource.getConfig().getClass());
}
}

View File

@ -2,17 +2,35 @@ package net.hostsharing.hsadminng.hs.hosting.asset.validators;
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetEntity;
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType;
import net.hostsharing.hsadminng.hs.validation.PropertiesProvider;
import java.util.regex.Pattern;
import static net.hostsharing.hsadminng.hs.validation.EnumerationProperty.enumerationProperty;
import static net.hostsharing.hsadminng.hs.validation.IntegerProperty.integerProperty;
import static net.hostsharing.hsadminng.hs.validation.PasswordProperty.passwordProperty;
import static net.hostsharing.hsadminng.hs.validation.StringProperty.stringProperty;
class HsUnixUserHostingAssetValidator extends HsHostingAssetEntityValidator {
private static final int DASH_LENGTH = "-".length();
HsUnixUserHostingAssetValidator() {
super(BookingItem.mustBeNull(),
ParentAsset.mustBeOfType(HsHostingAssetType.MANAGED_WEBSPACE),
AssignedToAsset.mustBeNull(),
AlarmContact.isOptional(), // TODO.spec: for quota notifications
NO_EXTRA_PROPERTIES); // TODO.spec: yet to be specified
super( BookingItem.mustBeNull(),
ParentAsset.mustBeOfType(HsHostingAssetType.MANAGED_WEBSPACE),
AssignedToAsset.mustBeNull(),
AlarmContact.isOptional(),
integerProperty("SSD hard quota").unit("GB").maxFrom("SSD").optional(),
integerProperty("SSD soft quota").unit("GB").maxFrom("SSD hard quota").optional(),
integerProperty("HDD hard quota").unit("GB").maxFrom("HDD").optional(),
integerProperty("HDD soft quota").unit("GB").maxFrom("HDD hard quota").optional(),
enumerationProperty("shell")
.values("/bin/false", "/bin/bash", "/bin/csh", "/bin/dash", "/usr/bin/tcsh", "/usr/bin/zsh", "/usr/bin/passwd")
.withDefault("/bin/false"),
stringProperty("homedir").readOnly().computedBy(HsUnixUserHostingAssetValidator::computeHomedir),
stringProperty("totpKey").matchesRegEx("^0x([0-9A-Fa-f]{2})+$").minLength(20).maxLength(256).undisclosed().writeOnly().optional(),
passwordProperty("password").minLength(8).maxLength(40).writeOnly());
}
@Override
@ -20,4 +38,11 @@ class HsUnixUserHostingAssetValidator extends HsHostingAssetEntityValidator {
final var webspaceIdentifier = assetEntity.getParentAsset().getIdentifier();
return Pattern.compile("^"+webspaceIdentifier+"$|^"+webspaceIdentifier+"-[a-z0-9]+$");
}
private static String computeHomedir(final PropertiesProvider propertiesProvider) {
final var entity = (HsHostingAssetEntity) propertiesProvider;
final var webspaceName = entity.getParentAsset().getIdentifier();
return "/home/pacs/" + webspaceName
+ "/users/" + entity.getIdentifier().substring(webspaceName.length()+DASH_LENGTH);
}
}

View File

@ -4,7 +4,7 @@ import lombok.Setter;
import net.hostsharing.hsadminng.mapper.Array;
import java.util.AbstractMap;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@ -29,9 +29,9 @@ public class BooleanProperty extends ValidatableProperty<Boolean> {
}
@Override
protected void validate(final ArrayList<String> result, final Boolean propValue, final Map<String, Object> props) {
protected void validate(final List<String> result, final Boolean propValue, final PropertiesProvider propProvider) {
if (falseIf != null && propValue) {
final Object referencedValue = props.get(falseIf.getKey());
final Object referencedValue = propProvider.directProps().get(falseIf.getKey());
if (Objects.equals(referencedValue, falseIf.getValue())) {
result.add(propertyName + "' is expected to be false because " +
falseIf.getKey() + "=" + referencedValue + " but is " + propValue);

View File

@ -3,9 +3,8 @@ package net.hostsharing.hsadminng.hs.validation;
import lombok.Setter;
import net.hostsharing.hsadminng.mapper.Array;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Map;
import java.util.List;
import static java.util.Arrays.stream;
@ -33,25 +32,25 @@ public class EnumerationProperty extends ValidatableProperty<String> {
}
public void deferredInit(final ValidatableProperty<?>[] allProperties) {
if (deferredInit != null) {
if (hasDeferredInit()) {
if (this.values != null) {
throw new IllegalStateException("property " + toString() + " already has values");
throw new IllegalStateException("property " + this + " already has values");
}
this.values = deferredInit.apply(allProperties);
this.values = doDeferredInit(allProperties);
}
}
public ValidatableProperty<String> valuesFromProperties(final String propertyNamePrefix) {
this.deferredInit = (ValidatableProperty<?>[] allProperties) -> stream(allProperties)
this.setDeferredInit( (ValidatableProperty<?>[] allProperties) -> stream(allProperties)
.map(ValidatableProperty::propertyName)
.filter(name -> name.startsWith(propertyNamePrefix))
.map(name -> name.substring(propertyNamePrefix.length()))
.toArray(String[]::new);
.toArray(String[]::new));
return this;
}
@Override
protected void validate(final ArrayList<String> result, final String propValue, final Map<String, Object> props) {
protected void validate(final List<String> result, final String propValue, final PropertiesProvider propProvider) {
if (stream(values).noneMatch(v -> v.equals(propValue))) {
result.add(propertyName + "' is expected to be one of " + Arrays.toString(values) + " but is '" + propValue + "'");
}

View File

@ -3,6 +3,7 @@ package net.hostsharing.hsadminng.hs.validation;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
@ -10,7 +11,8 @@ import java.util.function.Supplier;
import static java.util.Arrays.stream;
import static java.util.Collections.emptyList;
public abstract class HsEntityValidator<E> {
// TODO.refa: rename to HsEntityProcessor, also subclasses
public abstract class HsEntityValidator<E extends PropertiesProvider> {
public final ValidatableProperty<?>[] propertyValidators;
@ -38,16 +40,22 @@ public abstract class HsEntityValidator<E> {
.toList();
}
protected ArrayList<String> validateProperties(final Map<String, Object> properties) {
protected ArrayList<String> validateProperties(final PropertiesProvider propsProvider) {
final var result = new ArrayList<String>();
// verify that all actually given properties are specified
final var properties = propsProvider.directProps();
properties.keySet().forEach( givenPropName -> {
if (stream(propertyValidators).map(pv -> pv.propertyName).noneMatch(propName -> propName.equals(givenPropName))) {
result.add(givenPropName + "' is not expected but is set to '" + properties.get(givenPropName) + "'");
}
});
// run all property validators
stream(propertyValidators).forEach(pv -> {
result.addAll(pv.validate(properties));
result.addAll(pv.validate(propsProvider));
});
return result;
}
@ -80,4 +88,17 @@ public abstract class HsEntityValidator<E> {
}
throw new IllegalArgumentException("Integer value (or null) expected, but got " + value);
}
public Map<String, Object> postProcess(final E entity, final Map<String, Object> config) {
final var copy = new HashMap<>(config);
stream(propertyValidators).forEach(p -> {
if ( p.isWriteOnly()) {
copy.remove(p.propertyName);
}
if (p.isComputed()) {
copy.put(p.propertyName, p.compute(entity));
}
});
return copy;
}
}

View File

@ -2,21 +2,23 @@ package net.hostsharing.hsadminng.hs.validation;
import lombok.Setter;
import net.hostsharing.hsadminng.mapper.Array;
import org.apache.commons.lang3.Validate;
import java.util.ArrayList;
import java.util.Map;
import java.util.List;
@Setter
public class IntegerProperty extends ValidatableProperty<Integer> {
private final static String[] KEY_ORDER = Array.join(
ValidatableProperty.KEY_ORDER_HEAD,
Array.of("unit", "min", "max", "step"),
Array.of("unit", "min", "minFrom", "max", "maxFrom", "step"),
ValidatableProperty.KEY_ORDER_TAIL);
private String unit;
private Integer min;
private String minFrom;
private Integer max;
private String maxFrom;
private Integer step;
public static IntegerProperty integerProperty(final String propertyName) {
@ -27,6 +29,22 @@ public class IntegerProperty extends ValidatableProperty<Integer> {
super(Integer.class, propertyName, KEY_ORDER);
}
@Override
public void deferredInit(final ValidatableProperty<?>[] allProperties) {
Validate.isTrue(min == null || minFrom == null, "min and minFrom are exclusive, but both are given");
Validate.isTrue(max == null || maxFrom == null, "max and maxFrom are exclusive, but both are given");
}
public IntegerProperty minFrom(final String propertyName) {
minFrom = propertyName;
return this;
}
public IntegerProperty maxFrom(final String propertyName) {
maxFrom = propertyName;
return this;
}
@Override
public String unit() {
return unit;
@ -37,20 +55,34 @@ public class IntegerProperty extends ValidatableProperty<Integer> {
}
@Override
protected void validate(final ArrayList<String> result, final Integer propValue, final Map<String, Object> props) {
if (min != null && propValue < min) {
result.add(propertyName + "' is expected to be >= " + min + " but is " + propValue);
}
if (max != null && propValue > max) {
result.add(propertyName + "' is expected to be <= " + max + " but is " + propValue);
}
protected void validate(final List<String> result, final Integer propValue, final PropertiesProvider propProvider) {
validateMin(result, propertyName, propValue, min);
validateMax(result, propertyName, propValue, max);
if (step != null && propValue % step != 0) {
result.add(propertyName + "' is expected to be multiple of " + step + " but is " + propValue);
}
if (minFrom != null) {
validateMin(result, propertyName, propValue, propProvider.getContextValue(minFrom, Integer.class));
}
if (maxFrom != null) {
validateMax(result, propertyName, propValue, propProvider.getContextValue(maxFrom, Integer.class, 0));
}
}
@Override
protected String simpleTypeName() {
return "integer";
}
private static void validateMin(final List<String> result, final String propertyName, final Integer propValue, final Integer min) {
if (min != null && propValue < min) {
result.add(propertyName + "' is expected to be at least " + min + " but is " + propValue);
}
}
private static void validateMax(final List<String> result, final String propertyName, final Integer propValue, final Integer max) {
if (max != null && propValue > max) {
result.add(propertyName + "' is expected to be at most " + max + " but is " + propValue);
}
}
}

View File

@ -0,0 +1,65 @@
package net.hostsharing.hsadminng.hs.validation;
import lombok.Setter;
import java.util.List;
import java.util.stream.Stream;
@Setter
public class PasswordProperty extends StringProperty {
private PasswordProperty(final String propertyName) {
super(propertyName);
undisclosed();
}
public static PasswordProperty passwordProperty(final String propertyName) {
return new PasswordProperty(propertyName);
}
@Override
protected void validate(final List<String> result, final String propValue, final PropertiesProvider propProvider) {
super.validate(result, propValue, propProvider);
validatePassword(result, propValue);
}
// TODO.impl: only a SHA512 hash should be stored in the database, not the password itself
@Override
protected String simpleTypeName() {
return "password";
}
private void validatePassword(final List<String> result, final String password) {
boolean hasLowerCase = false;
boolean hasUpperCase = false;
boolean hasDigit = false;
boolean hasSpecialChar = false;
boolean containsColon = false;
for (char c : password.toCharArray()) {
if (Character.isLowerCase(c)) {
hasLowerCase = true;
} else if (Character.isUpperCase(c)) {
hasUpperCase = true;
} else if (Character.isDigit(c)) {
hasDigit = true;
} else if (!Character.isLetterOrDigit(c)) {
hasSpecialChar = true;
}
if (c == ':') {
containsColon = true;
}
}
final long groupsCovered = Stream.of(hasLowerCase, hasUpperCase, hasDigit, hasSpecialChar).filter(v->v).count();
if ( groupsCovered < 3) {
result.add(propertyName + "' must contain at least one character of at least 3 of the following groups: upper case letters, lower case letters, digits, special characters");
}
if (containsColon) {
result.add(propertyName + "' must not contain colon (':')");
}
}
}

View File

@ -0,0 +1,31 @@
package net.hostsharing.hsadminng.hs.validation;
import java.util.Map;
public interface PropertiesProvider {
Map<String, Object> directProps();
Object getContextValue(final String propName);
default <T> T getDirectValue(final String propName, final Class<T> clazz) {
return cast(propName, directProps().get(propName), clazz, null);
}
default <T> T getContextValue(final String propName, final Class<T> clazz) {
return cast(propName, getContextValue(propName), clazz, null);
}
default <T> T getContextValue(final String propName, final Class<T> clazz, final T defaultValue) {
return cast(propName, getContextValue(propName), clazz, defaultValue);
}
private static <T> T cast( final String propName, final Object value, final Class<T> clazz, final T defaultValue) {
if (value == null && defaultValue != null) {
return defaultValue;
}
if (value == null || clazz.isInstance(value)) {
return clazz.cast(value);
}
throw new IllegalStateException(propName + " expected to be an "+clazz.getSimpleName()+", but got '" + value + "'");
}
}

View File

@ -0,0 +1,79 @@
package net.hostsharing.hsadminng.hs.validation;
import lombok.Setter;
import net.hostsharing.hsadminng.mapper.Array;
import java.util.List;
import java.util.regex.Pattern;
@Setter
public class StringProperty extends ValidatableProperty<String> {
private static final String[] KEY_ORDER = Array.join(
ValidatableProperty.KEY_ORDER_HEAD,
Array.of("matchesRegEx", "minLength", "maxLength"),
ValidatableProperty.KEY_ORDER_TAIL,
Array.of("undisclosed"));
private Pattern matchesRegEx;
private Integer minLength;
private Integer maxLength;
private boolean undisclosed;
protected StringProperty(final String propertyName) {
super(String.class, propertyName, KEY_ORDER);
}
public static StringProperty stringProperty(final String propertyName) {
return new StringProperty(propertyName);
}
public StringProperty minLength(final int minLength) {
this.minLength = minLength;
return this;
}
public StringProperty maxLength(final int maxLength) {
this.maxLength = maxLength;
return this;
}
public StringProperty matchesRegEx(final String regExPattern) {
this.matchesRegEx = Pattern.compile(regExPattern);
return this;
}
/**
* The property value is not disclosed in error messages.
*
* @return this;
*/
public StringProperty undisclosed() {
this.undisclosed = true;
return this;
}
@Override
protected void validate(final List<String> result, final String propValue, final PropertiesProvider propProvider) {
if (minLength != null && propValue.length()<minLength) {
result.add(propertyName + "' length is expected to be at min " + minLength + " but length of " + display(propValue) + " is " + propValue.length());
}
if (maxLength != null && propValue.length()>maxLength) {
result.add(propertyName + "' length is expected to be at max " + maxLength + " but length of " + display(propValue) + " is " + propValue.length());
}
if (matchesRegEx != null && !matchesRegEx.matcher(propValue).matches()) {
result.add(propertyName + "' is expected to be match " + matchesRegEx + " but " + display(propValue) + " does not match");
}
if (isReadOnly() && propValue != null) {
result.add(propertyName + "' is readonly but given as " + display(propValue));
}
}
private String display(final String propValue) {
return undisclosed ? "provided value" : ("'" + propValue + "'");
}
@Override
protected String simpleTypeName() {
return "string";
}
}

View File

@ -1,6 +1,8 @@
package net.hostsharing.hsadminng.hs.validation;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.experimental.Accessors;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemEntity;
@ -21,19 +23,37 @@ import static java.lang.Boolean.TRUE;
import static java.util.Collections.emptyList;
import static java.util.Optional.ofNullable;
@Getter
@RequiredArgsConstructor
public abstract class ValidatableProperty<T> {
protected static final String[] KEY_ORDER_HEAD = Array.of("propertyName");
protected static final String[] KEY_ORDER_TAIL = Array.of("required", "defaultValue", "isTotalsValidator", "thresholdPercentage");
protected static final String[] KEY_ORDER_TAIL = Array.of("required", "defaultValue", "readOnly", "writeOnly", "computed", "isTotalsValidator", "thresholdPercentage");
final Class<T> type;
final String propertyName;
@JsonIgnore
private final String[] keyOrder;
private Boolean required;
private T defaultValue;
protected Function<ValidatableProperty<?>[], T[]> deferredInit;
@JsonIgnore
private Function<PropertiesProvider, T> computedBy;
@Accessors(makeFinal = true, chain = true, fluent = false)
private boolean computed; // used in descriptor, because computedBy cannot be rendered to a text string
@Accessors(makeFinal = true, chain = true, fluent = false)
private boolean readOnly;
@Accessors(makeFinal = true, chain = true, fluent = false)
private boolean writeOnly;
private Function<ValidatableProperty<?>[], T[]> deferredInit;
private boolean isTotalsValidator = false;
@JsonIgnore
private List<Function<HsBookingItemEntity, List<String>>> asTotalLimitValidators; // TODO.impl: move to BookingItemIntegerProperty
@ -43,6 +63,30 @@ public abstract class ValidatableProperty<T> {
return null;
}
protected void setDeferredInit(final Function<ValidatableProperty<?>[], T[]> function) {
this.deferredInit = function;
}
public boolean hasDeferredInit() {
return deferredInit != null;
}
public T[] doDeferredInit(final ValidatableProperty<?>[] allProperties) {
return deferredInit.apply(allProperties);
}
public ValidatableProperty<T> writeOnly() {
this.writeOnly = true;
optional();
return this;
}
public ValidatableProperty<T> readOnly() {
this.readOnly = true;
optional();
return this;
}
public ValidatableProperty<T> required() {
required = TRUE;
return this;
@ -116,8 +160,9 @@ public abstract class ValidatableProperty<T> {
return this;
}
public final List<String> validate(final Map<String, Object> props) {
public final List<String> validate(final PropertiesProvider propsProvider) {
final var result = new ArrayList<String>();
final var props = propsProvider.directProps();
final var propValue = props.get(propertyName);
if (propValue == null) {
if (required) {
@ -127,7 +172,7 @@ public abstract class ValidatableProperty<T> {
if (propValue != null){
if ( type.isInstance(propValue)) {
//noinspection unchecked
validate(result, (T) propValue, props);
validate(result, (T) propValue, propsProvider);
} else {
result.add(propertyName + "' is expected to be of type " + type + ", " +
"but is of type '" + propValue.getClass().getSimpleName() + "'");
@ -136,7 +181,7 @@ public abstract class ValidatableProperty<T> {
return result;
}
protected abstract void validate(final ArrayList<String> result, final T propValue, final Map<String, Object> props);
protected abstract void validate(final List<String> result, final T propValue, final PropertiesProvider propProvider);
public void verifyConsistency(final Map.Entry<? extends Enum<?>, ?> typeDef) {
if (required == null ) {
@ -158,26 +203,32 @@ public abstract class ValidatableProperty<T> {
// Add entries according to the given order
for (String key : keyOrder) {
final Optional<Object> propValue = getPropertyValue(key);
propValue.ifPresent(o -> sortedMap.put(key, o));
propValue.filter(ValidatableProperty::isToBeRendered).ifPresent(o -> sortedMap.put(key, o));
}
return sortedMap;
}
private static boolean isToBeRendered(final Object v) {
return !(v instanceof Boolean b) || b;
}
@SneakyThrows
private Optional<Object> getPropertyValue(final String key) {
return getPropertyValue(getClass(), key);
}
@SneakyThrows
private Optional<Object> getPropertyValue(final Class<?> clazz, final String key) {
try {
final var field = getClass().getDeclaredField(key);
final var field = clazz.getDeclaredField(key);
field.setAccessible(true);
return Optional.ofNullable(arrayToList(field.get(this)));
} catch (final NoSuchFieldException e1) {
try {
final var field = getClass().getSuperclass().getDeclaredField(key);
field.setAccessible(true);
return Optional.ofNullable(arrayToList(field.get(this)));
} catch (final NoSuchFieldException e2) {
return Optional.empty();
} catch (final NoSuchFieldException exc) {
if (clazz.getSuperclass() != null) {
return getPropertyValue(clazz.getSuperclass(), key);
}
throw exc;
}
}
@ -198,4 +249,14 @@ public abstract class ValidatableProperty<T> {
.flatMap(Collection::stream)
.toList();
}
public ValidatableProperty<T> computedBy(final Function<PropertiesProvider, T> compute) {
this.computedBy = compute;
this.computed = true;
return this;
}
public <E extends PropertiesProvider> T compute(final E entity) {
return computedBy.apply(entity);
}
}

View File

@ -1,5 +1,6 @@
package net.hostsharing.hsadminng.mapper;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@ -43,4 +44,8 @@ public class Array {
.toArray(String[]::new);
return joined;
}
public static <T> T[] emptyArray() {
return of();
}
}

View File

@ -53,13 +53,20 @@ public class PatchableMapWrapper<T> implements Map<String, T> {
}
public String toString() {
return "{ "
return "{\n"
+ (
keySet().stream().sorted()
.map(k -> k + ": " + get(k)))
.collect(joining(", ")
.map(k -> " \"" + k + "\": " + optionallyQuoted(get(k))))
.collect(joining(",\n")
)
+ " }";
+ "\n}\n";
}
private Object optionallyQuoted(final Object value) {
if ( value instanceof Number || value instanceof Boolean ) {
return value;
}
return "\"" + value + "\"";
}
// --- below just delegating methods --------------------------------

View File

@ -53,7 +53,7 @@ class HsBookingItemEntityUnitTest {
void toStringContainsAllPropertiesAndResourcesSortedByKey() {
final var result = givenBookingItem.toString();
assertThat(result).isEqualTo("HsBookingItemEntity(D-1234500:test project, CLOUD_SERVER, [2020-01-01,2031-01-01), some caption, { CPUs: 2, HDD-storage: 2048, SSD-storage: 512 })");
assertThat(result).isEqualToIgnoringWhitespace("HsBookingItemEntity(D-1234500:test project, CLOUD_SERVER, [2020-01-01,2031-01-01), some caption, { \"CPUs\": 2, \"HDD-storage\": 2048, \"SSD-storage\": 512 })");
}
@Test

View File

@ -170,9 +170,9 @@ class HsBookingItemRepositoryIntegrationTest extends ContextBasedTestWithCleanup
// then
allTheseBookingItemsAreReturned(
result,
"HsBookingItemEntity(D-1000212:D-1000212 default project, MANAGED_WEBSPACE, [2022-10-01,), separate ManagedWebspace, { Daemons: 0, Multi: 1, SSD: 100, Traffic: 50 })",
"HsBookingItemEntity(D-1000212:D-1000212 default project, MANAGED_SERVER, [2022-10-01,), separate ManagedServer, { CPUs: 2, RAM: 8, SSD: 500, Traffic: 500 })",
"HsBookingItemEntity(D-1000212:D-1000212 default project, PRIVATE_CLOUD, [2024-04-01,), some PrivateCloud, { CPUs: 10, HDD: 10000, RAM: 32, SSD: 4000, Traffic: 2000 })");
"HsBookingItemEntity(D-1000212:D-1000212 default project, MANAGED_WEBSPACE, [2022-10-01,), separate ManagedWebspace, { Daemons: 0, Multi: 1, SSD: 100, Traffic: 50 } )",
"HsBookingItemEntity(D-1000212:D-1000212 default project, MANAGED_SERVER, [2022-10-01,), separate ManagedServer, { CPUs: 2, RAM: 8, SSD: 500, Traffic: 500 } )",
"HsBookingItemEntity(D-1000212:D-1000212 default project, PRIVATE_CLOUD, [2024-04-01,), some PrivateCloud, { CPUs: 10, HDD: 10000, RAM: 32, SSD: 4000, Traffic: 2000 } )");
assertThat(result.stream().filter(bi -> bi.getRelatedHostingAsset()!=null).findAny())
.as("at least one relatedProject expected, but none found => fetching relatedProject does not work")
.isNotEmpty();
@ -193,9 +193,9 @@ class HsBookingItemRepositoryIntegrationTest extends ContextBasedTestWithCleanup
// then:
exactlyTheseBookingItemsAreReturned(
result,
"HsBookingItemEntity(D-1000111:D-1000111 default project, MANAGED_SERVER, [2022-10-01,), separate ManagedServer, { CPUs: 2, RAM: 8, SSD: 500, Traffic: 500 })",
"HsBookingItemEntity(D-1000111:D-1000111 default project, MANAGED_WEBSPACE, [2022-10-01,), separate ManagedWebspace, { Daemons: 0, Multi: 1, SSD: 100, Traffic: 50 })",
"HsBookingItemEntity(D-1000111:D-1000111 default project, PRIVATE_CLOUD, [2024-04-01,), some PrivateCloud, { CPUs: 10, HDD: 10000, RAM: 32, SSD: 4000, Traffic: 2000 })");
"HsBookingItemEntity(D-1000111:D-1000111 default project, MANAGED_WEBSPACE, [2022-10-01,), separate ManagedWebspace, { Daemons: 0, Multi: 1, SSD: 100, Traffic: 50 } )",
"HsBookingItemEntity(D-1000111:D-1000111 default project, MANAGED_SERVER, [2022-10-01,), separate ManagedServer, { CPUs: 2, RAM: 8, SSD: 500, Traffic: 500 } )",
"HsBookingItemEntity(D-1000111:D-1000111 default project, PRIVATE_CLOUD, [2024-04-01,), some PrivateCloud, { CPUs: 10, HDD: 10000, RAM: 32, SSD: 4000, Traffic: 2000 } )");
}
}
@ -348,13 +348,17 @@ class HsBookingItemRepositoryIntegrationTest extends ContextBasedTestWithCleanup
final List<HsBookingItemEntity> actualResult,
final String... bookingItemNames) {
assertThat(actualResult)
.extracting(bookingItemEntity -> bookingItemEntity.toString())
.extracting(HsBookingItemEntity::toString)
.extracting(string-> string.replaceAll("\\s+", " "))
.extracting(string-> string.replaceAll("\"", ""))
.containsExactlyInAnyOrder(bookingItemNames);
}
void allTheseBookingItemsAreReturned(final List<HsBookingItemEntity> actualResult, final String... bookingItemNames) {
assertThat(actualResult)
.extracting(bookingItemEntity -> bookingItemEntity.toString())
.extracting(HsBookingItemEntity::toString)
.extracting(string -> string.replaceAll("\\s+", " "))
.extracting(string -> string.replaceAll("\"", ""))
.contains(bookingItemNames);
}
}

View File

@ -12,21 +12,35 @@ import static net.hostsharing.hsadminng.hs.booking.project.TestHsBookingProject.
@UtilityClass
public class TestHsBookingItem {
public static final HsBookingItemEntity TEST_MANAGED_SERVER_BOOKING_ITEM = HsBookingItemEntity.builder()
.project(TEST_PROJECT)
.type(HsBookingItemType.MANAGED_SERVER)
.caption("test project booking item")
.resources(Map.ofEntries(
entry("someThing", 1),
entry("anotherThing", "blue")
))
.validity(Range.closedInfinite(LocalDate.of(2020, 1, 15)))
.build();
public static final HsBookingItemEntity TEST_CLOUD_SERVER_BOOKING_ITEM = HsBookingItemEntity.builder()
.project(TEST_PROJECT)
.type(HsBookingItemType.CLOUD_SERVER)
.caption("test cloud server booking item")
.validity(Range.closedInfinite(LocalDate.of(2020, 1, 15)))
.build();
public static final HsBookingItemEntity TEST_MANAGED_SERVER_BOOKING_ITEM = HsBookingItemEntity.builder()
.project(TEST_PROJECT)
.type(HsBookingItemType.MANAGED_SERVER)
.caption("test project booking item")
.resources(Map.ofEntries(
entry("CPUs", 2),
entry("RAM", 4),
entry("SSD", 50),
entry("Traffic", 250)
))
.validity(Range.closedInfinite(LocalDate.of(2020, 1, 15)))
.build();
public static final HsBookingItemEntity TEST_MANAGED_WEBSPACE_BOOKING_ITEM = HsBookingItemEntity.builder()
.parentItem(TEST_MANAGED_SERVER_BOOKING_ITEM)
.type(HsBookingItemType.MANAGED_WEBSPACE)
.caption("test managed webspace item")
.resources(Map.ofEntries(
entry("SSD", 50),
entry("Traffic", 250)
))
.validity(Range.closedInfinite(LocalDate.of(2020, 1, 15)))
.build();
}

View File

@ -25,12 +25,14 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.function.Supplier;
import static java.util.Map.entry;
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_SERVER;
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_WEBSPACE;
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.UNIX_USER;
import static net.hostsharing.hsadminng.rbac.test.JsonMatcher.lenientlyEquals;
import static net.hostsharing.hsadminng.rbac.test.JsonMatcher.strictlyEquals;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.matchesRegex;
@ -73,7 +75,7 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup
// given
context("superuser-alex@hostsharing.net");
final var givenProject = projectRepo.findByCaption("D-1000111 default project").stream()
.findAny().orElseThrow();
.findAny().orElseThrow();
RestAssured // @formatter:off
.given()
@ -264,7 +266,8 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup
void propertyValidationsArePerformend_whenAddingAsset() {
context.define("superuser-alex@hostsharing.net");
final var givenBookingItem = givenSomeNewBookingItem("D-1000111 default project",
final var givenBookingItem = givenSomeNewBookingItem(
"D-1000111 default project",
HsBookingItemType.MANAGED_SERVER,
"some PrivateCloud");
@ -292,14 +295,13 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup
"statusPhrase": "Bad Request",
"message": "[
<<<'MANAGED_SERVER:vm1400.config.extra' is not expected but is set to '42',
<<<'MANAGED_SERVER:vm1400.config.monit_max_cpu_usage' is expected to be <= 100 but is 101,
<<<'MANAGED_SERVER:vm1400.config.monit_max_ssd_usage' is expected to be >= 10 but is 0
<<<'MANAGED_SERVER:vm1400.config.monit_max_cpu_usage' is expected to be at most 100 but is 101,
<<<'MANAGED_SERVER:vm1400.config.monit_max_ssd_usage' is expected to be at least 10 but is 0
<<<]"
}
""".replaceAll(" +<<<", ""))); // @formatter:on
}
@Test
void totalsLimitValidationsArePerformend_whenAddingAsset() {
@ -311,7 +313,7 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup
jpaAttempt.transacted(() -> {
context.define("superuser-alex@hostsharing.net");
for (int n = 0; n < 25; ++n ) {
for (int n = 0; n < 25; ++n) {
toCleanup(assetRepo.save(
HsHostingAssetEntity.builder()
.type(UNIX_USER)
@ -358,8 +360,8 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup
void globalAdmin_canGetArbitraryAsset() {
context.define("superuser-alex@hostsharing.net");
final var givenAssetUuid = assetRepo.findByIdentifier("vm1011").stream()
.filter(bi -> bi.getBookingItem().getProject().getCaption().equals("D-1000111 default project"))
.findAny().orElseThrow().getUuid();
.filter(bi -> bi.getBookingItem().getProject().getCaption().equals("D-1000111 default project"))
.findAny().orElseThrow().getUuid();
RestAssured // @formatter:off
.given()
@ -429,8 +431,23 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup
@Test
void globalAdmin_canPatchAllUpdatablePropertiesOfAsset() {
final var givenAsset = givenSomeTemporaryHostingAsset("2001", MANAGED_SERVER,
config("monit_max_ssd_usage", 80), config("monit_max_hdd_usage", 90), config("monit_max_cpu_usage", 90), config("monit_max_ram_usage", 70));
final var givenAsset = givenSomeTemporaryHostingAsset(() ->
HsHostingAssetEntity.builder()
.uuid(UUID.randomUUID())
.bookingItem(givenSomeNewBookingItem(
"D-1000111 default project",
HsBookingItemType.MANAGED_SERVER,
"temp ManagedServer"))
.type(MANAGED_SERVER)
.identifier("vm2001")
.caption("some test-asset")
.config(Map.ofEntries(
Map.<String, Object>entry("monit_max_ssd_usage", 80),
Map.<String, Object>entry("monit_max_hdd_usage", 90),
Map.<String, Object>entry("monit_max_cpu_usage", 90),
Map.<String, Object>entry("monit_max_ram_usage", 70)
))
.build());
final var alarmContactUuid = givenContact().getUuid();
RestAssured // @formatter:off