Compare commits

...

3 Commits

Author SHA1 Message Date
Michael Hoennig
d7a57fd112 add alarm contact to hosting asset 2024-06-20 15:24:58 +02:00
d157730de7 finalize PrivateCloud, Cloud- and ManagedServer and ManagedWebspace Billingtems and HostingAssets (#63)
Co-authored-by: Michael Hoennig <michael@hoennig.de>
Reviewed-on: #63
Reviewed-by: Marc Sandlus <marc.sandlus@hostsharing.net>
2024-06-20 11:03:59 +02:00
04d9b43301 BookingItem validity start date today (#62)
Co-authored-by: Michael Hoennig <michael@hoennig.de>
Reviewed-on: #62
Reviewed-by: Marc Sandlus <marc.sandlus@hostsharing.net>
2024-06-20 10:44:28 +02:00
38 changed files with 1214 additions and 424 deletions

View File

@ -8,7 +8,11 @@ import static java.lang.String.join;
public class MultiValidationException extends ValidationException {
private MultiValidationException(final List<String> violations) {
super("[\n" + join(",\n", violations) + "\n]");
super(
violations.size() > 1
? "[\n" + join(",\n", violations) + "\n]"
: "[" + join(",\n", violations) + "]"
);
}
public static void throwInvalid(final List<String> violations) {

View File

@ -16,6 +16,7 @@ import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBui
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
import java.util.function.BiConsumer;
@ -130,9 +131,8 @@ public class HsBookingItemController implements HsBookingItemsApi {
}
};
@SuppressWarnings("unchecked")
final BiConsumer<HsBookingItemInsertResource, HsBookingItemEntity> RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> {
entity.setValidity(toPostgresDateRange(resource.getValidFrom(), resource.getValidTo()));
entity.setValidity(toPostgresDateRange(LocalDate.now(), resource.getValidTo()));
entity.putResources(KeyValueMap.from(resource.getResources()));
};
}

View File

@ -17,8 +17,6 @@ import net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL;
import net.hostsharing.hsadminng.rbac.rbacobject.RbacObject;
import net.hostsharing.hsadminng.stringify.Stringify;
import net.hostsharing.hsadminng.stringify.Stringifyable;
import org.hibernate.annotations.NotFound;
import org.hibernate.annotations.NotFoundAction;
import org.hibernate.annotations.Type;
import jakarta.persistence.CascadeType;
@ -101,7 +99,7 @@ public class HsBookingItemEntity implements Stringifyable, RbacObject {
@Builder.Default
@Type(PostgreSQLRangeType.class)
@Column(name = "validity", columnDefinition = "daterange")
private Range<LocalDate> validity = Range.emptyRange(LocalDate.class);
private Range<LocalDate> validity = Range.closedInfinite(LocalDate.now());
@Column(name = "caption")
private String caption;

View File

@ -3,6 +3,7 @@ package net.hostsharing.hsadminng.hs.booking.item.validators;
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemEntity;
import net.hostsharing.hsadminng.hs.validation.HsEntityValidator;
import net.hostsharing.hsadminng.hs.validation.ValidatableProperty;
import org.apache.commons.lang3.BooleanUtils;
import java.util.Collection;
import java.util.List;
@ -59,19 +60,24 @@ public class HsBookingItemEntityValidator extends HsEntityValidator<HsBookingIte
final var totalValue = ofNullable(bookingItem.getSubBookingItems()).orElse(emptyList())
.stream()
.map(subItem -> propDef.getValue(subItem.getResources()))
.map(HsBookingItemEntityValidator::toNonNullInteger)
.map(HsBookingItemEntityValidator::convertBooleanToInteger)
.map(HsBookingItemEntityValidator::toIntegerWithDefault0)
.reduce(0, Integer::sum);
final var maxValue = getNonNullIntegerValue(propDef, bookingItem.getResources());
final var maxValue = getIntegerValueWithDefault0(propDef, bookingItem.getResources());
if (propDef.thresholdPercentage() != null ) {
return totalValue > (maxValue * propDef.thresholdPercentage() / 100)
? "%s' maximum total is %d%s, but actual total %s %d%s, which exceeds threshold of %d%%"
? "%s' maximum total is %d%s, but actual total %s is %d%s, which exceeds threshold of %d%%"
.formatted(propName, maxValue, propUnit, propName, totalValue, propUnit, propDef.thresholdPercentage())
: null;
} else {
return totalValue > maxValue
? "%s' maximum total is %d%s, but actual total %s %d%s"
? "%s' maximum total is %d%s, but actual total %s is %d%s"
.formatted(propName, maxValue, propUnit, propName, totalValue, propUnit)
: null;
}
}
private static Object convertBooleanToInteger(final Object value) {
return value instanceof Boolean ? BooleanUtils.toInteger((Boolean)value) : value;
}
}

View File

@ -1,7 +1,6 @@
package net.hostsharing.hsadminng.hs.booking.item.validators;
import static net.hostsharing.hsadminng.hs.validation.BooleanProperty.booleanProperty;
import static net.hostsharing.hsadminng.hs.validation.EnumerationProperty.enumerationProperty;
import static net.hostsharing.hsadminng.hs.validation.IntegerProperty.integerProperty;
@ -9,12 +8,21 @@ class HsCloudServerBookingItemValidator extends HsBookingItemEntityValidator {
HsCloudServerBookingItemValidator() {
super(
integerProperty("CPUs").min(1).max(32).required(),
integerProperty("RAM").unit("GB").min(1).max(128).required(),
integerProperty("SSD").unit("GB").min(25).max(1000).step(25).required(),
integerProperty("HDD").unit("GB").min(0).max(4000).step(250).withDefault(0),
integerProperty("Traffic").unit("GB").min(250).max(10000).step(250).required(),
// @formatter:off
booleanProperty("active") .withDefault(true),
integerProperty("CPUs") .min( 1) .max( 32) .required(),
integerProperty("RAM").unit("GB") .min( 1) .max( 128) .required(),
integerProperty("SSD").unit("GB") .min( 0) .max( 1000) .step(25).required(), // (1)
integerProperty("HDD").unit("GB") .min( 0) .max( 4000) .step(250).withDefault(0),
integerProperty("Traffic").unit("GB") .min(250) .max(10000) .step(250).required(),
enumerationProperty("SLA-Infrastructure").values("BASIC", "EXT8H", "EXT4H", "EXT2H").optional()
// @formatter:on
);
// (q) We do have pre-existing CloudServers without SSD, just HDD, thus SSD starts with min=0.
// TODO.impl: Validation that SSD+HDD is at minimum 25 GB is missing.
// e.g. validationGroup("SSD", "HDD").min(0);
}
}

View File

@ -1,18 +1,40 @@
package net.hostsharing.hsadminng.hs.booking.item.validators;
import static net.hostsharing.hsadminng.hs.validation.EnumerationProperty.enumerationProperty;
import static net.hostsharing.hsadminng.hs.validation.IntegerProperty.integerProperty;
class HsPrivateCloudBookingItemValidator extends HsBookingItemEntityValidator {
HsPrivateCloudBookingItemValidator() {
super(
integerProperty("CPUs").min(4).max(128).required().asTotalLimit(),
integerProperty("RAM").unit("GB").min(4).max(512).required().asTotalLimit(),
integerProperty("SSD").unit("GB").min(100).max(4000).step(25).required().asTotalLimit(),
integerProperty("HDD").unit("GB").min(0).max(16000).step(25).withDefault(0).asTotalLimit(),
integerProperty("Traffic").unit("GB").min(1000).max(40000).step(250).required().asTotalLimit(),
enumerationProperty("SLA-Infrastructure").values("BASIC", "EXT8H", "EXT4H", "EXT2H").withDefault("BASIC")
// @formatter:off
integerProperty("CPUs") .min( 1).max( 128).required().asTotalLimit(),
integerProperty("RAM").unit("GB") .min( 1).max( 512).required().asTotalLimit(),
integerProperty("SSD").unit("GB") .min( 25).max( 4000).step(25).required().asTotalLimit(),
integerProperty("HDD").unit("GB") .min( 0).max(16000).step(250).withDefault(0).asTotalLimit(),
integerProperty("Traffic").unit("GB") .min(250).max(40000).step(250).required().asTotalLimit(),
// Alternatively we could specify it similarly to "Multi" option but exclusively counting:
// integerProperty("Resource-Points") .min(4).max(100).required()
// .each("CPUs").countsAs(64)
// .each("RAM").countsAs(64)
// .each("SSD").countsAs(18)
// .each("HDD").countsAs(2)
// .each("Traffic").countsAs(1),
integerProperty("SLA-Infrastructure EXT8H") .min( 0).max( 20).withDefault(0).asTotalLimitFor("SLA-Infrastructure", "EXT8H"),
integerProperty("SLA-Infrastructure EXT4H") .min( 0).max( 20).withDefault(0).asTotalLimitFor("SLA-Infrastructure", "EXT4H"),
integerProperty("SLA-Infrastructure EXT2H") .min( 0).max( 20).withDefault(0).asTotalLimitFor("SLA-Infrastructure", "EXT2H"),
integerProperty("SLA-Platform EXT8H") .min( 0).max( 20).withDefault(0).asTotalLimitFor("SLA-Platform", "EXT8H"),
integerProperty("SLA-Platform EXT4H") .min( 0).max( 20).withDefault(0).asTotalLimitFor("SLA-Platform", "EXT4H"),
integerProperty("SLA-Platform EXT2H") .min( 0).max( 20).withDefault(0).asTotalLimitFor("SLA-Platform", "EXT2H"),
integerProperty("SLA-EMail") .min( 0).max( 20).withDefault(0).asTotalLimit(),
integerProperty("SLA-Maria") .min( 0).max( 20).withDefault(0).asTotalLimit(),
integerProperty("SLA-PgSQL") .min( 0).max( 20).withDefault(0).asTotalLimit(),
integerProperty("SLA-Office") .min( 0).max( 20).withDefault(0).asTotalLimit(),
integerProperty("SLA-Web") .min( 0).max( 20).withDefault(0).asTotalLimit()
// @formatter:on
);
}
}

View File

@ -1,5 +1,6 @@
package net.hostsharing.hsadminng.hs.hosting.asset;
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRepository;
import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.api.HsHostingAssetsApi;
import net.hostsharing.hsadminng.context.Context;
@ -34,6 +35,9 @@ public class HsHostingAssetController implements HsHostingAssetsApi {
@Autowired
private HsHostingAssetRepository assetRepo;
@Autowired
private HsBookingItemRepository bookingItemRepo;
@Override
@Transactional(readOnly = true)
public ResponseEntity<List<HsHostingAssetResource>> listAssets(
@ -124,6 +128,11 @@ public class HsHostingAssetController implements HsHostingAssetsApi {
final BiConsumer<HsHostingAssetInsertResource, HsHostingAssetEntity> RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> {
entity.putConfig(KeyValueMap.from(resource.getConfig()));
if (resource.getBookingItemUuid() != null) {
entity.setBookingItem(bookingItemRepo.findByUuid(resource.getBookingItemUuid())
.orElseThrow(() -> new EntityNotFoundException("ERROR: [400] bookingItemUuid %s not found".formatted(
resource.getBookingItemUuid()))));
}
if (resource.getParentAssetUuid() != null) {
entity.setParentAsset(assetRepo.findByUuid(resource.getParentAssetUuid())
.orElseThrow(() -> new EntityNotFoundException("ERROR: [400] parentAssetUuid %s not found".formatted(

View File

@ -8,6 +8,8 @@ import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemEntity;
import net.hostsharing.hsadminng.hs.hosting.contact.HsHostingContactEntity;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity;
import net.hostsharing.hsadminng.mapper.PatchableMapWrapper;
import net.hostsharing.hsadminng.rbac.rbacdef.RbacView;
import net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL;
@ -27,6 +29,7 @@ import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import jakarta.persistence.Transient;
import jakarta.persistence.Version;
@ -47,6 +50,7 @@ import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.UPDATE;
import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.ADMIN;
import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.AGENT;
import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.OWNER;
import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.REFERRER;
import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.TENANT;
import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL.directlyFetchedByDependsOnColumn;
import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.rbacViewFor;
@ -78,7 +82,7 @@ public class HsHostingAssetEntity implements Stringifyable, RbacObject {
@Version
private int version;
@ManyToOne(fetch = FetchType.LAZY)
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "bookingitemuuid")
private HsBookingItemEntity bookingItem;
@ -94,6 +98,10 @@ public class HsHostingAssetEntity implements Stringifyable, RbacObject {
@Enumerated(EnumType.STRING)
private HsHostingAssetType type;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "alarmcontactuuid")
private HsHostingContactEntity alarmContactUuid;
@OneToMany(cascade = CascadeType.REFRESH, orphanRemoval = true, fetch = FetchType.LAZY)
@JoinColumn(name="parentassetuuid", referencedColumnName="uuid")
private List<HsHostingAssetEntity> subHostingAssets;
@ -135,14 +143,13 @@ public class HsHostingAssetEntity implements Stringifyable, RbacObject {
return rbacViewFor("asset", HsHostingAssetEntity.class)
.withIdentityView(SQL.projection("identifier"))
.withRestrictedViewOrderBy(SQL.expression("identifier"))
.withUpdatableColumns("version", "caption", "config")
.withUpdatableColumns("version", "caption", "config", "assignedToAssetUuid", "alarmContactUUid")
.toRole(GLOBAL, ADMIN).grantPermission(INSERT) // TODO.impl: Why is this necessary to insert test data?
.importEntityAlias("bookingItem", HsBookingItemEntity.class, usingDefaultCase(),
dependsOnColumn("bookingItemUuid"),
directlyFetchedByDependsOnColumn(),
NULLABLE)
.toRole("bookingItem", AGENT).grantPermission(INSERT)
.importEntityAlias("parentAsset", HsHostingAssetEntity.class, usingDefaultCase(),
dependsOnColumn("parentAssetUuid"),
@ -155,6 +162,11 @@ public class HsHostingAssetEntity implements Stringifyable, RbacObject {
directlyFetchedByDependsOnColumn(),
NULLABLE)
.importEntityAlias("alarmContact", HsOfficeContactEntity.class, usingDefaultCase(),
dependsOnColumn("alarmContactUuid"),
directlyFetchedByDependsOnColumn(),
NULLABLE)
.createRole(OWNER, (with) -> {
with.incomingSuperRole("bookingItem", ADMIN);
with.incomingSuperRole("parentAsset", ADMIN);
@ -167,13 +179,15 @@ public class HsHostingAssetEntity implements Stringifyable, RbacObject {
})
.createSubRole(AGENT, (with) -> {
with.outgoingSubRole("assignedToAsset", TENANT);
with.outgoingSubRole("alarmContact", REFERRER);
})
.createSubRole(TENANT, (with) -> {
with.outgoingSubRole("bookingItem", TENANT);
with.outgoingSubRole("parentAsset", TENANT);
with.incomingSuperRole("alarmContact", ADMIN);
with.permission(SELECT);
})
.limitDiagramTo("asset", "bookingItem", "bookingItem.debitorRel", "parentAsset", "assignedToAsset", "global");
.limitDiagramTo("asset", "bookingItem", "bookingItem.debitorRel", "parentAsset", "assignedToAsset", "alarmContact", "global");
}
public static void main(String[] args) throws IOException {

View File

@ -65,11 +65,11 @@ public class HsHostingAssetEntityValidator extends HsEntityValidator<HsHostingAs
final var totalValue = ofNullable(hostingAsset.getSubHostingAssets()).orElse(emptyList())
.stream()
.map(subItem -> propDef.getValue(subItem.getConfig()))
.map(HsEntityValidator::toNonNullInteger)
.map(HsEntityValidator::toIntegerWithDefault0)
.reduce(0, Integer::sum);
final var maxValue = getNonNullIntegerValue(propDef, hostingAsset.getConfig());
final var maxValue = getIntegerValueWithDefault0(propDef, hostingAsset.getConfig());
return totalValue > maxValue
? "%s' maximum total is %d%s, but actual total is %s %d%s".formatted(
? "%s' maximum total is %d%s, but actual total %s is %d%s".formatted(
propName, maxValue, propUnit, propName, totalValue, propUnit)
: null;
}

View File

@ -1,18 +1,48 @@
package net.hostsharing.hsadminng.hs.hosting.asset.validators;
import static net.hostsharing.hsadminng.hs.validation.BooleanProperty.booleanProperty;
import static net.hostsharing.hsadminng.hs.validation.EnumerationProperty.enumerationProperty;
import static net.hostsharing.hsadminng.hs.validation.IntegerProperty.integerProperty;
class HsManagedServerHostingAssetValidator extends HsHostingAssetEntityValidator {
public HsManagedServerHostingAssetValidator() {
super(
integerProperty("monit_min_free_ssd").min(1).max(1000).optional(),
integerProperty("monit_min_free_hdd").min(1).max(4000).optional(),
integerProperty("monit_max_ssd_usage").unit("%").min(10).max(100).required(),
integerProperty("monit_max_hdd_usage").unit("%").min(10).max(100).optional(),
integerProperty("monit_max_cpu_usage").unit("%").min(10).max(100).required(),
integerProperty("monit_max_ram_usage").unit("%").min(10).max(100).required()
// TODO: stringProperty("monit_alarm_email").unit("GB").optional()
// monitoring
integerProperty("monit_max_cpu_usage").unit("%").min(10).max(100).withDefault(92),
integerProperty("monit_max_ram_usage").unit("%").min(10).max(100).withDefault(92),
integerProperty("monit_max_ssd_usage").unit("%").min(10).max(100).withDefault(98),
integerProperty("monit_min_free_ssd").min(1).max(1000).withDefault(5),
integerProperty("monit_max_hdd_usage").unit("%").min(10).max(100).withDefault(95),
integerProperty("monit_min_free_hdd").min(1).max(4000).withDefault(10),
// stringProperty("monit_alarm_email").unit("GB").optional() TODO.impl: via Contact?
// other settings
// booleanProperty("fastcgi_small").withDefault(false), TODO.spec: clarify Salt-Grains
// database software
booleanProperty("software-pgsql").withDefault(true),
booleanProperty("software-mariadb").withDefault(true),
// PHP
enumerationProperty("php-default").valuesFromProperties("software-php-").withDefault("8.2"),
booleanProperty("software-php-5.6").withDefault(false),
booleanProperty("software-php-7.0").withDefault(false),
booleanProperty("software-php-7.1").withDefault(false),
booleanProperty("software-php-7.2").withDefault(false),
booleanProperty("software-php-7.3").withDefault(false),
booleanProperty("software-php-7.4").withDefault(true),
booleanProperty("software-php-8.0").withDefault(false),
booleanProperty("software-php-8.1").withDefault(false),
booleanProperty("software-php-8.2").withDefault(true),
// other software
booleanProperty("software-postfix-tls-1.0").withDefault(false),
booleanProperty("software-dovecot-tls-1.0").withDefault(false),
booleanProperty("software-clamav").withDefault(true),
booleanProperty("software-collabora").withDefault(false),
booleanProperty("software-libreoffice").withDefault(false),
booleanProperty("software-imagemagick-ghostscript").withDefault(false)
);
}
}

View File

@ -0,0 +1,57 @@
package net.hostsharing.hsadminng.hs.hosting.contact;
import io.hypersistence.utils.hibernate.type.json.JsonType;
import lombok.*;
import lombok.experimental.FieldNameConstants;
import net.hostsharing.hsadminng.errors.DisplayName;
import net.hostsharing.hsadminng.rbac.rbacobject.RbacObject;
import net.hostsharing.hsadminng.stringify.Stringify;
import net.hostsharing.hsadminng.stringify.Stringifyable;
import org.hibernate.annotations.Type;
import jakarta.persistence.*;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import static net.hostsharing.hsadminng.stringify.Stringify.stringify;
@Entity
@Table(name = "hs_office_contact_rv")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@FieldNameConstants
@DisplayName("Contact")
public class HsHostingContactEntity implements Stringifyable, RbacObject {
private static Stringify<HsHostingContactEntity> toString = stringify(HsHostingContactEntity.class, "contact")
.withProp(HsHostingContactEntity.Fields.caption, HsHostingContactEntity::getCaption)
.withProp(HsHostingContactEntity.Fields.emailAddresses, HsHostingContactEntity::getEmailAddresses);
@Id
private UUID uuid;
@Version
private int version;
@Column(name = "caption")
private String caption;
@Builder.Default
@Setter(AccessLevel.NONE)
@Type(JsonType.class)
@Column(name = "emailaddresses")
private Map<String, String> emailAddresses = new HashMap<>();
@Override
public String toString() {
return toString.apply(this);
}
@Override
public String toShortString() {
return caption;
}
}

View File

@ -7,6 +7,8 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.Map;
import static java.util.Arrays.stream;
@Setter
public class EnumerationProperty extends ValidatableProperty<String> {
@ -30,9 +32,27 @@ public class EnumerationProperty extends ValidatableProperty<String> {
return this;
}
public void deferredInit(final ValidatableProperty<?>[] allProperties) {
if (deferredInit != null) {
if (this.values != null) {
throw new IllegalStateException("property " + toString() + " already has values");
}
this.values = deferredInit.apply(allProperties);
}
}
public ValidatableProperty<String> valuesFromProperties(final String propertyNamePrefix) {
this.deferredInit = (ValidatableProperty<?>[] allProperties) -> stream(allProperties)
.map(ValidatableProperty::propertyName)
.filter(name -> name.startsWith(propertyNamePrefix))
.map(name -> name.substring(propertyNamePrefix.length()))
.toArray(String[]::new);
return this;
}
@Override
protected void validate(final ArrayList<String> result, final String propValue, final Map<String, Object> props) {
if (Arrays.stream(values).noneMatch(v -> v.equals(propValue))) {
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

@ -16,6 +16,7 @@ public abstract class HsEntityValidator<E> {
public HsEntityValidator(final ValidatableProperty<?>... validators) {
propertyValidators = validators;
stream(propertyValidators).forEach(p -> p.deferredInit(propertyValidators));
}
protected static List<String> enrich(final String prefix, final List<String> messages) {
@ -59,18 +60,24 @@ public abstract class HsEntityValidator<E> {
.orElse(emptyList()));
}
protected static Integer getNonNullIntegerValue(final ValidatableProperty<?> prop, final Map<String, Object> propValues) {
protected static Integer getIntegerValueWithDefault0(final ValidatableProperty<?> prop, final Map<String, Object> propValues) {
final var value = prop.getValue(propValues);
if (value instanceof Integer) {
return (Integer) value;
}
if (value == null) {
return 0;
}
throw new IllegalArgumentException(prop.propertyName + " Integer value expected, but got " + value);
}
protected static Integer toNonNullInteger(final Object value) {
protected static Integer toIntegerWithDefault0(final Object value) {
if (value instanceof Integer) {
return (Integer) value;
}
throw new IllegalArgumentException("Integer value expected, but got " + value);
if (value == null) {
return 0;
}
throw new IllegalArgumentException("Integer value (or null) expected, but got " + value);
}
}

View File

@ -19,6 +19,7 @@ import java.util.function.Function;
import static java.lang.Boolean.FALSE;
import static java.lang.Boolean.TRUE;
import static java.util.Collections.emptyList;
import static java.util.Optional.ofNullable;
@RequiredArgsConstructor
public abstract class ValidatableProperty<T> {
@ -31,6 +32,7 @@ public abstract class ValidatableProperty<T> {
private final String[] keyOrder;
private Boolean required;
private T defaultValue;
protected Function<ValidatableProperty<?>[], T[]> deferredInit;
private boolean isTotalsValidator = false;
@JsonIgnore
private List<Function<HsBookingItemEntity, List<String>>> asTotalLimitValidators; // TODO.impl: move to BookingItemIntegerProperty
@ -57,11 +59,38 @@ public abstract class ValidatableProperty<T> {
return this;
}
public void deferredInit(final ValidatableProperty<?>[] allProperties) {
}
public ValidatableProperty<T> asTotalLimit() {
isTotalsValidator = true;
return this;
}
public ValidatableProperty<T> asTotalLimitFor(final String propertyName, final String propertyValue) {
if (asTotalLimitValidators == null) {
asTotalLimitValidators = new ArrayList<>();
}
final TriFunction<HsBookingItemEntity, IntegerProperty, Integer, List<String>> validator =
(final HsBookingItemEntity entity, final IntegerProperty prop, final Integer factor) -> {
final var total = entity.getSubBookingItems().stream()
.map(server -> server.getResources().get(propertyName))
.filter(propertyValue::equals)
.count();
final long limitingValue = ofNullable(prop.getValue(entity.getResources())).orElse(0);
if (total > factor*limitingValue) {
return List.of(
prop.propertyName() + " maximum total is " + (factor*limitingValue) + ", but actual total for " + propertyName + "=" + propertyValue + " is " + total
);
}
return emptyList();
};
asTotalLimitValidators.add((final HsBookingItemEntity entity) -> validator.apply(entity, (IntegerProperty)this, 1));
return this;
}
public String propertyName() {
return propertyName;
}

View File

@ -62,10 +62,6 @@ components:
minLength: 3
maxLength: 80
nullable: false
validFrom:
type: string
format: date
nullable: false
validTo:
type: string
format: date

View File

@ -0,0 +1,63 @@
### rbac bookingItem
This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manually.
```mermaid
%%{init:{'flowchart':{'htmlLabels':false}}}%%
flowchart TB
subgraph bookingItem["`**bookingItem**`"]
direction TB
style bookingItem fill:#dd4901,stroke:#274d6e,stroke-width:8px
subgraph bookingItem:roles[ ]
style bookingItem:roles fill:#dd4901,stroke:white
role:bookingItem:OWNER[[bookingItem:OWNER]]
role:bookingItem:ADMIN[[bookingItem:ADMIN]]
role:bookingItem:AGENT[[bookingItem:AGENT]]
role:bookingItem:TENANT[[bookingItem:TENANT]]
end
subgraph bookingItem:permissions[ ]
style bookingItem:permissions fill:#dd4901,stroke:white
perm:bookingItem:INSERT{{bookingItem:INSERT}}
perm:bookingItem:DELETE{{bookingItem:DELETE}}
perm:bookingItem:UPDATE{{bookingItem:UPDATE}}
perm:bookingItem:SELECT{{bookingItem:SELECT}}
end
end
subgraph project["`**project**`"]
direction TB
style project fill:#99bcdb,stroke:#274d6e,stroke-width:8px
subgraph project:roles[ ]
style project:roles fill:#99bcdb,stroke:white
role:project:OWNER[[project:OWNER]]
role:project:ADMIN[[project:ADMIN]]
role:project:AGENT[[project:AGENT]]
role:project:TENANT[[project:TENANT]]
end
end
%% granting roles to roles
role:project:OWNER -.-> role:project:ADMIN
role:project:ADMIN -.-> role:project:AGENT
role:project:AGENT -.-> role:project:TENANT
role:project:AGENT ==> role:bookingItem:OWNER
role:bookingItem:OWNER ==> role:bookingItem:ADMIN
role:bookingItem:ADMIN ==> role:bookingItem:AGENT
role:bookingItem:AGENT ==> role:bookingItem:TENANT
role:bookingItem:TENANT ==> role:project:TENANT
%% granting permissions to roles
role:global:ADMIN ==> perm:bookingItem:INSERT
role:global:ADMIN ==> perm:bookingItem:DELETE
role:project:ADMIN ==> perm:bookingItem:INSERT
role:bookingItem:ADMIN ==> perm:bookingItem:UPDATE
role:bookingItem:TENANT ==> perm:bookingItem:SELECT
```

View File

@ -0,0 +1,277 @@
--liquibase formatted sql
-- This code generated was by RbacViewPostgresGenerator, do not amend manually.
-- ============================================================================
--changeset hs-booking-item-rbac-OBJECT:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
call generateRelatedRbacObject('hs_booking_item');
--//
-- ============================================================================
--changeset hs-booking-item-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
call generateRbacRoleDescriptors('hsBookingItem', 'hs_booking_item');
--//
-- ============================================================================
--changeset hs-booking-item-rbac-insert-trigger:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
/*
Creates the roles, grants and permission for the AFTER INSERT TRIGGER.
*/
create or replace procedure buildRbacSystemForHsBookingItem(
NEW hs_booking_item
)
language plpgsql as $$
declare
newProject hs_booking_project;
newParentItem hs_booking_item;
begin
call enterTriggerForObjectUuid(NEW.uuid);
SELECT * FROM hs_booking_project WHERE uuid = NEW.projectUuid INTO newProject;
SELECT * FROM hs_booking_item WHERE uuid = NEW.parentItemUuid INTO newParentItem;
perform createRoleWithGrants(
hsBookingItemOWNER(NEW),
incomingSuperRoles => array[
hsBookingItemAGENT(newParentItem),
hsBookingProjectAGENT(newProject)]
);
perform createRoleWithGrants(
hsBookingItemADMIN(NEW),
permissions => array['UPDATE'],
incomingSuperRoles => array[hsBookingItemOWNER(NEW)]
);
perform createRoleWithGrants(
hsBookingItemAGENT(NEW),
incomingSuperRoles => array[hsBookingItemADMIN(NEW)]
);
perform createRoleWithGrants(
hsBookingItemTENANT(NEW),
permissions => array['SELECT'],
incomingSuperRoles => array[hsBookingItemAGENT(NEW)],
outgoingSubRoles => array[
hsBookingItemTENANT(newParentItem),
hsBookingProjectTENANT(newProject)]
);
call grantPermissionToRole(createPermission(NEW.uuid, 'DELETE'), globalAdmin());
call leaveTriggerForObjectUuid(NEW.uuid);
end; $$;
/*
AFTER INSERT TRIGGER to create the role+grant structure for a new hs_booking_item row.
*/
create or replace function insertTriggerForHsBookingItem_tf()
returns trigger
language plpgsql
strict as $$
begin
call buildRbacSystemForHsBookingItem(NEW);
return NEW;
end; $$;
create trigger insertTriggerForHsBookingItem_tg
after insert on hs_booking_item
for each row
execute procedure insertTriggerForHsBookingItem_tf();
--//
-- ============================================================================
--changeset hs-booking-item-rbac-GRANTING-INSERT-PERMISSION:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
-- granting INSERT permission to global ----------------------------
/*
Grants INSERT INTO hs_booking_item permissions to specified role of pre-existing global rows.
*/
do language plpgsql $$
declare
row global;
begin
call defineContext('create INSERT INTO hs_booking_item permissions for pre-exising global rows');
FOR row IN SELECT * FROM global
-- unconditional for all rows in that table
LOOP
call grantPermissionToRole(
createPermission(row.uuid, 'INSERT', 'hs_booking_item'),
globalADMIN());
END LOOP;
end;
$$;
/**
Grants hs_booking_item INSERT permission to specified role of new global rows.
*/
create or replace function new_hs_booking_item_grants_insert_to_global_tf()
returns trigger
language plpgsql
strict as $$
begin
-- unconditional for all rows in that table
call grantPermissionToRole(
createPermission(NEW.uuid, 'INSERT', 'hs_booking_item'),
globalADMIN());
-- end.
return NEW;
end; $$;
-- z_... is to put it at the end of after insert triggers, to make sure the roles exist
create trigger z_new_hs_booking_item_grants_insert_to_global_tg
after insert on global
for each row
execute procedure new_hs_booking_item_grants_insert_to_global_tf();
-- granting INSERT permission to hs_booking_project ----------------------------
/*
Grants INSERT INTO hs_booking_item permissions to specified role of pre-existing hs_booking_project rows.
*/
do language plpgsql $$
declare
row hs_booking_project;
begin
call defineContext('create INSERT INTO hs_booking_item permissions for pre-exising hs_booking_project rows');
FOR row IN SELECT * FROM hs_booking_project
-- unconditional for all rows in that table
LOOP
call grantPermissionToRole(
createPermission(row.uuid, 'INSERT', 'hs_booking_item'),
hsBookingProjectADMIN(row));
END LOOP;
end;
$$;
/**
Grants hs_booking_item INSERT permission to specified role of new hs_booking_project rows.
*/
create or replace function new_hs_booking_item_grants_insert_to_hs_booking_project_tf()
returns trigger
language plpgsql
strict as $$
begin
-- unconditional for all rows in that table
call grantPermissionToRole(
createPermission(NEW.uuid, 'INSERT', 'hs_booking_item'),
hsBookingProjectADMIN(NEW));
-- end.
return NEW;
end; $$;
-- z_... is to put it at the end of after insert triggers, to make sure the roles exist
create trigger z_new_hs_booking_item_grants_insert_to_hs_booking_project_tg
after insert on hs_booking_project
for each row
execute procedure new_hs_booking_item_grants_insert_to_hs_booking_project_tf();
-- granting INSERT permission to hs_booking_item ----------------------------
-- Granting INSERT INTO hs_hosting_asset permissions to specified role of pre-existing hs_hosting_asset rows slipped,
-- because there cannot yet be any pre-existing rows in the same table yet.
/**
Grants hs_booking_item INSERT permission to specified role of new hs_booking_item rows.
*/
create or replace function new_hs_booking_item_grants_insert_to_hs_booking_item_tf()
returns trigger
language plpgsql
strict as $$
begin
-- unconditional for all rows in that table
call grantPermissionToRole(
createPermission(NEW.uuid, 'INSERT', 'hs_booking_item'),
hsBookingItemADMIN(NEW));
-- end.
return NEW;
end; $$;
-- z_... is to put it at the end of after insert triggers, to make sure the roles exist
create trigger z_new_hs_booking_item_grants_insert_to_hs_booking_item_tg
after insert on hs_booking_item
for each row
execute procedure new_hs_booking_item_grants_insert_to_hs_booking_item_tf();
-- ============================================================================
--changeset hs_booking_item-rbac-CHECKING-INSERT-PERMISSION:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
/**
Checks if the user respectively the assumed roles are allowed to insert a row to hs_booking_item.
*/
create or replace function hs_booking_item_insert_permission_check_tf()
returns trigger
language plpgsql as $$
declare
superObjectUuid uuid;
begin
-- check INSERT INSERT if global ADMIN
if isGlobalAdmin() then
return NEW;
end if;
-- check INSERT permission via direct foreign key: NEW.projectUuid
if hasInsertPermission(NEW.projectUuid, 'hs_booking_item') then
return NEW;
end if;
-- check INSERT permission via direct foreign key: NEW.parentItemUuid
if hasInsertPermission(NEW.parentItemUuid, 'hs_booking_item') then
return NEW;
end if;
raise exception '[403] insert into hs_booking_item values(%) not allowed for current subjects % (%)',
NEW, currentSubjects(), currentSubjectsUuids();
end; $$;
create trigger hs_booking_item_insert_permission_check_tg
before insert on hs_booking_item
for each row
execute procedure hs_booking_item_insert_permission_check_tf();
--//
-- ============================================================================
--changeset hs-booking-item-rbac-IDENTITY-VIEW:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
call generateRbacIdentityViewFromProjection('hs_booking_item',
$idName$
caption
$idName$);
--//
-- ============================================================================
--changeset hs-booking-item-rbac-RESTRICTED-VIEW:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
call generateRbacRestrictedView('hs_booking_item',
$orderBy$
validity
$orderBy$,
$updates$
version = new.version,
caption = new.caption,
validity = new.validity,
resources = new.resources
$updates$);
--//

View File

@ -33,6 +33,7 @@ create table if not exists hs_hosting_asset
identifier varchar(80) not null,
caption varchar(80),
config jsonb not null,
alarmContactUuid uuid null references hs_office_contact(uuid) initially deferred,
constraint chk_hs_hosting_asset_has_booking_item_or_parent_asset
check (bookingItemUuid is not null or parentAssetUuid is not null)

View File

@ -1,72 +0,0 @@
### rbac asset inCaseOf:CLOUD_SERVER
This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manually.
```mermaid
%%{init:{'flowchart':{'htmlLabels':false}}}%%
flowchart TB
subgraph asset["`**asset**`"]
direction TB
style asset fill:#dd4901,stroke:#274d6e,stroke-width:8px
subgraph asset:roles[ ]
style asset:roles fill:#dd4901,stroke:white
role:asset:OWNER[[asset:OWNER]]
role:asset:ADMIN[[asset:ADMIN]]
role:asset:TENANT[[asset:TENANT]]
end
subgraph asset:permissions[ ]
style asset:permissions fill:#dd4901,stroke:white
perm:asset:INSERT{{asset:INSERT}}
perm:asset:DELETE{{asset:DELETE}}
perm:asset:UPDATE{{asset:UPDATE}}
perm:asset:SELECT{{asset:SELECT}}
end
end
subgraph bookingItem["`**bookingItem**`"]
direction TB
style bookingItem fill:#99bcdb,stroke:#274d6e,stroke-width:8px
subgraph bookingItem:roles[ ]
style bookingItem:roles fill:#99bcdb,stroke:white
role:bookingItem:OWNER[[bookingItem:OWNER]]
role:bookingItem:ADMIN[[bookingItem:ADMIN]]
role:bookingItem:AGENT[[bookingItem:AGENT]]
role:bookingItem:TENANT[[bookingItem:TENANT]]
end
end
subgraph parentServer["`**parentServer**`"]
direction TB
style parentServer fill:#99bcdb,stroke:#274d6e,stroke-width:8px
subgraph parentServer:roles[ ]
style parentServer:roles fill:#99bcdb,stroke:white
role:parentServer:ADMIN[[parentServer:ADMIN]]
end
end
%% granting roles to roles
role:bookingItem:OWNER -.-> role:bookingItem:ADMIN
role:bookingItem:ADMIN -.-> role:bookingItem:AGENT
role:bookingItem:AGENT -.-> role:bookingItem:TENANT
role:bookingItem:ADMIN ==> role:asset:OWNER
role:asset:OWNER ==> role:asset:ADMIN
role:asset:ADMIN ==> role:asset:TENANT
role:asset:TENANT ==> role:bookingItem:TENANT
%% granting permissions to roles
role:bookingItem:AGENT ==> perm:asset:INSERT
role:asset:OWNER ==> perm:asset:DELETE
role:asset:ADMIN ==> perm:asset:UPDATE
role:asset:TENANT ==> perm:asset:SELECT
role:global:ADMIN ==> perm:asset:INSERT
```

View File

@ -1,72 +0,0 @@
### rbac asset inCaseOf:MANAGED_SERVER
This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manually.
```mermaid
%%{init:{'flowchart':{'htmlLabels':false}}}%%
flowchart TB
subgraph asset["`**asset**`"]
direction TB
style asset fill:#dd4901,stroke:#274d6e,stroke-width:8px
subgraph asset:roles[ ]
style asset:roles fill:#dd4901,stroke:white
role:asset:OWNER[[asset:OWNER]]
role:asset:ADMIN[[asset:ADMIN]]
role:asset:TENANT[[asset:TENANT]]
end
subgraph asset:permissions[ ]
style asset:permissions fill:#dd4901,stroke:white
perm:asset:INSERT{{asset:INSERT}}
perm:asset:DELETE{{asset:DELETE}}
perm:asset:UPDATE{{asset:UPDATE}}
perm:asset:SELECT{{asset:SELECT}}
end
end
subgraph bookingItem["`**bookingItem**`"]
direction TB
style bookingItem fill:#99bcdb,stroke:#274d6e,stroke-width:8px
subgraph bookingItem:roles[ ]
style bookingItem:roles fill:#99bcdb,stroke:white
role:bookingItem:OWNER[[bookingItem:OWNER]]
role:bookingItem:ADMIN[[bookingItem:ADMIN]]
role:bookingItem:AGENT[[bookingItem:AGENT]]
role:bookingItem:TENANT[[bookingItem:TENANT]]
end
end
subgraph parentServer["`**parentServer**`"]
direction TB
style parentServer fill:#99bcdb,stroke:#274d6e,stroke-width:8px
subgraph parentServer:roles[ ]
style parentServer:roles fill:#99bcdb,stroke:white
role:parentServer:ADMIN[[parentServer:ADMIN]]
end
end
%% granting roles to roles
role:bookingItem:OWNER -.-> role:bookingItem:ADMIN
role:bookingItem:ADMIN -.-> role:bookingItem:AGENT
role:bookingItem:AGENT -.-> role:bookingItem:TENANT
role:bookingItem:ADMIN ==> role:asset:OWNER
role:asset:OWNER ==> role:asset:ADMIN
role:asset:ADMIN ==> role:asset:TENANT
role:asset:TENANT ==> role:bookingItem:TENANT
%% granting permissions to roles
role:bookingItem:AGENT ==> perm:asset:INSERT
role:asset:OWNER ==> perm:asset:DELETE
role:asset:ADMIN ==> perm:asset:UPDATE
role:asset:TENANT ==> perm:asset:SELECT
role:global:ADMIN ==> perm:asset:INSERT
```

View File

@ -1,73 +0,0 @@
### rbac asset inCaseOf:MANAGED_WEBSPACE
This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manually.
```mermaid
%%{init:{'flowchart':{'htmlLabels':false}}}%%
flowchart TB
subgraph asset["`**asset**`"]
direction TB
style asset fill:#dd4901,stroke:#274d6e,stroke-width:8px
subgraph asset:roles[ ]
style asset:roles fill:#dd4901,stroke:white
role:asset:OWNER[[asset:OWNER]]
role:asset:ADMIN[[asset:ADMIN]]
role:asset:TENANT[[asset:TENANT]]
end
subgraph asset:permissions[ ]
style asset:permissions fill:#dd4901,stroke:white
perm:asset:INSERT{{asset:INSERT}}
perm:asset:DELETE{{asset:DELETE}}
perm:asset:UPDATE{{asset:UPDATE}}
perm:asset:SELECT{{asset:SELECT}}
end
end
subgraph bookingItem["`**bookingItem**`"]
direction TB
style bookingItem fill:#99bcdb,stroke:#274d6e,stroke-width:8px
subgraph bookingItem:roles[ ]
style bookingItem:roles fill:#99bcdb,stroke:white
role:bookingItem:OWNER[[bookingItem:OWNER]]
role:bookingItem:ADMIN[[bookingItem:ADMIN]]
role:bookingItem:AGENT[[bookingItem:AGENT]]
role:bookingItem:TENANT[[bookingItem:TENANT]]
end
end
subgraph parentServer["`**parentServer**`"]
direction TB
style parentServer fill:#99bcdb,stroke:#274d6e,stroke-width:8px
subgraph parentServer:roles[ ]
style parentServer:roles fill:#99bcdb,stroke:white
role:parentServer:ADMIN[[parentServer:ADMIN]]
end
end
%% granting roles to roles
role:bookingItem:OWNER -.-> role:bookingItem:ADMIN
role:bookingItem:ADMIN -.-> role:bookingItem:AGENT
role:bookingItem:AGENT -.-> role:bookingItem:TENANT
role:bookingItem:ADMIN ==> role:asset:OWNER
role:asset:OWNER ==> role:asset:ADMIN
role:asset:ADMIN ==> role:asset:TENANT
role:asset:TENANT ==> role:bookingItem:TENANT
%% granting permissions to roles
role:bookingItem:AGENT ==> perm:asset:INSERT
role:parentServer:ADMIN ==> perm:asset:INSERT
role:asset:OWNER ==> perm:asset:DELETE
role:asset:ADMIN ==> perm:asset:UPDATE
role:asset:TENANT ==> perm:asset:SELECT
role:global:ADMIN ==> perm:asset:INSERT
```

View File

@ -6,6 +6,19 @@ This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manua
%%{init:{'flowchart':{'htmlLabels':false}}}%%
flowchart TB
subgraph alarmContact["`**alarmContact**`"]
direction TB
style alarmContact fill:#99bcdb,stroke:#274d6e,stroke-width:8px
subgraph alarmContact:roles[ ]
style alarmContact:roles fill:#99bcdb,stroke:white
role:alarmContact:OWNER[[alarmContact:OWNER]]
role:alarmContact:ADMIN[[alarmContact:ADMIN]]
role:alarmContact:REFERRER[[alarmContact:REFERRER]]
end
end
subgraph asset["`**asset**`"]
direction TB
style asset fill:#dd4901,stroke:#274d6e,stroke-width:8px
@ -25,6 +38,7 @@ subgraph asset["`**asset**`"]
perm:asset:INSERT{{asset:INSERT}}
perm:asset:DELETE{{asset:DELETE}}
perm:asset:UPDATE{{asset:UPDATE}}
perm:asset:SELECT{{asset:SELECT}}
end
end
@ -39,16 +53,58 @@ subgraph assignedToAsset["`**assignedToAsset**`"]
end
end
subgraph bookingItem["`**bookingItem**`"]
direction TB
style bookingItem fill:#99bcdb,stroke:#274d6e,stroke-width:8px
subgraph bookingItem:roles[ ]
style bookingItem:roles fill:#99bcdb,stroke:white
role:bookingItem:OWNER[[bookingItem:OWNER]]
role:bookingItem:ADMIN[[bookingItem:ADMIN]]
role:bookingItem:AGENT[[bookingItem:AGENT]]
role:bookingItem:TENANT[[bookingItem:TENANT]]
end
end
subgraph parentAsset["`**parentAsset**`"]
direction TB
style parentAsset fill:#99bcdb,stroke:#274d6e,stroke-width:8px
subgraph parentAsset:roles[ ]
style parentAsset:roles fill:#99bcdb,stroke:white
role:parentAsset:ADMIN[[parentAsset:ADMIN]]
role:parentAsset:AGENT[[parentAsset:AGENT]]
role:parentAsset:TENANT[[parentAsset:TENANT]]
end
end
%% granting roles to roles
role:bookingItem:OWNER -.-> role:bookingItem:ADMIN
role:bookingItem:ADMIN -.-> role:bookingItem:AGENT
role:bookingItem:AGENT -.-> role:bookingItem:TENANT
role:global:ADMIN -.-> role:alarmContact:OWNER
role:alarmContact:OWNER -.-> role:alarmContact:ADMIN
role:alarmContact:ADMIN -.-> role:alarmContact:REFERRER
role:bookingItem:ADMIN ==> role:asset:OWNER
role:parentAsset:ADMIN ==> role:asset:OWNER
role:asset:OWNER ==> role:asset:ADMIN
role:bookingItem:AGENT ==> role:asset:ADMIN
role:parentAsset:AGENT ==> role:asset:ADMIN
role:asset:ADMIN ==> role:asset:AGENT
role:asset:AGENT ==> role:assignedToAsset:TENANT
role:asset:AGENT ==> role:alarmContact:REFERRER
role:asset:AGENT ==> role:asset:TENANT
role:assignedToAsset:TENANT ==> role:asset:TENANT
role:asset:TENANT ==> role:bookingItem:TENANT
role:asset:TENANT ==> role:parentAsset:TENANT
role:alarmContact:ADMIN ==> role:asset:TENANT
%% granting permissions to roles
role:global:ADMIN ==> perm:asset:INSERT
role:parentAsset:ADMIN ==> perm:asset:INSERT
role:asset:OWNER ==> perm:asset:DELETE
role:asset:ADMIN ==> perm:asset:UPDATE
role:asset:TENANT ==> perm:asset:SELECT
```

View File

@ -32,6 +32,7 @@ create or replace procedure buildRbacSystemForHsHostingAsset(
declare
newBookingItem hs_booking_item;
newAssignedToAsset hs_hosting_asset;
newAlarmContact hs_office_contact;
newParentAsset hs_hosting_asset;
begin
@ -41,6 +42,8 @@ begin
SELECT * FROM hs_hosting_asset WHERE uuid = NEW.assignedToAssetUuid INTO newAssignedToAsset;
SELECT * FROM hs_office_contact WHERE uuid = NEW.alarmContactUuid INTO newAlarmContact;
SELECT * FROM hs_hosting_asset WHERE uuid = NEW.parentAssetUuid INTO newParentAsset;
perform createRoleWithGrants(
@ -63,14 +66,17 @@ begin
perform createRoleWithGrants(
hsHostingAssetAGENT(NEW),
incomingSuperRoles => array[hsHostingAssetADMIN(NEW)],
outgoingSubRoles => array[hsHostingAssetTENANT(newAssignedToAsset)]
outgoingSubRoles => array[
hsHostingAssetTENANT(newAssignedToAsset),
hsOfficeContactREFERRER(newAlarmContact)]
);
perform createRoleWithGrants(
hsHostingAssetTENANT(NEW),
permissions => array['SELECT'],
incomingSuperRoles => array[
hsHostingAssetAGENT(NEW),
hsHostingAssetTENANT(newAssignedToAsset)],
hsOfficeContactADMIN(newAlarmContact)],
outgoingSubRoles => array[
hsBookingItemTENANT(newBookingItem),
hsHostingAssetTENANT(newParentAsset)]
@ -99,6 +105,47 @@ execute procedure insertTriggerForHsHostingAsset_tf();
--//
-- ============================================================================
--changeset hs-hosting-asset-rbac-update-trigger:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
/*
Called from the AFTER UPDATE TRIGGER to re-wire the grants.
*/
create or replace procedure updateRbacRulesForHsHostingAsset(
OLD hs_hosting_asset,
NEW hs_hosting_asset
)
language plpgsql as $$
begin
if NEW.assignedToAssetUuid is distinct from OLD.assignedToAssetUuid then
delete from rbacgrants g where g.grantedbytriggerof = OLD.uuid;
call buildRbacSystemForHsHostingAsset(NEW);
end if;
end; $$;
/*
AFTER INSERT TRIGGER to re-wire the grant structure for a new hs_hosting_asset row.
*/
create or replace function updateTriggerForHsHostingAsset_tf()
returns trigger
language plpgsql
strict as $$
begin
call updateRbacRulesForHsHostingAsset(OLD, NEW);
return NEW;
end; $$;
create trigger updateTriggerForHsHostingAsset_tg
after update on hs_hosting_asset
for each row
execute procedure updateTriggerForHsHostingAsset_tf();
--//
-- ============================================================================
--changeset hs-hosting-asset-rbac-GRANTING-INSERT-PERMISSION:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
@ -146,49 +193,6 @@ create trigger z_new_hs_hosting_asset_grants_insert_to_global_tg
for each row
execute procedure new_hs_hosting_asset_grants_insert_to_global_tf();
-- granting INSERT permission to hs_booking_item ----------------------------
/*
Grants INSERT INTO hs_hosting_asset permissions to specified role of pre-existing hs_booking_item rows.
*/
do language plpgsql $$
declare
row hs_booking_item;
begin
call defineContext('create INSERT INTO hs_hosting_asset permissions for pre-exising hs_booking_item rows');
FOR row IN SELECT * FROM hs_booking_item
-- unconditional for all rows in that table
LOOP
call grantPermissionToRole(
createPermission(row.uuid, 'INSERT', 'hs_hosting_asset'),
hsBookingItemAGENT(row));
END LOOP;
end;
$$;
/**
Grants hs_hosting_asset INSERT permission to specified role of new hs_booking_item rows.
*/
create or replace function new_hs_hosting_asset_grants_insert_to_hs_booking_item_tf()
returns trigger
language plpgsql
strict as $$
begin
-- unconditional for all rows in that table
call grantPermissionToRole(
createPermission(NEW.uuid, 'INSERT', 'hs_hosting_asset'),
hsBookingItemAGENT(NEW));
-- end.
return NEW;
end; $$;
-- z_... is to put it at the end of after insert triggers, to make sure the roles exist
create trigger z_new_hs_hosting_asset_grants_insert_to_hs_booking_item_tg
after insert on hs_booking_item
for each row
execute procedure new_hs_hosting_asset_grants_insert_to_hs_booking_item_tf();
-- granting INSERT permission to hs_hosting_asset ----------------------------
-- Granting INSERT INTO hs_hosting_asset permissions to specified role of pre-existing hs_hosting_asset rows slipped,
@ -234,10 +238,6 @@ begin
if isGlobalAdmin() then
return NEW;
end if;
-- check INSERT permission via direct foreign key: NEW.bookingItemUuid
if hasInsertPermission(NEW.bookingItemUuid, 'hs_hosting_asset') then
return NEW;
end if;
-- check INSERT permission via direct foreign key: NEW.parentAssetUuid
if hasInsertPermission(NEW.parentAssetUuid, 'hs_hosting_asset') then
return NEW;
@ -275,7 +275,9 @@ call generateRbacRestrictedView('hs_hosting_asset',
$updates$
version = new.version,
caption = new.caption,
config = new.config
config = new.config,
assignedToAssetUuid = new.assignedToAssetUuid,
alarmContactUUid = new.alarmContactUUid
$updates$);
--//

View File

@ -144,13 +144,16 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup
.contentType(ContentType.JSON)
.body("""
{
"projectUuid": "%s",
"projectUuid": "{projectUuid}",
"type": "MANAGED_SERVER",
"caption": "some new booking",
"resources": { "CPUs": 12, "RAM": 4, "SSD": 100, "Traffic": 250 },
"validFrom": "2022-10-13"
"validTo": "{validTo}",
"resources": { "CPUs": 12, "RAM": 4, "SSD": 100, "Traffic": 250 }
}
""".formatted(givenProject.getUuid()))
"""
.replace("{projectUuid}", givenProject.getUuid().toString())
.replace("{validTo}", LocalDate.now().plusMonths(1).toString())
)
.port(port)
.when()
.post("http://localhost/api/hs/booking/items")
@ -161,11 +164,14 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup
{
"type": "MANAGED_SERVER",
"caption": "some new booking",
"validFrom": "2022-10-13",
"validTo": null,
"validFrom": "{today}",
"validTo": "{todayPlus1Month}",
"resources": { "CPUs": 12, "SSD": 100, "Traffic": 250 }
}
"""))
"""
.replace("{today}", LocalDate.now().toString())
.replace("{todayPlus1Month}", LocalDate.now().plusMonths(1).toString()))
)
.header("Location", matchesRegex("http://localhost:[1-9][0-9]*/api/hs/booking/items/[^/]*"))
.extract().header("Location"); // @formatter:on
@ -236,7 +242,6 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup
@Test
@Order(3)
// TODO.impl: For unknown reason this test fails in about 50%, not finding the uuid (404).
void projectAdmin_canGetRelatedBookingItem() {
context.define("superuser-alex@hostsharing.net");
final var givenBookingItem = bookingItemRepo.findByCaption("separate ManagedServer").stream()

View File

@ -0,0 +1,171 @@
package net.hostsharing.hsadminng.hs.booking.item;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectEntity;
import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectRepository;
import net.hostsharing.hsadminng.mapper.Mapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.SynchronizationType;
import java.time.LocalDate;
import java.util.Map;
import java.util.UUID;
import static net.hostsharing.hsadminng.rbac.test.JsonMatcher.lenientlyEquals;
import static org.hamcrest.Matchers.matchesRegex;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(HsBookingItemController.class)
@Import(Mapper.class)
@RunWith(SpringRunner.class)
class HsBookingItemControllerRestTest {
@Autowired
MockMvc mockMvc;
@MockBean
Context contextMock;
@Mock
EntityManager em;
@MockBean
EntityManagerFactory emf;
@MockBean
HsBookingProjectRepository bookingProjectRepo;
@MockBean
HsBookingItemRepository bookingItemRepo;
@BeforeEach
void init() {
when(emf.createEntityManager()).thenReturn(em);
when(emf.createEntityManager(any(Map.class))).thenReturn(em);
when(emf.createEntityManager(any(SynchronizationType.class))).thenReturn(em);
when(emf.createEntityManager(any(SynchronizationType.class), any(Map.class))).thenReturn(em);
}
@Nested
class AddBookingItem {
@Test
void globalAdmin_canAddValidBookingItem() throws Exception {
final var givenProjectUuid = UUID.randomUUID();
// given
when(em.find(HsBookingProjectEntity.class, givenProjectUuid)).thenAnswer(invocation ->
HsBookingProjectEntity.builder()
.uuid(invocation.getArgument(1))
.build()
);
when(bookingItemRepo.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
// when
mockMvc.perform(MockMvcRequestBuilders
.post("/api/hs/booking/items")
.header("current-user", "superuser-alex@hostsharing.net")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"projectUuid": "{projectUuid}",
"type": "MANAGED_SERVER",
"caption": "some new booking",
"validTo": "{validTo}",
"garbage": "should not be accepted",
"resources": { "CPUs": 12, "RAM": 4, "SSD": 100, "Traffic": 250 }
}
"""
.replace("{projectUuid}", givenProjectUuid.toString())
.replace("{validTo}", LocalDate.now().plusMonths(1).toString())
)
.accept(MediaType.APPLICATION_JSON))
// then
.andExpect(status().isCreated())
.andExpect(jsonPath("$", lenientlyEquals("""
{
"type": "MANAGED_SERVER",
"caption": "some new booking",
"validFrom": "{today}",
"validTo": "{todayPlus1Month}",
"resources": { "CPUs": 12, "SSD": 100, "Traffic": 250 }
}
"""
.replace("{today}", LocalDate.now().toString())
.replace("{todayPlus1Month}", LocalDate.now().plusMonths(1).toString()))
))
.andExpect(header().string("Location", matchesRegex("http://localhost/api/hs/booking/items/[^/]*")));
}
@Test
void globalAdmin_canNotAddInvalidBookingItem() throws Exception {
final var givenProjectUuid = UUID.randomUUID();
// given
when(em.find(HsBookingProjectEntity.class, givenProjectUuid)).thenAnswer(invocation ->
HsBookingProjectEntity.builder()
.uuid(invocation.getArgument(1))
.build()
);
when(bookingItemRepo.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
// when
mockMvc.perform(MockMvcRequestBuilders
.post("/api/hs/booking/items")
.header("current-user", "superuser-alex@hostsharing.net")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"projectUuid": "{projectUuid}",
"type": "MANAGED_SERVER",
"caption": "some new booking",
"validFrom": "{validFrom}",
"resources": { "CPUs": 12, "RAM": 4, "SSD": 100, "Traffic": 250 }
}
"""
.replace("{projectUuid}", givenProjectUuid.toString())
.replace("{validFrom}", LocalDate.now().plusMonths(1).toString())
)
.accept(MediaType.APPLICATION_JSON))
// then
// TODO.test: MockMvc does not seem to validate additionalProperties=false
// .andExpect(status().is4xxClientError())
.andExpect(status().isCreated())
.andExpect(jsonPath("$", lenientlyEquals("""
{
"type": "MANAGED_SERVER",
"caption": "some new booking",
"validFrom": "{today}",
"validTo": null,
"resources": { "CPUs": 12, "SSD": 100, "Traffic": 250 }
}
"""
.replace("{today}", LocalDate.now().toString())
.replace("{todayPlus1Month}", LocalDate.now().plusMonths(1).toString()))
))
.andExpect(header().string("Location", matchesRegex("http://localhost/api/hs/booking/items/[^/]*")));
}
}
}

View File

@ -1,8 +1,12 @@
package net.hostsharing.hsadminng.hs.booking.item;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import java.time.LocalDate;
import java.time.Month;
import java.util.Map;
import static java.util.Map.entry;
@ -14,6 +18,8 @@ class HsBookingItemEntityUnitTest {
public static final LocalDate GIVEN_VALID_FROM = LocalDate.parse("2020-01-01");
public static final LocalDate GIVEN_VALID_TO = LocalDate.parse("2030-12-31");
private MockedStatic<LocalDate> localDateMockedStatic = Mockito.mockStatic(LocalDate.class, Mockito.CALLS_REAL_METHODS);
final HsBookingItemEntity givenBookingItem = HsBookingItemEntity.builder()
.project(TEST_PROJECT)
.type(HsBookingItemType.CLOUD_SERVER)
@ -25,6 +31,24 @@ class HsBookingItemEntityUnitTest {
.validity(toPostgresDateRange(GIVEN_VALID_FROM, GIVEN_VALID_TO))
.build();
@AfterEach
void tearDown() {
localDateMockedStatic.close();
}
@Test
void validityStartsToday() {
// given
final var fakedToday = LocalDate.of(2024, Month.MAY, 1);
localDateMockedStatic.when(LocalDate::now).thenReturn(fakedToday);
// when
final var newBookingItem = HsBookingItemEntity.builder().build();
// then
assertThat(newBookingItem.getValidity().toString()).isEqualTo("Range{lower=2024-05-01, upper=null, mask=82, clazz=class java.time.LocalDate}");
}
@Test
void toStringContainsAllPropertiesAndResourcesSortedByKey() {
final var result = givenBookingItem.toString();

View File

@ -97,9 +97,7 @@ class HsBookingItemRepositoryIntegrationTest extends ContextBasedTestWithCleanup
// given
context("superuser-alex@hostsharing.net");
final var initialRoleNames = distinctRoleNamesOf(rawRoleRepo.findAll());
final var initialGrantNames = distinctGrantDisplaysOf(rawGrantRepo.findAll()).stream()
.map(s -> s.replace("hs_office_", ""))
.toList();
final var initialGrantNames = distinctGrantDisplaysOf(rawGrantRepo.findAll());
// when
attempt(em, () -> {
@ -124,7 +122,6 @@ class HsBookingItemRepositoryIntegrationTest extends ContextBasedTestWithCleanup
"hs_booking_item#somenewbookingitem:OWNER",
"hs_booking_item#somenewbookingitem:TENANT"));
assertThat(distinctGrantDisplaysOf(rawGrantRepo.findAll()))
.map(s -> s.replace("hs_office_", ""))
.containsExactlyInAnyOrder(fromFormatted(
initialGrantNames,
@ -138,7 +135,6 @@ class HsBookingItemRepositoryIntegrationTest extends ContextBasedTestWithCleanup
// admin
"{ grant perm:hs_booking_item#somenewbookingitem:UPDATE to role:hs_booking_item#somenewbookingitem:ADMIN by system and assume }",
"{ grant role:hs_booking_item#somenewbookingitem:ADMIN to role:hs_booking_item#somenewbookingitem:OWNER by system and assume }",
"{ grant perm:hs_booking_item#somenewbookingitem:INSERT>hs_hosting_asset to role:hs_booking_item#somenewbookingitem:AGENT by system and assume }",
// agent
"{ grant role:hs_booking_item#somenewbookingitem:AGENT to role:hs_booking_item#somenewbookingitem:ADMIN by system and assume }",

View File

@ -55,9 +55,10 @@ class HsCloudServerBookingItemValidatorUnitTest {
// then
assertThat(validator.properties()).map(Map::toString).containsExactlyInAnyOrder(
"{type=boolean, propertyName=active, required=false, defaultValue=true, isTotalsValidator=false}",
"{type=integer, propertyName=CPUs, min=1, max=32, required=true, isTotalsValidator=false}",
"{type=integer, propertyName=RAM, unit=GB, min=1, max=128, required=true, isTotalsValidator=false}",
"{type=integer, propertyName=SSD, unit=GB, min=25, max=1000, step=25, required=true, isTotalsValidator=false}",
"{type=integer, propertyName=SSD, unit=GB, min=0, max=1000, step=25, required=true, isTotalsValidator=false}",
"{type=integer, propertyName=HDD, unit=GB, min=0, max=4000, step=250, required=false, defaultValue=0, isTotalsValidator=false}",
"{type=integer, propertyName=Traffic, unit=GB, min=250, max=10000, step=250, required=true, isTotalsValidator=false}",
"{type=enumeration, propertyName=SLA-Infrastructure, values=[BASIC, EXT8H, EXT4H, EXT2H], required=false, isTotalsValidator=false}");
@ -109,10 +110,10 @@ class HsCloudServerBookingItemValidatorUnitTest {
// then
assertThat(result).containsExactlyInAnyOrder(
"'D-12345:Test-Project:Test Cloud.resources.CPUs' maximum total is 4, but actual total CPUs 5",
"'D-12345:Test-Project:Test Cloud.resources.RAM' maximum total is 20 GB, but actual total RAM 30 GB",
"'D-12345:Test-Project:Test Cloud.resources.SSD' maximum total is 100 GB, but actual total SSD 150 GB",
"'D-12345:Test-Project:Test Cloud.resources.Traffic' maximum total is 5000 GB, but actual total Traffic 5500 GB"
"'D-12345:Test-Project:Test Cloud.resources.CPUs' maximum total is 4, but actual total CPUs is 5",
"'D-12345:Test-Project:Test Cloud.resources.RAM' maximum total is 20 GB, but actual total RAM is 30 GB",
"'D-12345:Test-Project:Test Cloud.resources.SSD' maximum total is 100 GB, but actual total SSD is 150 GB",
"'D-12345:Test-Project:Test Cloud.resources.Traffic' maximum total is 5000 GB, but actual total Traffic is 5500 GB"
);
}
}

View File

@ -120,10 +120,10 @@ class HsManagedServerBookingItemValidatorUnitTest {
// then
assertThat(result).containsExactlyInAnyOrder(
"'D-12345:Test-Project:null.resources.CPUs' maximum total is 4, but actual total CPUs 5",
"'D-12345:Test-Project:null.resources.RAM' maximum total is 20 GB, but actual total RAM 30 GB",
"'D-12345:Test-Project:null.resources.SSD' maximum total is 100 GB, but actual total SSD 150 GB",
"'D-12345:Test-Project:null.resources.Traffic' maximum total is 5000 GB, but actual total Traffic 5500 GB"
"'D-12345:Test-Project:null.resources.CPUs' maximum total is 4, but actual total CPUs is 5",
"'D-12345:Test-Project:null.resources.RAM' maximum total is 20 GB, but actual total RAM is 30 GB",
"'D-12345:Test-Project:null.resources.SSD' maximum total is 100 GB, but actual total SSD is 150 GB",
"'D-12345:Test-Project:null.resources.Traffic' maximum total is 5000 GB, but actual total Traffic is 5500 GB"
);
}

View File

@ -28,29 +28,38 @@ class HsPrivateCloudBookingItemValidatorUnitTest {
// given
final var privateCloudBookingItemEntity = HsBookingItemEntity.builder()
.type(PRIVATE_CLOUD)
.caption("myPC")
.resources(ofEntries(
entry("CPUs", 4),
entry("RAM", 20),
entry("SSD", 100),
entry("Traffic", 5000)
entry("Traffic", 5000),
entry("SLA-Platform EXT4H", 2),
entry("SLA-EMail", 2)
))
.subBookingItems(of(
HsBookingItemEntity.builder()
.type(MANAGED_SERVER)
.caption("myMS-1")
.resources(ofEntries(
entry("CPUs", 2),
entry("RAM", 10),
entry("SSD", 50),
entry("Traffic", 2500)
entry("Traffic", 2500),
entry("SLA-Platform", "EXT4H"),
entry("SLA-EMail", true)
))
.build(),
HsBookingItemEntity.builder()
.type(CLOUD_SERVER)
.caption("myMS-2")
.resources(ofEntries(
entry("CPUs", 2),
entry("RAM", 10),
entry("SSD", 50),
entry("Traffic", 2500)
entry("Traffic", 2500),
entry("SLA-Platform", "EXT4H"),
entry("SLA-EMail", true)
))
.build()
))
@ -69,29 +78,42 @@ class HsPrivateCloudBookingItemValidatorUnitTest {
final var privateCloudBookingItemEntity = HsBookingItemEntity.builder()
.project(project)
.type(PRIVATE_CLOUD)
.caption("myPC")
.resources(ofEntries(
entry("CPUs", 4),
entry("RAM", 20),
entry("SSD", 100),
entry("Traffic", 5000)
entry("Traffic", 5000),
entry("SLA-Platform EXT2H", 1),
entry("SLA-EMail", 1)
))
.subBookingItems(of(
HsBookingItemEntity.builder()
.type(MANAGED_SERVER)
.caption("myMS-1")
.resources(ofEntries(
entry("CPUs", 3),
entry("RAM", 20),
entry("SSD", 100),
entry("Traffic", 3000)
entry("Traffic", 3000),
entry("SLA-Platform", "EXT2H"),
entry("SLA-EMail", true)
))
.build(),
HsBookingItemEntity.builder()
.type(CLOUD_SERVER)
.caption("myMS-2")
.resources(ofEntries(
entry("CPUs", 2),
entry("RAM", 10),
entry("SSD", 50),
entry("Traffic", 2500)
entry("Traffic", 2500),
entry("SLA-Platform", "EXT2H"),
entry("SLA-EMail", true),
entry("SLA-Maria", true),
entry("SLA-PgSQL", true),
entry("SLA-Office", true),
entry("SLA-Web", true)
))
.build()
))
@ -102,11 +124,16 @@ class HsPrivateCloudBookingItemValidatorUnitTest {
// then
assertThat(result).containsExactlyInAnyOrder(
"'D-12345:Test-Project:null.resources.CPUs' maximum total is 4, but actual total CPUs 5",
"'D-12345:Test-Project:null.resources.RAM' maximum total is 20 GB, but actual total RAM 30 GB",
"'D-12345:Test-Project:null.resources.SSD' maximum total is 100 GB, but actual total SSD 150 GB",
"'D-12345:Test-Project:null.resources.Traffic' maximum total is 5000 GB, but actual total Traffic 5500 GB"
);
"'D-12345:Test-Project:myPC.resources.CPUs' maximum total is 4, but actual total CPUs is 5",
"'D-12345:Test-Project:myPC.resources.RAM' maximum total is 20 GB, but actual total RAM is 30 GB",
"'D-12345:Test-Project:myPC.resources.SSD' maximum total is 100 GB, but actual total SSD is 150 GB",
"'D-12345:Test-Project:myPC.resources.Traffic' maximum total is 5000 GB, but actual total Traffic is 5500 GB",
"'D-12345:Test-Project:myPC.resources.SLA-Platform EXT2H maximum total is 1, but actual total for SLA-Platform=EXT2H is 2",
"'D-12345:Test-Project:myPC.resources.SLA-EMail' maximum total is 1, but actual total SLA-EMail is 2",
"'D-12345:Test-Project:myPC.resources.SLA-Maria' maximum total is 0, but actual total SLA-Maria is 1",
"'D-12345:Test-Project:myPC.resources.SLA-PgSQL' maximum total is 0, but actual total SLA-PgSQL is 1",
"'D-12345:Test-Project:myPC.resources.SLA-Office' maximum total is 0, but actual total SLA-Office is 1",
"'D-12345:Test-Project:myPC.resources.SLA-Web' maximum total is 0, but actual total SLA-Web is 1"
);
}
}

View File

@ -10,8 +10,11 @@ import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectRepository;
import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorRepository;
import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup;
import net.hostsharing.hsadminng.rbac.test.JpaAttempt;
import org.junit.jupiter.api.ClassOrderer;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestClassOrder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
@ -28,11 +31,12 @@ import static net.hostsharing.hsadminng.rbac.test.JsonMatcher.lenientlyEquals;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.matchesRegex;
@Transactional
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
classes = { HsadminNgApplication.class, JpaAttempt.class }
)
@Transactional
@TestClassOrder(ClassOrderer.OrderAnnotation.class) // fail early on fetching problems
class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup {
@LocalServerPort
@ -54,6 +58,7 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup
JpaAttempt jpaAttempt;
@Nested
@Order(2)
class ListAssets {
@Test
@ -152,6 +157,7 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup
}
@Nested
@Order(3)
class AddAsset {
@Test
@ -231,17 +237,17 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup
.when()
.post("http://localhost/api/hs/hosting/assets")
.then().log().all().assertThat()
.statusCode(201)
.contentType(ContentType.JSON)
.body("", lenientlyEquals("""
{
"type": "MANAGED_WEBSPACE",
"identifier": "fir90",
"caption": "some new ManagedWebspace in client's ManagedServer",
"config": {}
}
"""))
.header("Location", matchesRegex("http://localhost:[1-9][0-9]*/api/hs/hosting/assets/[^/]*"))
.statusCode(201)
.contentType(ContentType.JSON)
.body("", lenientlyEquals("""
{
"type": "MANAGED_WEBSPACE",
"identifier": "fir90",
"caption": "some new ManagedWebspace in client's ManagedServer",
"config": {}
}
"""))
.header("Location", matchesRegex("http://localhost:[1-9][0-9]*/api/hs/hosting/assets/[^/]*"))
.extract().header("Location"); // @formatter:on
// finally, the new asset can be accessed under the generated UUID
@ -258,34 +264,33 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup
RestAssured // @formatter:off
.given()
.header("current-user", "superuser-alex@hostsharing.net")
.contentType(ContentType.JSON)
.body("""
{
"bookingItemUuid": "%s",
"type": "MANAGED_SERVER",
"identifier": "vm1400",
"caption": "some new ManagedServer",
"config": { "monit_max_ssd_usage": 0, "monit_max_cpu_usage": 101, "extra": 42 }
}
""".formatted(givenBookingItem.getUuid()))
.port(port)
.header("current-user", "superuser-alex@hostsharing.net")
.contentType(ContentType.JSON)
.body("""
{
"bookingItemUuid": "%s",
"type": "MANAGED_SERVER",
"identifier": "vm1400",
"caption": "some new ManagedServer",
"config": { "monit_max_ssd_usage": 0, "monit_max_cpu_usage": 101, "extra": 42 }
}
""".formatted(givenBookingItem.getUuid()))
.port(port)
.when()
.post("http://localhost/api/hs/hosting/assets")
.post("http://localhost/api/hs/hosting/assets")
.then().log().all().assertThat()
.statusCode(400)
.contentType(ContentType.JSON)
.body("", lenientlyEquals("""
{
"statusPhrase": "Bad Request",
"message": "[
<<<'MANAGED_SERVER:vm1400.config.extra' is not expected but is set to '42',
<<<'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 <= 100 but is 101,
<<<'MANAGED_SERVER:vm1400.config.monit_max_ram_usage' is required but missing
<<<]"
}
""".replaceAll(" +<<<", ""))); // @formatter:on
.statusCode(400)
.contentType(ContentType.JSON)
.body("", lenientlyEquals("""
{
"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
<<<]"
}
""".replaceAll(" +<<<", ""))); // @formatter:on
}
@ -333,15 +338,14 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup
.body("", lenientlyEquals("""
{
"statusPhrase": "Bad Request",
"message": "[
<<<'D-1000111:D-1000111 default project:separate ManagedWebspace.resources.Multi=1 allows at maximum 25 unix users, but 26 found
<<<]"
"message": "['D-1000111:D-1000111 default project:separate ManagedWebspace.resources.Multi=1 allows at maximum 25 unix users, but 26 found]"
}
""".replaceAll(" +<<<", ""))); // @formatter:on
}
}
@Nested
@Order(1)
class GetAsset {
@Test
@ -413,6 +417,7 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup
}
@Nested
@Order(4)
class PatchAsset {
@Test
@ -466,6 +471,7 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup
}
@Nested
@Order(5)
class DeleteAsset {
@Test

View File

@ -55,18 +55,22 @@ class HsHostingAssetPropsControllerAcceptanceTest {
[
{
"type": "integer",
"propertyName": "monit_min_free_ssd",
"min": 1,
"max": 1000,
"propertyName": "monit_max_cpu_usage",
"unit": "%",
"min": 10,
"max": 100,
"required": false,
"defaultValue": 92,
"isTotalsValidator": false
},
{
"type": "integer",
"propertyName": "monit_min_free_hdd",
"min": 1,
"max": 4000,
"propertyName": "monit_max_ram_usage",
"unit": "%",
"min": 10,
"max": 100,
"required": false,
"defaultValue": 92,
"isTotalsValidator": false
},
{
@ -75,7 +79,17 @@ class HsHostingAssetPropsControllerAcceptanceTest {
"unit": "%",
"min": 10,
"max": 100,
"required": true,
"required": false,
"defaultValue": 98,
"isTotalsValidator": false
},
{
"type": "integer",
"propertyName": "monit_min_free_ssd",
"min": 1,
"max": 1000,
"required": false,
"defaultValue": 5,
"isTotalsValidator": false
},
{
@ -85,29 +99,157 @@ class HsHostingAssetPropsControllerAcceptanceTest {
"min": 10,
"max": 100,
"required": false,
"defaultValue": 95,
"isTotalsValidator": false
},
{
"type": "integer",
"propertyName": "monit_max_cpu_usage",
"unit": "%",
"min": 10,
"max": 100,
"required": true,
"propertyName": "monit_min_free_hdd",
"min": 1,
"max": 4000,
"required": false,
"defaultValue": 10,
"isTotalsValidator": false
},
{
"type": "integer",
"propertyName": "monit_max_ram_usage",
"unit": "%",
"min": 10,
"max": 100,
"required": true,
"type": "boolean",
"propertyName": "software-pgsql",
"required": false,
"defaultValue": true,
"isTotalsValidator": false
},
{
"type": "boolean",
"propertyName": "software-mariadb",
"required": false,
"defaultValue": true,
"isTotalsValidator": false
},
{
"type": "enumeration",
"propertyName": "php-default",
"values": [
"5.6",
"7.0",
"7.1",
"7.2",
"7.3",
"7.4",
"8.0",
"8.1",
"8.2"
],
"required": false,
"defaultValue": "8.2",
"isTotalsValidator": false
},
{
"type": "boolean",
"propertyName": "software-php-5.6",
"required": false,
"defaultValue": false,
"isTotalsValidator": false
},
{
"type": "boolean",
"propertyName": "software-php-7.0",
"required": false,
"defaultValue": false,
"isTotalsValidator": false
},
{
"type": "boolean",
"propertyName": "software-php-7.1",
"required": false,
"defaultValue": false,
"isTotalsValidator": false
},
{
"type": "boolean",
"propertyName": "software-php-7.2",
"required": false,
"defaultValue": false,
"isTotalsValidator": false
},
{
"type": "boolean",
"propertyName": "software-php-7.3",
"required": false,
"defaultValue": false,
"isTotalsValidator": false
},
{
"type": "boolean",
"propertyName": "software-php-7.4",
"required": false,
"defaultValue": true,
"isTotalsValidator": false
},
{
"type": "boolean",
"propertyName": "software-php-8.0",
"required": false,
"defaultValue": false,
"isTotalsValidator": false
},
{
"type": "boolean",
"propertyName": "software-php-8.1",
"required": false,
"defaultValue": false,
"isTotalsValidator": false
},
{
"type": "boolean",
"propertyName": "software-php-8.2",
"required": false,
"defaultValue": true,
"isTotalsValidator": false
},
{
"type": "boolean",
"propertyName": "software-postfix-tls-1.0",
"required": false,
"defaultValue": false,
"isTotalsValidator": false
},
{
"type": "boolean",
"propertyName": "software-dovecot-tls-1.0",
"required": false,
"defaultValue": false,
"isTotalsValidator": false
},
{
"type": "boolean",
"propertyName": "software-clamav",
"required": false,
"defaultValue": true,
"isTotalsValidator": false
},
{
"type": "boolean",
"propertyName": "software-collabora",
"required": false,
"defaultValue": false,
"isTotalsValidator": false
},
{
"type": "boolean",
"propertyName": "software-libreoffice",
"required": false,
"defaultValue": false,
"isTotalsValidator": false
},
{
"type": "boolean",
"propertyName": "software-imagemagick-ghostscript",
"required": false,
"defaultValue": false,
"isTotalsValidator": false
}
]
"""));
// @formatter:on
}
}

View File

@ -24,8 +24,6 @@ import jakarta.servlet.http.HttpServletRequest;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
import static java.util.Map.entry;
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.CLOUD_SERVER;
@ -149,6 +147,7 @@ class HsHostingAssetRepositoryIntegrationTest extends ContextBasedTestWithCleanu
"{ grant role:hs_booking_item#fir01:TENANT to role:hs_hosting_asset#fir00:TENANT by system and assume }",
"{ grant role:hs_hosting_asset#fir00:TENANT to role:hs_hosting_asset#fir00:AGENT by system and assume }",
"{ grant role:hs_hosting_asset#vm1011:TENANT to role:hs_hosting_asset#fir00:TENANT by system and assume }",
"{ grant perm:hs_hosting_asset#fir00:SELECT to role:hs_hosting_asset#fir00:TENANT by system and assume }",
null));
}

View File

@ -3,7 +3,6 @@ package net.hostsharing.hsadminng.hs.hosting.asset.validators;
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetEntity;
import org.junit.jupiter.api.Test;
import jakarta.validation.ValidationException;
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_SERVER;
import static org.assertj.core.api.Assertions.assertThat;
@ -23,10 +22,6 @@ class HsHostingAssetEntityValidatorUnitTest {
final var result = catchThrowable( ()-> HsHostingAssetEntityValidatorRegistry.validated(managedServerHostingAssetEntity));
// then
assertThat(result).isInstanceOf(ValidationException.class)
.hasMessageContaining(
"'MANAGED_SERVER:vm1234.config.monit_max_ssd_usage' is required but missing",
"'MANAGED_SERVER:vm1234.config.monit_max_cpu_usage' is required but missing",
"'MANAGED_SERVER:vm1234.config.monit_max_ram_usage' is required but missing");
assertThat(result).isNull(); // all required properties have defaults
}
}

View File

@ -32,7 +32,6 @@ class HsManagedServerHostingAssetValidatorUnitTest {
assertThat(result).containsExactlyInAnyOrder(
"'MANAGED_SERVER:vm1234.config.monit_max_cpu_usage' is expected to be >= 10 but is 2",
"'MANAGED_SERVER:vm1234.config.monit_max_ram_usage' is expected to be <= 100 but is 101",
"'MANAGED_SERVER:vm1234.config.monit_max_ssd_usage' is required but missing",
"'MANAGED_SERVER:vm1234.config.monit_max_hdd_usage' is expected to be of type class java.lang.Integer, but is of type 'String'");
}
}

View File

@ -0,0 +1,20 @@
package net.hostsharing.hsadminng.hs.hosting.contact;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class HsHostingContactEntityUnitTest {
@Test
void toStringReturnsNullForNullContact() {
final HsHostingContactEntity givenContact = null;
assertThat("" + givenContact).isEqualTo("null");
}
@Test
void toStringReturnsCaption() {
final var givenContact = HsHostingContactEntity.builder().caption("given caption").build();
assertThat("" + givenContact).isEqualTo("contact(caption='given caption')");
}
}

View File

@ -109,9 +109,9 @@ class HsOfficeDebitorRepositoryIntegrationTest extends ContextBasedTestWithClean
assertThat(debitorRepo.count()).isEqualTo(count + 1);
}
@Transactional
@ParameterizedTest
@ValueSource(strings = {"", "a", "ab", "a12", "123", "12a"})
@Transactional
public void canNotCreateNewDebitorWithInvalidDefaultPrefix(final String givenPrefix) {
// given
context("superuser-alex@hostsharing.net");

View File

@ -14,9 +14,12 @@ import org.junit.jupiter.api.TestInfo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import org.springframework.transaction.PlatformTransactionManager;
import jakarta.persistence.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Supplier;
import static java.lang.System.out;
import static java.util.Comparator.comparing;
@ -28,9 +31,13 @@ import static org.assertj.core.api.Assertions.assertThat;
public abstract class ContextBasedTestWithCleanup extends ContextBasedTest {
private static final boolean DETAILED_BUT_SLOW_CHECK = true;
@PersistenceContext
protected EntityManager em;
@Autowired
private PlatformTransactionManager tm;
@Autowired
RbacGrantRepository rbacGrantRepo;
@ -166,12 +173,16 @@ public abstract class ContextBasedTestWithCleanup extends ContextBasedTest {
@AfterEach
void cleanupAndCheckCleanup(final TestInfo testInfo) {
out.println(ContextBasedTestWithCleanup.class.getSimpleName() + ".cleanupAndCheckCleanup");
cleanupTemporaryTestData();
deleteLeakedRbacObjects();
long rbacObjectCount = assertNoNewRbacObjectsRolesAndGrantsLeaked();
// If the whole test method has its own transaction, cleanup makes no sense.
// If that transaction even failed, cleaunup would cause an exception.
if (!tm.getTransaction(null).isRollbackOnly()) {
out.println(ContextBasedTestWithCleanup.class.getSimpleName() + ".cleanupAndCheckCleanup");
cleanupTemporaryTestData();
repeatUntilTrue(3, this::deleteLeakedRbacObjects);
out.println("TOTAL OBJECT COUNT (after): " + rbacObjectCount);
long rbacObjectCount = assertNoNewRbacObjectsRolesAndGrantsLeaked();
out.println("TOTAL OBJECT COUNT (after): " + rbacObjectCount);
}
}
private void cleanupTemporaryTestData() {
@ -218,7 +229,8 @@ public abstract class ContextBasedTestWithCleanup extends ContextBasedTest {
}).assertSuccessful().returnedValue();
}
private void deleteLeakedRbacObjects() {
private boolean deleteLeakedRbacObjects() {
final var deletionSuccessful = new AtomicBoolean(true);
rbacObjectRepo.findAll().stream()
.filter(o -> o.serialId > latestIntialTestDataSerialId)
.sorted(comparing(o -> o.serialId))
@ -235,8 +247,10 @@ public abstract class ContextBasedTestWithCleanup extends ContextBasedTest {
if (exception != null) {
out.println("DELETING leaked " + o.objectTable + "#" + o.uuid + " FAILED " + exception);
deletionSuccessful.set(false);
}
});
return deletionSuccessful.get();
}
private void assertEqual(final Set<String> before, final Set<String> after) {
@ -297,6 +311,15 @@ public abstract class ContextBasedTestWithCleanup extends ContextBasedTest {
"doc/temp/" + name + ".md"
);
}
public static boolean repeatUntilTrue(int maxAttempts, Supplier<Boolean> method) {
for (int attempts = 0; attempts < maxAttempts; attempts++) {
if (method.get()) {
return true;
}
}
return false;
}
}
interface RbacObjectRepository extends Repository<RbacObjectEntity, UUID> {