validator;
+ private String expectedStep = "preprocessEntity";
+ private final EntityManager em;
+ private HsHostingAsset entity;
+ private HsHostingAssetResource resource;
+
+ public HostingAssetEntitySaveProcessor(final EntityManager em, final HsHostingAsset entity) {
+ this.em = em;
+ this.entity = entity;
+ this.validator = HostingAssetEntityValidatorRegistry.forType(entity.getType());
+ }
+
+ /// initial step allowing to set default values before any validations
+ public HostingAssetEntitySaveProcessor preprocessEntity() {
+ step("preprocessEntity", "validateEntity");
+ validator.preprocessEntity(entity);
+ return this;
+ }
+
+ /// validates the entity itself including its properties
+ public HostingAssetEntitySaveProcessor validateEntity() {
+ step("validateEntity", "prepareForSave");
+ MultiValidationException.throwIfNotEmpty(validator.validateEntity(entity));
+ return this;
+ }
+
+ // TODO.impl: remove once the migration of legacy data is done
+ /// validates the entity itself including its properties, but ignoring some error messages for import of legacy data
+ public HostingAssetEntitySaveProcessor validateEntityIgnoring(final String... ignoreRegExp) {
+ step("validateEntity", "prepareForSave");
+ final var ignoreRegExpPatterns = Arrays.stream(ignoreRegExp).map(Pattern::compile).toList();
+ MultiValidationException.throwIfNotEmpty(
+ validator.validateEntity(entity).stream()
+ .filter(error -> ignoreRegExpPatterns.stream().noneMatch(p -> p.matcher(error).matches() ))
+ .toList()
+ );
+ return this;
+ }
+
+ /// hashing passwords etc.
+ @SuppressWarnings("unchecked")
+ public HostingAssetEntitySaveProcessor prepareForSave() {
+ step("prepareForSave", "save");
+ validator.prepareProperties(em, entity);
+ return this;
+ }
+
+ /**
+ * Saves the entity using the given `saveFunction`.
+ *
+ * `validator.postPersist(em, entity)` is NOT called.
+ * If any postprocessing is necessary, the saveFunction has to implement this.
+ * @param saveFunction
+ * @return
+ */
+ public HostingAssetEntitySaveProcessor saveUsing(final Function saveFunction) {
+ step("save", "validateContext");
+ entity = saveFunction.apply(entity);
+ return this;
+ }
+
+ /**
+ * Saves the using the `EntityManager`, but does NOT ever merge the entity.
+ *
+ * `validator.postPersist(em, entity)` is called afterwards with the entity guaranteed to be flushed to the database.
+ * @return
+ */
+ public HostingAssetEntitySaveProcessor save() {
+ return saveUsing(e -> {
+ if (!em.contains(entity)) {
+ em.persist(entity);
+ }
+ em.flush(); // makes RbacEntity available as RealEntity if needed
+ validator.postPersist(em, entity);
+ return entity;
+ });
+ }
+
+ /// validates the entity within it's parent and child hierarchy (e.g. totals validators and other limits)
+ public HostingAssetEntitySaveProcessor validateContext() {
+ step("validateContext", "mapUsing");
+ return HsEntityValidator.doWithEntityManager(em, () -> {
+ MultiValidationException.throwIfNotEmpty(validator.validateContext(entity));
+ return this;
+ });
+ }
+
+ /// maps entity to JSON resource representation
+ public HostingAssetEntitySaveProcessor mapUsing(
+ final Function mapFunction) {
+ step("mapUsing", "revampProperties");
+ resource = mapFunction.apply(entity);
+ return this;
+ }
+
+ /// removes write-only-properties and ads computed-properties
+ @SuppressWarnings("unchecked")
+ public HsHostingAssetResource revampProperties() {
+ step("revampProperties", null);
+ final var revampedProps = validator.revampProperties(em, entity, (Map) resource.getConfig());
+ resource.setConfig(revampedProps);
+ return resource;
+ }
+
+ // Makes sure that the steps are called in the correct order.
+ // Could also be implemented using an interface per method, but that seems exaggerated.
+ private void step(final String current, final String next) {
+ if (!expectedStep.equals(current)) {
+ throw new IllegalStateException("expected " + expectedStep + " but got " + current);
+ }
+ expectedStep = next;
+ }
+}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HostingAssetEntityValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HostingAssetEntityValidator.java
new file mode 100644
index 00000000..bff087f4
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HostingAssetEntityValidator.java
@@ -0,0 +1,238 @@
+package net.hostsharing.hsadminng.hs.hosting.asset.validators;
+
+import net.hostsharing.hsadminng.hs.booking.item.HsBookingItem;
+import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType;
+import net.hostsharing.hsadminng.hs.booking.item.validators.HsBookingItemEntityValidatorRegistry;
+import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
+import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType;
+import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity;
+import net.hostsharing.hsadminng.hs.validation.HsEntityValidator;
+import net.hostsharing.hsadminng.hs.validation.ValidatableProperty;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static java.util.Arrays.stream;
+import static java.util.Collections.emptyList;
+import static java.util.Optional.ofNullable;
+
+public abstract class HostingAssetEntityValidator extends HsEntityValidator {
+
+ static final ValidatableProperty, ?>[] NO_EXTRA_PROPERTIES = new ValidatableProperty, ?>[0];
+
+ private final ReferenceValidator bookingItemReferenceValidation;
+ private final ReferenceValidator parentAssetReferenceValidation;
+ private final ReferenceValidator assignedToAssetReferenceValidation;
+ private final HostingAssetEntityValidator.AlarmContact alarmContactValidation;
+
+ HostingAssetEntityValidator(
+ final HsHostingAssetType assetType,
+ final AlarmContact alarmContactValidation, // hostmaster alert address is implicitly added where needed
+ final ValidatableProperty, ?>... properties) {
+ super(properties);
+ this.bookingItemReferenceValidation = new ReferenceValidator<>(
+ assetType.bookingItemPolicy(),
+ assetType.bookingItemTypes(),
+ HsHostingAsset::getBookingItem,
+ HsBookingItem::getType);
+ this.parentAssetReferenceValidation = new ReferenceValidator<>(
+ assetType.parentAssetPolicy(),
+ assetType.parentAssetTypes(),
+ HsHostingAsset::getParentAsset,
+ HsHostingAsset::getType);
+ this.assignedToAssetReferenceValidation = new ReferenceValidator<>(
+ assetType.assignedToAssetPolicy(),
+ assetType.assignedToAssetTypes(),
+ HsHostingAsset::getAssignedToAsset,
+ HsHostingAsset::getType);
+ this.alarmContactValidation = alarmContactValidation;
+ }
+
+ @Override
+ public List validateEntity(final HsHostingAsset assetEntity) {
+ return sequentiallyValidate(
+ () -> validateEntityReferencesAndProperties(assetEntity),
+ () -> validateIdentifierPattern(assetEntity)
+ );
+ }
+
+ @Override
+ public List validateContext(final HsHostingAsset assetEntity) {
+ return sequentiallyValidate(
+ () -> optionallyValidate(assetEntity.getBookingItem()),
+ () -> optionallyValidate(assetEntity.getParentAsset()),
+ () -> validateAgainstSubEntities(assetEntity)
+ );
+ }
+
+ private List validateEntityReferencesAndProperties(final HsHostingAsset assetEntity) {
+ return Stream.of(
+ validateReferencedEntity(assetEntity, "bookingItem", bookingItemReferenceValidation::validate),
+ validateReferencedEntity(assetEntity, "parentAsset", parentAssetReferenceValidation::validate),
+ validateReferencedEntity(assetEntity, "assignedToAsset", assignedToAssetReferenceValidation::validate),
+ validateReferencedEntity(assetEntity, "alarmContact", alarmContactValidation::validate),
+ validateProperties(assetEntity))
+ .filter(Objects::nonNull)
+ .flatMap(List::stream)
+ .filter(Objects::nonNull)
+ .toList();
+ }
+
+ private List validateReferencedEntity(
+ final HsHostingAsset assetEntity,
+ final String referenceFieldName,
+ final BiFunction> validator) {
+ return enrich(prefix(assetEntity.toShortString()), validator.apply(assetEntity, referenceFieldName));
+ }
+
+ private List validateProperties(final HsHostingAsset assetEntity) {
+ return enrich(prefix(assetEntity.toShortString(), "config"), super.validateProperties(assetEntity));
+ }
+
+ private static List optionallyValidate(final HsHostingAsset assetEntity) {
+ return assetEntity != null
+ ? enrich(
+ prefix(assetEntity.toShortString(), "parentAsset"),
+ HostingAssetEntityValidatorRegistry.forType(assetEntity.getType()).validateContext(assetEntity))
+ : emptyList();
+ }
+
+ private static List optionallyValidate(final HsBookingItem bookingItem) {
+ return bookingItem != null
+ ? enrich(
+ prefix(bookingItem.toShortString(), "bookingItem"),
+ HsBookingItemEntityValidatorRegistry.forType(bookingItem.getType()).validateContext(bookingItem))
+ : emptyList();
+ }
+
+ protected List validateAgainstSubEntities(final HsHostingAsset assetEntity) {
+ return enrich(
+ prefix(assetEntity.toShortString(), "config"),
+ stream(propertyValidators)
+ .filter(ValidatableProperty::isTotalsValidator)
+ .map(prop -> validateMaxTotalValue(assetEntity, prop))
+ .filter(Objects::nonNull)
+ .toList());
+ }
+
+ // TODO.test: check, if there are any hosting assets which need this validation at all
+ private String validateMaxTotalValue(
+ final HsHostingAsset hostingAsset,
+ final ValidatableProperty, ?> propDef) {
+ final var propName = propDef.propertyName();
+ final var propUnit = ofNullable(propDef.unit()).map(u -> " " + u).orElse("");
+ final var totalValue = ofNullable(hostingAsset.getSubHostingAssets()).orElse(emptyList())
+ .stream()
+ .map(subItem -> propDef.getValue(subItem.getConfig()))
+ .map(HsEntityValidator::toIntegerWithDefault0)
+ .reduce(0, Integer::sum);
+ final var maxValue = getIntegerValueWithDefault0(propDef, hostingAsset.getConfig());
+ return totalValue > maxValue
+ ? "%s' maximum total is %d%s, but actual total %s is %d%s".formatted(
+ propName, maxValue, propUnit, propName, totalValue, propUnit)
+ : null;
+ }
+
+ private List validateIdentifierPattern(final HsHostingAsset assetEntity) {
+ final var expectedIdentifierPattern = identifierPattern(assetEntity);
+ if (assetEntity.getIdentifier() == null ||
+ !expectedIdentifierPattern.matcher(assetEntity.getIdentifier()).matches()) {
+ return List.of(
+ "'identifier' expected to match '" + expectedIdentifierPattern + "', but is '" + assetEntity.getIdentifier()
+ + "'");
+ }
+ return Collections.emptyList();
+ }
+
+ protected abstract Pattern identifierPattern(HsHostingAsset assetEntity);
+
+ static class ReferenceValidator {
+
+ private final HsHostingAssetType.RelationPolicy policy;
+ private final Set referencedEntityTypes;
+ private final Function referencedEntityGetter;
+ private final Function referencedEntityTypeGetter;
+
+ public ReferenceValidator(
+ final HsHostingAssetType.RelationPolicy policy,
+ final Set referencedEntityTypes,
+ final Function referencedEntityGetter,
+ final Function referencedEntityTypeGetter) {
+ this.policy = policy;
+ this.referencedEntityTypes = referencedEntityTypes;
+ this.referencedEntityGetter = referencedEntityGetter;
+ this.referencedEntityTypeGetter = referencedEntityTypeGetter;
+ }
+
+ public ReferenceValidator(
+ final HsHostingAssetType.RelationPolicy policy,
+ final Function referencedEntityGetter) {
+ this.policy = policy;
+ this.referencedEntityTypes = Set.of();
+ this.referencedEntityGetter = referencedEntityGetter;
+ this.referencedEntityTypeGetter = e -> null;
+ }
+
+ List validate(final HsHostingAsset assetEntity, final String referenceFieldName) {
+
+ final var referencedEntity = referencedEntityGetter.apply(assetEntity);
+ final var referencedEntityType = referencedEntity != null ? referencedEntityTypeGetter.apply(referencedEntity) : null;
+
+ switch (policy) {
+ case REQUIRED:
+ if (!referencedEntityTypes.contains(referencedEntityType)) {
+ return List.of(referencedEntityType == null
+ ? referenceFieldName + "' must be of type " + toDisplay(referencedEntityTypes) + " but is null"
+ : referenceFieldName + "' must be of type " + toDisplay(referencedEntityTypes) + " but is of type " + referencedEntityType);
+ }
+ break;
+ case TERMINATORY:
+ if (assetEntity.getParentAsset() != null && assetEntity.getBookingItem() != null) {
+ return List.of(referenceFieldName + "' or parentItem must be null but is of type " + referencedEntityType);
+ }
+ if (assetEntity.getParentAsset() == null && !referencedEntityTypes.contains(referencedEntityType)) {
+ return List.of(referencedEntityType == null
+ ? referenceFieldName + "' must be of type " + toDisplay(referencedEntityTypes) + " but is null"
+ : referenceFieldName + "' must be of type " + toDisplay(referencedEntityTypes) + " but is of type " + referencedEntityType);
+ }
+ break;
+ case OPTIONAL:
+ if (referencedEntityType != null && !referencedEntityTypes.contains(referencedEntityType)) {
+ return List.of(referenceFieldName + "' must be null or of type " + toDisplay(referencedEntityTypes) + " but is of type "
+ + referencedEntityType);
+ }
+ break;
+ case FORBIDDEN:
+ if (referencedEntityType != null) {
+ return List.of(referenceFieldName + "' must be null but is of type " + referencedEntityType);
+ }
+ break;
+ }
+ return emptyList();
+ }
+
+ private String toDisplay(final Set referencedEntityTypes) {
+ return referencedEntityTypes.stream().sorted().map(Object::toString).collect(Collectors.joining(" or "));
+ }
+ }
+
+ static class AlarmContact extends ReferenceValidator> {
+
+ AlarmContact(final HsHostingAssetType.RelationPolicy policy) {
+ super(policy, HsHostingAsset::getAlarmContact);
+ }
+
+ // hostmaster alert address is implicitly added where neccessary
+ static AlarmContact isOptional() {
+ return new AlarmContact(HsHostingAssetType.RelationPolicy.OPTIONAL);
+ }
+ }
+
+}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HostingAssetEntityValidatorRegistry.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HostingAssetEntityValidatorRegistry.java
new file mode 100644
index 00000000..5f7a453c
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HostingAssetEntityValidatorRegistry.java
@@ -0,0 +1,56 @@
+package net.hostsharing.hsadminng.hs.hosting.asset.validators;
+
+import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
+import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
+import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType;
+import net.hostsharing.hsadminng.hs.validation.HsEntityValidator;
+
+import java.util.*;
+
+import static java.util.Arrays.stream;
+import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.*;
+
+public class HostingAssetEntityValidatorRegistry {
+
+ private static final Map, HsEntityValidator> validators = new HashMap<>();
+ static {
+ // HOWTO: add (register) new HsHostingAssetType-specific validators
+ register(CLOUD_SERVER, new HsCloudServerHostingAssetValidator());
+ register(MANAGED_SERVER, new HsManagedServerHostingAssetValidator());
+ register(MANAGED_WEBSPACE, new HsManagedWebspaceHostingAssetValidator());
+ register(UNIX_USER, new HsUnixUserHostingAssetValidator());
+ register(EMAIL_ALIAS, new HsEMailAliasHostingAssetValidator());
+ register(DOMAIN_SETUP, new HsDomainSetupHostingAssetValidator());
+ register(DOMAIN_DNS_SETUP, new HsDomainDnsSetupHostingAssetValidator());
+ register(DOMAIN_HTTP_SETUP, new HsDomainHttpSetupHostingAssetValidator());
+ register(DOMAIN_SMTP_SETUP, new HsDomainSmtpSetupHostingAssetValidator());
+ register(DOMAIN_MBOX_SETUP, new HsDomainMboxSetupHostingAssetValidator());
+ register(EMAIL_ADDRESS, new HsEMailAddressHostingAssetValidator());
+ register(MARIADB_INSTANCE, new HsMariaDbInstanceHostingAssetValidator());
+ register(MARIADB_USER, new HsMariaDbUserHostingAssetValidator());
+ register(MARIADB_DATABASE, new HsMariaDbDatabaseHostingAssetValidator());
+ register(PGSQL_INSTANCE, new HsPostgreSqlDbInstanceHostingAssetValidator());
+ register(PGSQL_USER, new HsPostgreSqlUserHostingAssetValidator());
+ register(PGSQL_DATABASE, new HsPostgreSqlDatabaseHostingAssetValidator());
+ register(IPV4_NUMBER, new HsIPv4NumberHostingAssetValidator());
+ register(IPV6_NUMBER, new HsIPv6NumberHostingAssetValidator());
+ }
+
+ private static void register(final Enum type, final HsEntityValidator validator) {
+ stream(validator.propertyValidators).forEach( entry -> {
+ entry.verifyConsistency(Map.entry(type, validator));
+ });
+ validators.put(type, validator);
+ }
+
+ public static HsEntityValidator forType(final Enum type) {
+ if ( validators.containsKey(type)) {
+ return validators.get(type);
+ }
+ throw new IllegalArgumentException("no validator found for type " + type);
+ }
+
+ public static Set> types() {
+ return validators.keySet();
+ }
+}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsCloudServerHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsCloudServerHostingAssetValidator.java
new file mode 100644
index 00000000..b9719a54
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsCloudServerHostingAssetValidator.java
@@ -0,0 +1,22 @@
+package net.hostsharing.hsadminng.hs.hosting.asset.validators;
+
+import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
+
+import java.util.regex.Pattern;
+
+import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.CLOUD_SERVER;
+
+class HsCloudServerHostingAssetValidator extends HostingAssetEntityValidator {
+
+ HsCloudServerHostingAssetValidator() {
+ super(
+ CLOUD_SERVER,
+ AlarmContact.isOptional(),
+ NO_EXTRA_PROPERTIES);
+ }
+
+ @Override
+ protected Pattern identifierPattern(final HsHostingAsset assetEntity) {
+ return Pattern.compile("^vm[0-9][0-9][0-9][0-9]$");
+ }
+}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainDnsSetupHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainDnsSetupHostingAssetValidator.java
new file mode 100644
index 00000000..57ffc279
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainDnsSetupHostingAssetValidator.java
@@ -0,0 +1,185 @@
+package net.hostsharing.hsadminng.hs.hosting.asset.validators;
+
+import lombok.SneakyThrows;
+import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
+import net.hostsharing.hsadminng.system.SystemProcess;
+
+import java.util.List;
+import java.util.regex.Pattern;
+
+import static java.util.Arrays.stream;
+import static java.util.Optional.ofNullable;
+import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_DNS_SETUP;
+import static net.hostsharing.hsadminng.hs.validation.ArrayProperty.arrayOf;
+import static net.hostsharing.hsadminng.hs.validation.BooleanProperty.booleanProperty;
+import static net.hostsharing.hsadminng.hs.validation.IntegerProperty.integerProperty;
+import static net.hostsharing.hsadminng.hs.validation.StringProperty.stringProperty;
+
+// TODO.impl: make package private once we've migrated the legacy data
+public class HsDomainDnsSetupHostingAssetValidator extends HostingAssetEntityValidator {
+
+ // according to RFC 1035 (section 5) and RFC 1034
+ static final String RR_REGEX_NAME = "(\\*\\.)?([a-zA-Z0-9\\._-]+|@)[ \t]+";
+ static final String RR_REGEX_TTL = "(([1-9][0-9]*[mMhHdDwW]?)+[ \t]+)?";
+ static final String RR_REGEX_IN = "[iI][nN][ \t]+"; // record class IN for Internet
+ static final String RR_RECORD_TYPE = "[a-zA-Z]+[ \t]+";
+ static final String RR_RECORD_DATA = "(([^;]+)|(\".*\")|(\\(.*\\)))[ \t]*";
+ static final String RR_COMMENT = "(;.*)?";
+
+ static final String RR_REGEX_TTL_IN =
+ RR_REGEX_NAME + RR_REGEX_TTL + RR_REGEX_IN + RR_RECORD_TYPE + RR_RECORD_DATA + RR_COMMENT;
+
+ static final String RR_REGEX_IN_TTL =
+ RR_REGEX_NAME + RR_REGEX_IN + RR_REGEX_TTL + RR_RECORD_TYPE + RR_RECORD_DATA + RR_COMMENT;
+ public static final String IDENTIFIER_SUFFIX = "|DNS";
+
+ private static List zoneFileErrors = null; // TODO.impl: remove once legacy data is migrated
+
+ HsDomainDnsSetupHostingAssetValidator() {
+ super(
+ DOMAIN_DNS_SETUP,
+ AlarmContact.isOptional(),
+
+ integerProperty("TTL").min(0).withDefault(21600),
+ booleanProperty("auto-SOA").withDefault(true),
+ booleanProperty("auto-NS-RR").withDefault(true),
+ booleanProperty("auto-MX-RR").withDefault(true),
+ booleanProperty("auto-A-RR").withDefault(true),
+ booleanProperty("auto-AAAA-RR").withDefault(true),
+ booleanProperty("auto-MAILSERVICES-RR").withDefault(true),
+ booleanProperty("auto-AUTOCONFIG-RR").withDefault(true),
+ booleanProperty("auto-AUTODISCOVER-RR").withDefault(true),
+ booleanProperty("auto-DKIM-RR").withDefault(true),
+ booleanProperty("auto-SPF-RR").withDefault(true),
+ booleanProperty("auto-WILDCARD-MX-RR").withDefault(true),
+ booleanProperty("auto-WILDCARD-A-RR").withDefault(true),
+ booleanProperty("auto-WILDCARD-AAAA-RR").withDefault(true),
+ booleanProperty("auto-WILDCARD-SPF-RR").withDefault(true),
+ arrayOf(
+ stringProperty("user-RR").matchesRegEx(RR_REGEX_TTL_IN, RR_REGEX_IN_TTL).required()
+ ).optional());
+ }
+
+ @Override
+ protected Pattern identifierPattern(final HsHostingAsset assetEntity) {
+ return Pattern.compile("^" + Pattern.quote(assetEntity.getParentAsset().getIdentifier() + IDENTIFIER_SUFFIX) + "$");
+ }
+
+ @Override
+ public void preprocessEntity(final HsHostingAsset entity) {
+ super.preprocessEntity(entity);
+ if (entity.getIdentifier() == null) {
+ ofNullable(entity.getParentAsset()).ifPresent(pa -> entity.setIdentifier(pa.getIdentifier() + IDENTIFIER_SUFFIX));
+ }
+ }
+
+ @Override
+ @SneakyThrows
+ public List validateContext(final HsHostingAsset assetEntity) {
+ final var result = super.validateContext(assetEntity);
+
+ // TODO.spec: define which checks should get raised to error level
+ final var namedCheckZone = new SystemProcess("named-checkzone", fqdn(assetEntity));
+ final var zonefileString = toZonefileString(assetEntity);
+ final var zoneFileErrorResult = zoneFileErrors != null ? zoneFileErrors : result;
+ if (namedCheckZone.execute(zonefileString) != 0) {
+ // yes, named-checkzone writes error messages to stdout, not stderr
+ stream(namedCheckZone.getStdOut().split("\n"))
+ .map(line -> line.replaceAll(" stream-0x[0-9a-f]+:", "line "))
+ .map(line -> "[" + assetEntity.getIdentifier() + "] " + line)
+ .forEach(zoneFileErrorResult::add);
+ if (!namedCheckZone.getStdErr().isEmpty()) {
+ result.add("unexpected stderr output for " + namedCheckZone.getCommand() + ": " + namedCheckZone.getStdErr());
+ }
+ }
+ return result;
+ }
+
+ String toZonefileString(final HsHostingAsset assetEntity) {
+ // TODO.spec: we need to expand the templates (auto-...) in the same way as in Saltstack, with proper IP-numbers etc.
+ // TODO.impl: auto-AUTOCONFIG-RR auto-AUTODISCOVER-RR missing
+ return """
+ $TTL {ttl}
+
+ {auto-SOA}
+ {auto-NS-RR}
+ {auto-MX-RR}
+ {auto-A-RR}
+ {auto-AAAA-RR}
+ {auto-DKIM-RR}
+ {auto-SPF-RR}
+
+ {auto-WILDCARD-MX-RR}
+ {auto-WILDCARD-A-RR}
+ {auto-WILDCARD-AAAA-RR}
+ {auto-WILDCARD-SPF-RR}
+
+ {userRRs}
+ """
+ .replace("{ttl}", assetEntity.getDirectValue("TTL", Integer.class, 43200).toString())
+ .replace("{auto-SOA}", assetEntity.getDirectValue("auto-SOA", Boolean.class, false).equals(true)
+ ? """
+ {domain}. IN SOA h00.hostsharing.net. hostmaster.hostsharing.net. (
+ 1303649373 ; serial secs since Jan 1 1970
+ 6H ; refresh (>=10000)
+ 1H ; retry (>=1800)
+ 1W ; expire
+ 1H ; minimum
+ )
+ """
+ : "; no auto-SOA"
+ )
+ .replace("{auto-NS-RR}", assetEntity.getDirectValue("auto-NS-RR", Boolean.class, true)
+ ? """
+ {domain}. IN NS dns1.hostsharing.net.
+ {domain}. IN NS dns2.hostsharing.net.
+ {domain}. IN NS dns3.hostsharing.net.
+ """
+ : "; no auto-NS-RR")
+ .replace("{auto-MX-RR}", assetEntity.getDirectValue("auto-MX-RR", Boolean.class, true)
+ ? """
+ {domain}. IN MX 30 mailin1.hostsharing.net.
+ {domain}. IN MX 30 mailin2.hostsharing.net.
+ {domain}. IN MX 30 mailin3.hostsharing.net.
+ """
+ : "; no auto-MX-RR")
+ .replace("{auto-A-RR}", assetEntity.getDirectValue("auto-A-RR", Boolean.class, true)
+ ? "{domain}. IN A 83.223.95.160" // arbitrary IP-number
+ : "; no auto-A-RR")
+ .replace("{auto-AAAA-RR}", assetEntity.getDirectValue("auto-AAA-RR", Boolean.class, true)
+ ? "{domain}. IN AAAA 2a01:37:1000::53df:5fa0:0" // arbitrary IP-number
+ : "; no auto-AAAA-RR")
+ .replace("{auto-DKIM-RR}", assetEntity.getDirectValue("auto-DKIM-RR", Boolean.class, true)
+ ? "default._domainkey 21600 IN TXT \"v=DKIM1; h=sha256; k=rsa; s=email; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCmdM9d15bqe94zbHVcKKpUF875XoCWHKRap/sG3NJZ9xZ/BjfGXmqoEYeFNpX3CB7pOXhH5naq4N+6gTjArTviAiVThHXyebhrxaf1dVS4IUC6raTEyQrWPZUf7ZxXmcCYvOdV4jIQ8GRfxwxqibIJcmMiufXTLIgRUif5uaTgFwIDAQAB\""
+ : "; no auto-DKIM-RR")
+ .replace("{auto-SPF-RR}", assetEntity.getDirectValue("auto-SPF-RR", Boolean.class, true)
+ ? "{domain}. IN TXT \"v=spf1 include:spf.hostsharing.net ?all\""
+ : "; no auto-SPF-RR")
+ .replace("{auto-WILDCARD-MX-RR}", assetEntity.getDirectValue("auto-SPF-RR", Boolean.class, true)
+ ? """
+ *.{domain}. IN MX 30 mailin1.hostsharing.net.
+ *.{domain}. IN MX 30 mailin1.hostsharing.net.
+ *.{domain}. IN MX 30 mailin1.hostsharing.net.
+ """
+ : "; no auto-WILDCARD-MX-RR")
+ .replace("{auto-WILDCARD-A-RR}", assetEntity.getDirectValue("auto-WILDCARD-A-RR", Boolean.class, true)
+ ? "*.{domain}. IN A 83.223.95.160" // arbitrary IP-number
+ : "; no auto-WILDCARD-A-RR")
+ .replace("{auto-WILDCARD-AAAA-RR}", assetEntity.getDirectValue("auto-WILDCARD-AAAA-RR", Boolean.class, true)
+ ? "*.{domain}. IN AAAA 2a01:37:1000::53df:5fa0:0" // arbitrary IP-number
+ : "; no auto-WILDCARD-AAAA-RR")
+ .replace("{auto-WILDCARD-SPF-RR}", assetEntity.getDirectValue("auto-WILDCARD-SPF-RR", Boolean.class, true)
+ ? "*.{domain}. IN TXT \"v=spf1 include:spf.hostsharing.net ?all\""
+ : "; no auto-WILDCARD-SPF-RR")
+ .replace("{domain}", fqdn(assetEntity))
+ .replace("{userRRs}", getPropertyValues(assetEntity, "user-RR"));
+ }
+
+ private String fqdn(final HsHostingAsset assetEntity) {
+ return assetEntity.getIdentifier().substring(0, assetEntity.getIdentifier().length() - IDENTIFIER_SUFFIX.length());
+ }
+
+ public static void addZonefileErrorsTo(final List zoneFileErrors) {
+ HsDomainDnsSetupHostingAssetValidator.zoneFileErrors = zoneFileErrors;
+ }
+}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainHttpSetupHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainHttpSetupHostingAssetValidator.java
new file mode 100644
index 00000000..f98daea7
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainHttpSetupHostingAssetValidator.java
@@ -0,0 +1,56 @@
+package net.hostsharing.hsadminng.hs.hosting.asset.validators;
+
+import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
+
+import java.util.regex.Pattern;
+
+import static java.util.Optional.ofNullable;
+import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_HTTP_SETUP;
+import static net.hostsharing.hsadminng.hs.validation.ArrayProperty.arrayOf;
+import static net.hostsharing.hsadminng.hs.validation.BooleanProperty.booleanProperty;
+import static net.hostsharing.hsadminng.hs.validation.StringProperty.stringProperty;
+
+class HsDomainHttpSetupHostingAssetValidator extends HostingAssetEntityValidator {
+
+ public static final String IDENTIFIER_SUFFIX = "|HTTP";
+ public static final String FILESYSTEM_PATH = "^/.*";
+ public static final String SUBDOMAIN_NAME_REGEX = "(\\*|(?!-)[A-Za-z0-9-]{1,63}(? entity.setIdentifier(pa.getIdentifier() + IDENTIFIER_SUFFIX));
+ }
+ }
+}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainMboxSetupHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainMboxSetupHostingAssetValidator.java
new file mode 100644
index 00000000..41c1aa52
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainMboxSetupHostingAssetValidator.java
@@ -0,0 +1,34 @@
+package net.hostsharing.hsadminng.hs.hosting.asset.validators;
+
+import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
+
+import java.util.regex.Pattern;
+
+import static java.util.Optional.ofNullable;
+import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_MBOX_SETUP;
+
+class HsDomainMboxSetupHostingAssetValidator extends HostingAssetEntityValidator {
+
+ public static final String IDENTIFIER_SUFFIX = "|MBOX";
+
+ HsDomainMboxSetupHostingAssetValidator() {
+ super(
+ DOMAIN_MBOX_SETUP,
+ AlarmContact.isOptional(),
+
+ NO_EXTRA_PROPERTIES);
+ }
+
+ @Override
+ protected Pattern identifierPattern(final HsHostingAsset assetEntity) {
+ return Pattern.compile("^" + Pattern.quote(assetEntity.getParentAsset().getIdentifier() + IDENTIFIER_SUFFIX) + "$");
+ }
+
+ @Override
+ public void preprocessEntity(final HsHostingAsset entity) {
+ super.preprocessEntity(entity);
+ if (entity.getIdentifier() == null) {
+ ofNullable(entity.getParentAsset()).ifPresent(pa -> entity.setIdentifier(pa.getIdentifier() + IDENTIFIER_SUFFIX));
+ }
+ }
+}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSetupHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSetupHostingAssetValidator.java
new file mode 100644
index 00000000..a4ad06a4
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSetupHostingAssetValidator.java
@@ -0,0 +1,128 @@
+package net.hostsharing.hsadminng.hs.hosting.asset.validators;
+
+import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.regex.Pattern;
+
+import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_SETUP;
+import static net.hostsharing.hsadminng.hs.hosting.asset.validators.Dns.superDomain;
+import static net.hostsharing.hsadminng.hs.hosting.asset.validators.HsDomainHttpSetupHostingAssetValidator.SUBDOMAIN_NAME_REGEX;
+
+class HsDomainSetupHostingAssetValidator extends HostingAssetEntityValidator {
+
+ public static final String DOMAIN_NAME_PROPERTY_NAME = "domainName";
+
+ HsDomainSetupHostingAssetValidator() {
+ super(
+ DOMAIN_SETUP,
+ AlarmContact.isOptional(),
+
+ NO_EXTRA_PROPERTIES);
+ }
+
+ @Override
+ public List validateEntity(final HsHostingAsset assetEntity) {
+ final var violations = super.validateEntity(assetEntity);
+ if (!violations.isEmpty() || assetEntity.isLoaded()) {
+ // it makes no sense to do DNS-based validation
+ // if the entity is already persisted or
+ // if the identifier (domain name) or structure is already invalid
+ return violations;
+ }
+
+ final var dnsResult = new Dns(assetEntity.getIdentifier()).fetchRecordsOfType("TXT");
+ switch (dnsResult.status()) {
+ case Dns.Status.SUCCESS:
+ violations.addAll(handleDomainNameFound(assetEntity, dnsResult));
+ break;
+
+ case Dns.Status.NAME_NOT_FOUND:
+ violations.addAll(handleDomainNameNotFoundError(assetEntity, dnsResult));
+ break;
+
+ case Dns.Status.INVALID_NAME:
+ // should not happen because we validate the domain name at booking item level
+ violations.add("[DNS] invalid domain name '" + assetEntity.getIdentifier() + "'");
+ break;
+
+ case Dns.Status.SERVICE_UNAVAILABLE:
+ case Dns.Status.UNKNOWN_FAILURE:
+ violations.add("[DNS] lookup failed for domain name '" + assetEntity.getIdentifier() + "': " + dnsResult.exception());
+ break;
+ }
+ return violations;
+ }
+
+ private static String verificationCode(final HsHostingAsset assetEntity) {
+ return assetEntity.getBookingItem().getDirectValue("verificationCode", String.class);
+ }
+
+ @Override
+ protected Pattern identifierPattern(final HsHostingAsset assetEntity) {
+ if (assetEntity.getBookingItem() != null) {
+ final var bookingItemDomainName = assetEntity.getBookingItem()
+ .getDirectValue(DOMAIN_NAME_PROPERTY_NAME, String.class);
+ return Pattern.compile(bookingItemDomainName, Pattern.CASE_INSENSITIVE | Pattern.LITERAL);
+ }
+ final var parentDomainName = assetEntity.getParentAsset().getIdentifier();
+ return Pattern.compile(SUBDOMAIN_NAME_REGEX + "\\." + parentDomainName.replace(".", "\\."), Pattern.CASE_INSENSITIVE);
+ }
+
+ private static List handleDomainNameFound(final HsHostingAsset assetEntity, final Dns.Result dnsResult) {
+ final var violations = new ArrayList();
+ final var expectedTxtRecordValue = "Hostsharing-domain-setup-verification-code=" + verificationCode(assetEntity);
+ final var verificationFound = findTxtRecord(dnsResult, expectedTxtRecordValue)
+ .or(() -> superDomain(assetEntity.getIdentifier())
+ .flatMap(superDomainName -> findTxtRecord(
+ new Dns(superDomainName).fetchRecordsOfType("TXT"),
+ expectedTxtRecordValue))
+ );
+ if (verificationFound.isEmpty()) {
+ violations.add(
+ "[DNS] no TXT record '" + expectedTxtRecordValue +
+ "' found for domain name '" + assetEntity.getIdentifier() + "' (nor in its super-domain)");
+ }
+ return violations;
+ }
+
+ private static List handleDomainNameNotFoundError(final HsHostingAsset assetEntity, final Dns.Result dnsResult) {
+ final var violations = new ArrayList();
+ if (isDnsVerificationRequiredForUnregisteredDomain(assetEntity)) {
+ final var superDomain = superDomain(assetEntity.getIdentifier());
+ final var expectedTxtRecordValue = "Hostsharing-domain-setup-verification-code=" + verificationCode(assetEntity);
+ final var verificationFoundInSuperDomain = superDomain.map(superDomainName ->
+ {
+ final Dns.Result superDomainDnsResult = new Dns(superDomainName).fetchRecordsOfType("TXT");
+ if (superDomainDnsResult.status() != Dns.Status.SUCCESS) {
+ violations.add("[DNS] lookup failed for domain name '" + superDomainName + "': " + dnsResult.exception());
+ }
+ return superDomainDnsResult;
+ }
+ )
+ .flatMap(records -> findTxtRecord(records, expectedTxtRecordValue));
+ if (verificationFoundInSuperDomain.isEmpty()) {
+ violations.add(
+ "[DNS] no TXT record '" + expectedTxtRecordValue +
+ "' found for domain name '" + superDomain.orElseThrow() + "'");
+ }
+ } else {
+ // otherwise no DNS verification to be able to setup DNS for domains to register
+ }
+ return violations;
+ }
+
+ private static boolean isDnsVerificationRequiredForUnregisteredDomain(final HsHostingAsset assetEntity) {
+ return !Dns.isRegistrableDomain(assetEntity.getIdentifier())
+ && assetEntity.getParentAsset() == null;
+ }
+
+
+ private static Optional findTxtRecord(final Dns.Result result, final String expectedTxtRecordValue) {
+ return result.records().stream()
+ .filter(r -> r.contains(expectedTxtRecordValue))
+ .findAny();
+ }
+}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSmtpSetupHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSmtpSetupHostingAssetValidator.java
new file mode 100644
index 00000000..bc422029
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSmtpSetupHostingAssetValidator.java
@@ -0,0 +1,34 @@
+package net.hostsharing.hsadminng.hs.hosting.asset.validators;
+
+import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
+
+import java.util.regex.Pattern;
+
+import static java.util.Optional.ofNullable;
+import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_SMTP_SETUP;
+
+class HsDomainSmtpSetupHostingAssetValidator extends HostingAssetEntityValidator {
+
+ public static final String IDENTIFIER_SUFFIX = "|SMTP";
+
+ HsDomainSmtpSetupHostingAssetValidator() {
+ super(
+ DOMAIN_SMTP_SETUP,
+ AlarmContact.isOptional(),
+
+ NO_EXTRA_PROPERTIES);
+ }
+
+ @Override
+ protected Pattern identifierPattern(final HsHostingAsset assetEntity) {
+ return Pattern.compile("^" + Pattern.quote(assetEntity.getParentAsset().getIdentifier() + IDENTIFIER_SUFFIX) + "$");
+ }
+
+ @Override
+ public void preprocessEntity(final HsHostingAsset entity) {
+ super.preprocessEntity(entity);
+ if (entity.getIdentifier() == null) {
+ ofNullable(entity.getParentAsset()).ifPresent(pa -> entity.setIdentifier(pa.getIdentifier() + IDENTIFIER_SUFFIX));
+ }
+ }
+}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsEMailAddressHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsEMailAddressHostingAssetValidator.java
new file mode 100644
index 00000000..77c32768
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsEMailAddressHostingAssetValidator.java
@@ -0,0 +1,53 @@
+package net.hostsharing.hsadminng.hs.hosting.asset.validators;
+
+import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
+import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType;
+
+import java.util.regex.Pattern;
+
+import static java.util.Optional.ofNullable;
+import static net.hostsharing.hsadminng.hs.validation.ArrayProperty.arrayOf;
+import static net.hostsharing.hsadminng.hs.validation.StringProperty.stringProperty;
+
+class HsEMailAddressHostingAssetValidator extends HostingAssetEntityValidator {
+
+ private static final String TARGET_MAILBOX_REGEX = "^[a-z][a-z0-9]{2}[0-9]{2}(-[a-z0-9][a-z0-9\\.+_-]*)?$"; // also accepts legacy pac-names
+ private static final String EMAIL_ADDRESS_LOCAL_PART_REGEX = "[a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+"; // RFC 5322
+ private static final String EMAIL_ADDRESS_DOMAIN_PART_REGEX = "[a-zA-Z0-9.-]+";
+ private static final String EMAIL_ADDRESS_FULL_REGEX = "^(" + EMAIL_ADDRESS_LOCAL_PART_REGEX + ")?@" + EMAIL_ADDRESS_DOMAIN_PART_REGEX + "$";
+ private static final String NOBODY_REGEX = "^nobody$";
+ private static final String DEVNULL_REGEX = "^/dev/null$";
+ public static final int EMAIL_ADDRESS_MAX_LENGTH = 320; // according to RFC 5321 and RFC 5322
+
+ HsEMailAddressHostingAssetValidator() {
+ super( HsHostingAssetType.EMAIL_ADDRESS,
+ AlarmContact.isOptional(),
+
+ stringProperty("local-part").matchesRegEx("^" + EMAIL_ADDRESS_LOCAL_PART_REGEX + "$").writeOnce().optional(),
+ stringProperty("sub-domain").matchesRegEx("^" + EMAIL_ADDRESS_LOCAL_PART_REGEX + "$").writeOnce().optional(),
+ arrayOf(
+ stringProperty("target").maxLength(EMAIL_ADDRESS_MAX_LENGTH).matchesRegEx(TARGET_MAILBOX_REGEX, EMAIL_ADDRESS_FULL_REGEX, NOBODY_REGEX, DEVNULL_REGEX)
+ ).required().minLength(1));
+ }
+
+ @Override
+ public void preprocessEntity(final HsHostingAsset entity) {
+ super.preprocessEntity(entity);
+ super.preprocessEntity(entity);
+ if (entity.getIdentifier() == null) {
+ entity.setIdentifier(combineIdentifier(entity));
+ }
+ }
+
+ @Override
+ protected Pattern identifierPattern(final HsHostingAsset assetEntity) {
+ return Pattern.compile("^"+ Pattern.quote(combineIdentifier(assetEntity)) + "$");
+ }
+
+ private static String combineIdentifier(final HsHostingAsset emailAddressAssetEntity) {
+ return ofNullable(emailAddressAssetEntity.getDirectValue("local-part", String.class)).orElse("")
+ + "@"
+ + ofNullable(emailAddressAssetEntity.getDirectValue("sub-domain", String.class)).map(s -> s + ".").orElse("")
+ + emailAddressAssetEntity.getParentAsset().getParentAsset().getIdentifier();
+ }
+}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsEMailAliasHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsEMailAliasHostingAssetValidator.java
new file mode 100644
index 00000000..f6c412bb
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsEMailAliasHostingAssetValidator.java
@@ -0,0 +1,34 @@
+package net.hostsharing.hsadminng.hs.hosting.asset.validators;
+
+import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
+import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType;
+
+import java.util.regex.Pattern;
+
+import static net.hostsharing.hsadminng.hs.validation.ArrayProperty.arrayOf;
+import static net.hostsharing.hsadminng.hs.validation.StringProperty.stringProperty;
+
+class HsEMailAliasHostingAssetValidator extends HostingAssetEntityValidator {
+
+ private static final String UNIX_USER_REGEX = "^[a-z][a-z0-9]{2}[0-9]{2}(-[a-z0-9][a-z0-9\\._-]*)?$"; // also accepts legacy pac-names
+ private static final String EMAIL_ADDRESS_REGEX = "^[a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+@[a-zA-Z0-9.-]+$"; // RFC 5322
+ private static final String INCLUDE_REGEX = "^:include:/.*$";
+ private static final String PIPE_REGEX = "^\\|.*$";
+ private static final String DEV_NULL_REGEX = "^/dev/null$";
+ public static final int EMAIL_ADDRESS_MAX_LENGTH = 320; // according to RFC 5321 and RFC 5322
+
+ HsEMailAliasHostingAssetValidator() {
+ super( HsHostingAssetType.EMAIL_ALIAS,
+ AlarmContact.isOptional(),
+
+ arrayOf(
+ stringProperty("target").maxLength(EMAIL_ADDRESS_MAX_LENGTH).matchesRegEx(UNIX_USER_REGEX, EMAIL_ADDRESS_REGEX, INCLUDE_REGEX, PIPE_REGEX, DEV_NULL_REGEX)
+ ).required().minLength(1));
+ }
+
+ @Override
+ protected Pattern identifierPattern(final HsHostingAsset assetEntity) {
+ final var webspaceIdentifier = assetEntity.getParentAsset().getIdentifier();
+ return Pattern.compile("^"+webspaceIdentifier+"$|^"+webspaceIdentifier+"-[a-z0-9][a-z0-9\\._-]*$");
+ }
+}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsIPv4NumberHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsIPv4NumberHostingAssetValidator.java
new file mode 100644
index 00000000..b237729e
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsIPv4NumberHostingAssetValidator.java
@@ -0,0 +1,26 @@
+package net.hostsharing.hsadminng.hs.hosting.asset.validators;
+
+import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
+
+import java.util.regex.Pattern;
+
+import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.IPV4_NUMBER;
+
+class HsIPv4NumberHostingAssetValidator extends HostingAssetEntityValidator {
+
+ private static final Pattern IPV4_REGEX = Pattern.compile("^((25[0-5]|(2[0-4]|1\\d|[1-9]|)\\d)\\.?\\b){4}$");
+
+ HsIPv4NumberHostingAssetValidator() {
+ super(
+ IPV4_NUMBER,
+ AlarmContact.isOptional(),
+
+ NO_EXTRA_PROPERTIES
+ );
+ }
+
+ @Override
+ protected Pattern identifierPattern(final HsHostingAsset assetEntity) {
+ return IPV4_REGEX;
+ }
+}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsIPv6NumberHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsIPv6NumberHostingAssetValidator.java
new file mode 100644
index 00000000..873a73eb
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsIPv6NumberHostingAssetValidator.java
@@ -0,0 +1,49 @@
+package net.hostsharing.hsadminng.hs.hosting.asset.validators;
+
+import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
+
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.util.List;
+import java.util.regex.Pattern;
+
+import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.IPV6_NUMBER;
+
+class HsIPv6NumberHostingAssetValidator extends HostingAssetEntityValidator {
+
+ // simplified pattern, the real check is done by letting Java parse the address
+ private static final Pattern IPV6_REGEX = Pattern.compile("([a-f0-9:]+:+)+[a-f0-9]+");
+
+ HsIPv6NumberHostingAssetValidator() {
+ super(
+ IPV6_NUMBER,
+ AlarmContact.isOptional(),
+
+ NO_EXTRA_PROPERTIES
+ );
+ }
+
+ @Override
+ public List validateEntity(final HsHostingAsset assetEntity) {
+ final var violations = super.validateEntity(assetEntity);
+
+ if (!isValidIPv6Address(assetEntity.getIdentifier())) {
+ violations.add("'identifier' expected to be a valid IPv6 address, but is '" + assetEntity.getIdentifier() + "'");
+ }
+
+ return violations;
+ }
+
+ @Override
+ protected Pattern identifierPattern(final HsHostingAsset assetEntity) {
+ return IPV6_REGEX;
+ }
+
+ private boolean isValidIPv6Address(final String identifier) {
+ try {
+ return InetAddress.getByName(identifier) instanceof java.net.Inet6Address;
+ } catch (UnknownHostException e) {
+ return false;
+ }
+ }
+}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsManagedServerHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsManagedServerHostingAssetValidator.java
new file mode 100644
index 00000000..99138e0e
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsManagedServerHostingAssetValidator.java
@@ -0,0 +1,60 @@
+package net.hostsharing.hsadminng.hs.hosting.asset.validators;
+
+import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
+
+import java.util.regex.Pattern;
+
+import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_SERVER;
+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 HostingAssetEntityValidator {
+
+ public HsManagedServerHostingAssetValidator() {
+ super(
+ MANAGED_SERVER,
+ AlarmContact.isOptional(),
+
+ // 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),
+
+ // 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)
+ );
+ }
+
+ @Override
+ protected Pattern identifierPattern(final HsHostingAsset assetEntity) {
+ return Pattern.compile("^vm[0-9][0-9][0-9][0-9]$");
+ }
+}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsManagedWebspaceHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsManagedWebspaceHostingAssetValidator.java
new file mode 100644
index 00000000..4579faf8
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsManagedWebspaceHostingAssetValidator.java
@@ -0,0 +1,50 @@
+package net.hostsharing.hsadminng.hs.hosting.asset.validators;
+
+import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
+import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRealEntity;
+
+import jakarta.persistence.EntityManager;
+import java.util.regex.Pattern;
+
+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.hs.validation.IntegerProperty.integerProperty;
+
+class HsManagedWebspaceHostingAssetValidator extends HostingAssetEntityValidator {
+ public HsManagedWebspaceHostingAssetValidator() {
+ super(
+ MANAGED_WEBSPACE,
+ AlarmContact.isOptional(),
+ integerProperty("groupid").readOnly()
+ );
+ }
+
+ @Override
+ protected Pattern identifierPattern(final HsHostingAsset assetEntity) {
+ final var prefixPattern =
+ !assetEntity.isLoaded()
+ ? assetEntity.getRelatedProject().getDebitor().getDefaultPrefix()
+ : "[a-z][a-z0-9][a-z0-9]";
+ return Pattern.compile("^" + prefixPattern + "[0-9][0-9]$");
+ }
+
+ @Override
+ public void postPersist(final EntityManager em, final HsHostingAsset webspaceAsset) {
+ if (!webspaceAsset.isLoaded()) {
+ final var unixUserAsset = HsHostingAssetRealEntity.builder()
+ .type(UNIX_USER)
+ .parentAsset(em.find(HsHostingAssetRealEntity.class, webspaceAsset.getUuid()))
+ .identifier(webspaceAsset.getIdentifier())
+ .caption(webspaceAsset.getIdentifier() + " webspace user")
+ .build();
+ webspaceAsset.getSubHostingAssets().add(unixUserAsset);
+ new HostingAssetEntitySaveProcessor(em, unixUserAsset)
+ .preprocessEntity()
+ .validateEntity()
+ .prepareForSave()
+ .save()
+ .validateContext();
+ webspaceAsset.getConfig().put("groupid", unixUserAsset.getConfig().get("userid"));
+ }
+ }
+}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsMariaDbDatabaseHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsMariaDbDatabaseHostingAssetValidator.java
new file mode 100644
index 00000000..823308ed
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsMariaDbDatabaseHostingAssetValidator.java
@@ -0,0 +1,27 @@
+package net.hostsharing.hsadminng.hs.hosting.asset.validators;
+
+import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
+
+import java.util.regex.Pattern;
+
+import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MARIADB_DATABASE;
+import static net.hostsharing.hsadminng.hs.validation.StringProperty.stringProperty;
+
+class HsMariaDbDatabaseHostingAssetValidator extends HostingAssetEntityValidator {
+
+ final static String HEAD_REGEXP = "^MAD\\|";
+
+ public HsMariaDbDatabaseHostingAssetValidator() {
+ super(
+ MARIADB_DATABASE,
+ AlarmContact.isOptional(),
+
+ stringProperty("encoding").matchesRegEx("[a-z0-9_]+").maxLength(24).provided("latin1", "utf8").withDefault("utf8"));
+ }
+
+ @Override
+ protected Pattern identifierPattern(final HsHostingAsset assetEntity) {
+ final var webspaceIdentifier = assetEntity.getParentAsset().getParentAsset().getIdentifier();
+ return Pattern.compile(HEAD_REGEXP+webspaceIdentifier+"$|"+HEAD_REGEXP+webspaceIdentifier+"_[a-zA-Z0-9_]+$");
+ }
+}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsMariaDbInstanceHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsMariaDbInstanceHostingAssetValidator.java
new file mode 100644
index 00000000..d9509906
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsMariaDbInstanceHostingAssetValidator.java
@@ -0,0 +1,37 @@
+package net.hostsharing.hsadminng.hs.hosting.asset.validators;
+
+import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
+
+import java.util.regex.Pattern;
+
+import static java.util.Optional.ofNullable;
+import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MARIADB_INSTANCE;
+
+class HsMariaDbInstanceHostingAssetValidator extends HostingAssetEntityValidator {
+
+ final static String DEFAULT_INSTANCE_IDENTIFIER_SUFFIX = "|MariaDB.default"; // TODO.spec: specify instance naming
+
+ public HsMariaDbInstanceHostingAssetValidator() {
+ super(
+ MARIADB_INSTANCE,
+ AlarmContact.isOptional(),
+ NO_EXTRA_PROPERTIES); // TODO.spec: specify instance properties, e.g. installed extensions
+ }
+
+ @Override
+ protected Pattern identifierPattern(final HsHostingAsset assetEntity) {
+ return Pattern.compile(
+ "^" + Pattern.quote(assetEntity.getParentAsset().getIdentifier()
+ + DEFAULT_INSTANCE_IDENTIFIER_SUFFIX)
+ + "$");
+ }
+
+ @Override
+ public void preprocessEntity(final HsHostingAsset entity) {
+ super.preprocessEntity(entity);
+ if (entity.getIdentifier() == null) {
+ ofNullable(entity.getParentAsset()).ifPresent(pa -> entity.setIdentifier(
+ pa.getIdentifier() + DEFAULT_INSTANCE_IDENTIFIER_SUFFIX));
+ }
+ }
+}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsMariaDbUserHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsMariaDbUserHostingAssetValidator.java
new file mode 100644
index 00000000..58a33520
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsMariaDbUserHostingAssetValidator.java
@@ -0,0 +1,35 @@
+package net.hostsharing.hsadminng.hs.hosting.asset.validators;
+
+import net.hostsharing.hsadminng.hash.HashGenerator;
+import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
+
+import java.util.regex.Pattern;
+
+import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MARIADB_USER;
+import static net.hostsharing.hsadminng.hs.validation.PasswordProperty.passwordProperty;
+
+class HsMariaDbUserHostingAssetValidator extends HostingAssetEntityValidator {
+
+ final static String HEAD_REGEXP = "^MAU\\|";
+
+ public HsMariaDbUserHostingAssetValidator() {
+ super(
+ MARIADB_USER,
+ AlarmContact.isOptional(),
+
+ // TODO.impl: we need to be able to suppress updating of fields etc., something like this:
+ // withFieldValidation(
+ // referenceProperty(alarmContact).isOptional(),
+ // referenceProperty(parentAsset).isWriteOnce(),
+ // referenceProperty(assignedToAsset).isWriteOnce(),
+ // );
+
+ passwordProperty("password").minLength(8).maxLength(40).hashedUsing(HashGenerator.Algorithm.MYSQL_NATIVE).writeOnly());
+ }
+
+ @Override
+ protected Pattern identifierPattern(final HsHostingAsset assetEntity) {
+ final var webspaceIdentifier = assetEntity.getParentAsset().getIdentifier();
+ return Pattern.compile(HEAD_REGEXP+webspaceIdentifier+"$|"+HEAD_REGEXP+webspaceIdentifier+"_[a-zA-Z0-9_]+$");
+ }
+}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsPostgreSqlDatabaseHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsPostgreSqlDatabaseHostingAssetValidator.java
new file mode 100644
index 00000000..830b2fbf
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsPostgreSqlDatabaseHostingAssetValidator.java
@@ -0,0 +1,30 @@
+package net.hostsharing.hsadminng.hs.hosting.asset.validators;
+
+import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
+
+import java.util.regex.Pattern;
+
+import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.PGSQL_DATABASE;
+import static net.hostsharing.hsadminng.hs.validation.StringProperty.stringProperty;
+
+class HsPostgreSqlDatabaseHostingAssetValidator extends HostingAssetEntityValidator {
+
+ final static String HEAD_REGEXP = "^PGD\\|";
+
+ public HsPostgreSqlDatabaseHostingAssetValidator() {
+ super(
+ PGSQL_DATABASE,
+ AlarmContact.isOptional(),
+
+ stringProperty("encoding").matchesRegEx("[A-Z0-9_]+").maxLength(24).provided("LATIN1", "UTF8").withDefault("UTF8")
+
+ // TODO.spec: PostgreSQL extensions in instance and here? also decide which. Free selection or booleans/checkboxes?
+ );
+ }
+
+ @Override
+ protected Pattern identifierPattern(final HsHostingAsset assetEntity) {
+ final var webspaceIdentifier = assetEntity.getParentAsset().getParentAsset().getIdentifier();
+ return Pattern.compile(HEAD_REGEXP+webspaceIdentifier+"$|"+HEAD_REGEXP+webspaceIdentifier+"_[a-zA-Z0-9_]+$");
+ }
+}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsPostgreSqlDbInstanceHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsPostgreSqlDbInstanceHostingAssetValidator.java
new file mode 100644
index 00000000..70de55f9
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsPostgreSqlDbInstanceHostingAssetValidator.java
@@ -0,0 +1,39 @@
+package net.hostsharing.hsadminng.hs.hosting.asset.validators;
+
+import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
+
+import java.util.regex.Pattern;
+
+import static java.util.Optional.ofNullable;
+import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.PGSQL_INSTANCE;
+
+class HsPostgreSqlDbInstanceHostingAssetValidator extends HostingAssetEntityValidator {
+
+ final static String DEFAULT_INSTANCE_IDENTIFIER_SUFFIX = "|PgSql.default"; // TODO.spec: specify instance naming
+
+ public HsPostgreSqlDbInstanceHostingAssetValidator() {
+ super(
+ PGSQL_INSTANCE,
+ AlarmContact.isOptional(),
+
+ // TODO.spec: PostgreSQL extensions in database and here? also decide which. Free selection or booleans/checkboxes?
+ NO_EXTRA_PROPERTIES); // TODO.spec: specify instance properties, e.g. installed extensions
+ }
+
+ @Override
+ protected Pattern identifierPattern(final HsHostingAsset assetEntity) {
+ return Pattern.compile(
+ "^" + Pattern.quote(assetEntity.getParentAsset().getIdentifier()
+ + DEFAULT_INSTANCE_IDENTIFIER_SUFFIX)
+ + "$");
+ }
+
+ @Override
+ public void preprocessEntity(final HsHostingAsset entity) {
+ super.preprocessEntity(entity);
+ if (entity.getIdentifier() == null) {
+ ofNullable(entity.getParentAsset()).ifPresent(pa -> entity.setIdentifier(
+ pa.getIdentifier() + DEFAULT_INSTANCE_IDENTIFIER_SUFFIX));
+ }
+ }
+}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsPostgreSqlUserHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsPostgreSqlUserHostingAssetValidator.java
new file mode 100644
index 00000000..e10b6e6c
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsPostgreSqlUserHostingAssetValidator.java
@@ -0,0 +1,35 @@
+package net.hostsharing.hsadminng.hs.hosting.asset.validators;
+
+import net.hostsharing.hsadminng.hash.HashGenerator;
+import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
+
+import java.util.regex.Pattern;
+
+import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.PGSQL_USER;
+import static net.hostsharing.hsadminng.hs.validation.PasswordProperty.passwordProperty;
+
+class HsPostgreSqlUserHostingAssetValidator extends HostingAssetEntityValidator {
+
+ final static String HEAD_REGEXP = "^PGU\\|";
+
+ public HsPostgreSqlUserHostingAssetValidator() {
+ super(
+ PGSQL_USER,
+ AlarmContact.isOptional(),
+
+ // TODO.impl: we need to be able to suppress updating of fields etc., something like this:
+ // withFieldValidation(
+ // referenceProperty(alarmContact).isOptional(),
+ // referenceProperty(parentAsset).isWriteOnce(),
+ // referenceProperty(assignedToAsset).isWriteOnce(),
+ // );
+
+ passwordProperty("password").minLength(8).maxLength(40).hashedUsing(HashGenerator.Algorithm.SCRAM_SHA256).writeOnly());
+ }
+
+ @Override
+ protected Pattern identifierPattern(final HsHostingAsset assetEntity) {
+ final var webspaceIdentifier = assetEntity.getParentAsset().getIdentifier();
+ return Pattern.compile(HEAD_REGEXP+webspaceIdentifier+"$|"+HEAD_REGEXP+webspaceIdentifier+"_[a-zA-Z0-9_]+$");
+ }
+}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsUnixUserHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsUnixUserHostingAssetValidator.java
new file mode 100644
index 00000000..a53b536f
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsUnixUserHostingAssetValidator.java
@@ -0,0 +1,60 @@
+package net.hostsharing.hsadminng.hs.hosting.asset.validators;
+
+import net.hostsharing.hsadminng.hash.HashGenerator;
+import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
+import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType;
+import net.hostsharing.hsadminng.hs.validation.PropertiesProvider;
+
+import jakarta.persistence.EntityManager;
+import java.util.regex.Pattern;
+
+import static net.hostsharing.hsadminng.hs.validation.BooleanProperty.booleanProperty;
+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 HostingAssetEntityValidator {
+
+ private static final int DASH_LENGTH = "-".length();
+
+ HsUnixUserHostingAssetValidator() {
+ super(
+ HsHostingAssetType.UNIX_USER,
+ AlarmContact.isOptional(),
+
+ booleanProperty("locked").readOnly(),
+ integerProperty("userid").readOnly().initializedBy(HsUnixUserHostingAssetValidator::computeUserId),
+
+ integerProperty("SSD hard quota").unit("MB").maxFrom("SSD").withFactor(1024).optional(),
+ integerProperty("SSD soft quota").unit("MB").maxFrom("SSD hard quota").optional(),
+ integerProperty("HDD hard quota").unit("MB").maxFrom("HDD").withFactor(1024).optional(),
+ integerProperty("HDD soft quota").unit("MB").maxFrom("HDD hard quota").optional(),
+ stringProperty("shell")
+ // TODO.spec: do we want to change them all to /usr/bin/, also in import?
+ .provided("/bin/false", "/bin/bash", "/bin/csh", "/bin/dash", "/usr/bin/tcsh", "/usr/bin/zsh", "/usr/bin/passwd")
+ .withDefault("/bin/false"),
+ stringProperty("homedir").readOnly().renderedBy(HsUnixUserHostingAssetValidator::computeHomedir),
+ stringProperty("totpKey").matchesRegEx("^0x([0-9A-Fa-f]{2})+$").minLength(20).maxLength(256).undisclosed().writeOnly().optional(),
+ passwordProperty("password").minLength(8).maxLength(40).hashedUsing(HashGenerator.Algorithm.LINUX_SHA512).writeOnly());
+ // TODO.spec: public SSH keys? (only if hsadmin-ng is only accessible with 2FA)
+ }
+
+ @Override
+ protected Pattern identifierPattern(final HsHostingAsset assetEntity) {
+ final var webspaceIdentifier = assetEntity.getParentAsset().getIdentifier();
+ return Pattern.compile("^"+webspaceIdentifier+"$|^"+webspaceIdentifier+"-[a-z0-9\\._-]+$");
+ }
+
+ private static String computeHomedir(final EntityManager em, final PropertiesProvider propertiesProvider) {
+ final var entity = (HsHostingAsset) propertiesProvider;
+ final var webspaceName = entity.getParentAsset().getIdentifier();
+ return "/home/pacs/" + webspaceName
+ + "/users/" + entity.getIdentifier().substring(webspaceName.length()+DASH_LENGTH);
+ }
+
+ private static Integer computeUserId(final EntityManager em, final PropertiesProvider propertiesProvider) {
+ final Object result = em.createNativeQuery("SELECT nextval('hs_hosting_asset_unixuser_system_id_seq')", Integer.class)
+ .getSingleResult();
+ return (Integer) result;
+ }
+}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/README.md b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/README.md
new file mode 100644
index 00000000..72470290
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/README.md
@@ -0,0 +1,40 @@
+### HsHostingAsset-Validation
+
+There is just a single `HsHostingAsset` interface and `HsHostingAssetEntity` entity for all types of hosting assets like Managed-Server, Managed-Webspace, Unix-Users, Databases etc. These are distinguished by `HsHostingAssetType HsHostingAsset.type`.
+
+For each of these types, a distinct validator has to be
+implemented as a subclass of `HsHostingAssetValidator` which needs to be registered (see `HsHostingAssetValidatorRegistry`) for the relevant type(s).
+
+### Kinds of Validations
+
+#### Identifier validation
+
+The identifier of a Hosting-Asset is for example the Webspace-Name like "xyz00" or a Unix-User-Name like "xyz00-test".
+
+To validate the identifier, vverride the method `identifierPattern(...)` and return a regular expression to validate the identifier against. The regular expression can depend on the actual entity instance.
+
+#### Reference validation
+
+References in this context are:
+- the related Booking-Item,
+- the parent-Hosting-Asset,
+- the Assigned-To-Hosting-Asset and
+- the Contact.
+
+The first parameters of the `HsHostingAssetValidator` superclass take rule descriptors for these references. These are all Subclasses fo
+
+### Validation Order
+
+The validations are called in a sensible order. E.g. if a property value is not numeric, it makes no sense to check the total sum of such values to be within certain numeric values. And if the related booking item is of wrong type, it makes no sense to validate limits against sub-entities.
+
+Properties are validated all at once, though. Thus, if multiple properties fail validation, all error messages are returned at once.
+
+In general, the validation es executed in this order:
+
+1. the entity itself
+ 1. its references
+ 2. its properties
+2. the limits of the parent entity (parent asset + booking item)
+3. limits against the own own-sub-entities
+
+This implementation can be found in `HsHostingAssetValidator.validate`.
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountController.java
index 764d0a4a..9f39767f 100644
--- a/src/main/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountController.java
+++ b/src/main/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountController.java
@@ -74,11 +74,11 @@ public class HsOfficeBankAccountController implements HsOfficeBankAccountsApi {
public ResponseEntity getBankAccountByUuid(
final String currentUser,
final String assumedRoles,
- final UUID BankAccountUuid) {
+ final UUID bankAccountUuid) {
context.define(currentUser, assumedRoles);
- final var result = bankAccountRepo.findByUuid(BankAccountUuid);
+ final var result = bankAccountRepo.findByUuid(bankAccountUuid);
if (result.isEmpty()) {
return ResponseEntity.notFound().build();
}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountEntity.java
index fd6b0c44..94fe2b16 100644
--- a/src/main/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountEntity.java
+++ b/src/main/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountEntity.java
@@ -2,17 +2,20 @@ package net.hostsharing.hsadminng.hs.office.bankaccount;
import lombok.*;
import lombok.experimental.FieldNameConstants;
-import net.hostsharing.hsadminng.errors.DisplayName;
-import net.hostsharing.hsadminng.hs.office.migration.HasUuid;
+import net.hostsharing.hsadminng.errors.DisplayAs;
+import net.hostsharing.hsadminng.rbac.rbacobject.BaseEntity;
+import net.hostsharing.hsadminng.rbac.rbacdef.RbacView;
import net.hostsharing.hsadminng.stringify.Stringify;
import net.hostsharing.hsadminng.stringify.Stringifyable;
-import jakarta.persistence.Entity;
-import jakarta.persistence.GeneratedValue;
-import jakarta.persistence.Id;
-import jakarta.persistence.Table;
+import jakarta.persistence.*;
+import java.io.IOException;
import java.util.UUID;
+import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.*;
+import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.*;
+import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.RbacUserReference.UserRole.CREATOR;
+import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.*;
import static net.hostsharing.hsadminng.stringify.Stringify.stringify;
@Entity
@@ -23,18 +26,21 @@ import static net.hostsharing.hsadminng.stringify.Stringify.stringify;
@NoArgsConstructor
@AllArgsConstructor
@FieldNameConstants
-@DisplayName("BankAccount")
-public class HsOfficeBankAccountEntity implements HasUuid, Stringifyable {
+@DisplayAs("BankAccount")
+public class HsOfficeBankAccountEntity implements BaseEntity, Stringifyable {
private static Stringify toString = stringify(HsOfficeBankAccountEntity.class, "bankAccount")
+ .withIdProp(HsOfficeBankAccountEntity::getIban)
.withProp(Fields.holder, HsOfficeBankAccountEntity::getHolder)
- .withProp(Fields.iban, HsOfficeBankAccountEntity::getIban)
.withProp(Fields.bic, HsOfficeBankAccountEntity::getBic);
@Id
@GeneratedValue
private UUID uuid;
+ @Version
+ private int version;
+
private String holder;
private String iban;
@@ -50,4 +56,28 @@ public class HsOfficeBankAccountEntity implements HasUuid, Stringifyable {
public String toShortString() {
return holder;
}
+
+ public static RbacView rbac() {
+ return rbacViewFor("bankAccount", HsOfficeBankAccountEntity.class)
+ .withIdentityView(SQL.projection("iban"))
+ .withUpdatableColumns("holder", "iban", "bic")
+
+ .toRole("global", GUEST).grantPermission(INSERT)
+
+ .createRole(OWNER, (with) -> {
+ with.owningUser(CREATOR);
+ with.incomingSuperRole(GLOBAL, ADMIN);
+ with.permission(DELETE);
+ })
+ .createSubRole(ADMIN, (with) -> {
+ with.permission(UPDATE);
+ })
+ .createSubRole(REFERRER, (with) -> {
+ with.permission(SELECT);
+ });
+ }
+
+ public static void main(String[] args) throws IOException {
+ rbac().generateWithBaseFileName("5-hs-office/505-bankaccount/5053-hs-office-bankaccount-rbac");
+ }
}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountRepository.java
index 92b12960..11de3bdb 100644
--- a/src/main/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountRepository.java
+++ b/src/main/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountRepository.java
@@ -13,13 +13,15 @@ public interface HsOfficeBankAccountRepository extends Repository findByOptionalHolderLike(String holder);
+ List findByOptionalHolderLikeImpl(String holder);
+ default List findByOptionalHolderLike(String holder) {
+ return findByOptionalHolderLikeImpl(holder == null ? "" : holder);
+ }
- List findByIbanOrderByIban(String iban);
+ List findByIbanOrderByIbanAsc(String iban);
S save(S entity);
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContact.java b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContact.java
new file mode 100644
index 00000000..9450e331
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContact.java
@@ -0,0 +1,106 @@
+package net.hostsharing.hsadminng.hs.office.contact;
+
+import io.hypersistence.utils.hibernate.type.json.JsonType;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import lombok.experimental.FieldNameConstants;
+import lombok.experimental.SuperBuilder;
+import net.hostsharing.hsadminng.errors.DisplayAs;
+import net.hostsharing.hsadminng.mapper.PatchableMapWrapper;
+import net.hostsharing.hsadminng.rbac.rbacobject.BaseEntity;
+import net.hostsharing.hsadminng.stringify.Stringify;
+import net.hostsharing.hsadminng.stringify.Stringifyable;
+import org.hibernate.annotations.GenericGenerator;
+import org.hibernate.annotations.Type;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.Id;
+import jakarta.persistence.MappedSuperclass;
+import jakarta.persistence.Transient;
+import jakarta.persistence.Version;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+
+import static net.hostsharing.hsadminng.stringify.Stringify.stringify;
+
+@MappedSuperclass
+@Getter
+@Setter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor(access = AccessLevel.PROTECTED)
+@SuperBuilder(toBuilder = true)
+@FieldNameConstants
+@DisplayAs("Contact")
+public class HsOfficeContact implements Stringifyable, BaseEntity {
+
+ private static Stringify toString = stringify(HsOfficeContact.class, "contact")
+ .withProp(Fields.caption, HsOfficeContact::getCaption)
+ .withProp(Fields.emailAddresses, HsOfficeContact::getEmailAddresses);
+
+ @Id
+ @GeneratedValue(generator = "UUID")
+ @GenericGenerator(name = "UUID", strategy = "org.hibernate.id.UUIDGenerator")
+ private UUID uuid;
+
+ @Version
+ private int version;
+
+ @Column(name = "caption")
+ private String caption;
+
+ @Column(name = "postaladdress")
+ private String postalAddress; // multiline free-format text
+
+ @Builder.Default
+ @Setter(AccessLevel.NONE)
+ @Type(JsonType.class)
+ @Column(name = "emailaddresses")
+ private Map emailAddresses = new HashMap<>();
+
+ @Transient
+ private PatchableMapWrapper emailAddressesWrapper;
+
+ @Builder.Default
+ @Setter(AccessLevel.NONE)
+ @Type(JsonType.class)
+ @Column(name = "phonenumbers")
+ private Map phoneNumbers = new HashMap<>();
+
+ @Transient
+ private PatchableMapWrapper phoneNumbersWrapper;
+
+ public PatchableMapWrapper getEmailAddresses() {
+ return PatchableMapWrapper.of(
+ emailAddressesWrapper,
+ (newWrapper) -> {emailAddressesWrapper = newWrapper;},
+ emailAddresses);
+ }
+
+ public void putEmailAddresses(Map newEmailAddresses) {
+ getEmailAddresses().assign(newEmailAddresses);
+ }
+
+ public PatchableMapWrapper getPhoneNumbers() {
+ return PatchableMapWrapper.of(phoneNumbersWrapper, (newWrapper) -> {phoneNumbersWrapper = newWrapper;}, phoneNumbers);
+ }
+
+ public void putPhoneNumbers(Map newPhoneNumbers) {
+ getPhoneNumbers().assign(newPhoneNumbers);
+ }
+
+ @Override
+ public String toString() {
+ return toString.apply(this);
+ }
+
+ @Override
+ public String toShortString() {
+ return caption;
+ }
+}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactController.java
index 073587f2..cee7e28a 100644
--- a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactController.java
+++ b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactController.java
@@ -14,6 +14,9 @@ import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBui
import java.util.List;
import java.util.UUID;
+import java.util.function.BiConsumer;
+
+import static net.hostsharing.hsadminng.mapper.KeyValueMap.from;
@RestController
@@ -26,17 +29,17 @@ public class HsOfficeContactController implements HsOfficeContactsApi {
private Mapper mapper;
@Autowired
- private HsOfficeContactRepository contactRepo;
+ private HsOfficeContactRbacRepository contactRepo;
@Override
@Transactional(readOnly = true)
public ResponseEntity> listContacts(
final String currentUser,
final String assumedRoles,
- final String label) {
+ final String caption) {
context.define(currentUser, assumedRoles);
- final var entities = contactRepo.findContactByOptionalLabelLike(label);
+ final var entities = contactRepo.findContactByOptionalCaptionLike(caption);
final var resources = mapper.mapList(entities, HsOfficeContactResource.class);
return ResponseEntity.ok(resources);
@@ -51,7 +54,7 @@ public class HsOfficeContactController implements HsOfficeContactsApi {
context.define(currentUser, assumedRoles);
- final var entityToSave = mapper.map(body, HsOfficeContactEntity.class);
+ final var entityToSave = mapper.map(body, HsOfficeContactRbacEntity.class, RESOURCE_TO_ENTITY_POSTMAPPER);
final var saved = contactRepo.save(entityToSave);
@@ -108,10 +111,16 @@ public class HsOfficeContactController implements HsOfficeContactsApi {
final var current = contactRepo.findByUuid(contactUuid).orElseThrow();
- new HsOfficeContactEntityPatch(current).apply(body);
+ new HsOfficeContactEntityPatcher(current).apply(body);
final var saved = contactRepo.save(current);
final var mapped = mapper.map(saved, HsOfficeContactResource.class);
return ResponseEntity.ok(mapped);
}
+
+ @SuppressWarnings("unchecked")
+ final BiConsumer RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> {
+ entity.putEmailAddresses(from(resource.getEmailAddresses()));
+ entity.putPhoneNumbers(from(resource.getPhoneNumbers()));
+ };
}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntity.java
deleted file mode 100644
index c3ecb6be..00000000
--- a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntity.java
+++ /dev/null
@@ -1,56 +0,0 @@
-package net.hostsharing.hsadminng.hs.office.contact;
-
-import lombok.*;
-import lombok.experimental.FieldNameConstants;
-import net.hostsharing.hsadminng.errors.DisplayName;
-import net.hostsharing.hsadminng.hs.office.migration.HasUuid;
-import net.hostsharing.hsadminng.stringify.Stringify;
-import net.hostsharing.hsadminng.stringify.Stringifyable;
-import org.hibernate.annotations.GenericGenerator;
-
-import jakarta.persistence.*;
-import java.util.UUID;
-
-import static net.hostsharing.hsadminng.stringify.Stringify.stringify;
-
-@Entity
-@Table(name = "hs_office_contact_rv")
-@Getter
-@Setter
-@Builder
-@NoArgsConstructor
-@AllArgsConstructor
-@FieldNameConstants
-@DisplayName("Contact")
-public class HsOfficeContactEntity implements Stringifyable, HasUuid {
-
- private static Stringify toString = stringify(HsOfficeContactEntity.class, "contact")
- .withProp(Fields.label, HsOfficeContactEntity::getLabel)
- .withProp(Fields.emailAddresses, HsOfficeContactEntity::getEmailAddresses);
-
-
- @Id
- @GeneratedValue(generator = "UUID")
- @GenericGenerator(name = "UUID", strategy = "org.hibernate.id.UUIDGenerator")
- private UUID uuid;
- private String label;
-
- @Column(name = "postaladdress")
- private String postalAddress;
-
- @Column(name = "emailaddresses", columnDefinition = "json")
- private String emailAddresses; // TODO: check if we can really add multiple. format: ["eins@...", "zwei@..."]
-
- @Column(name = "phonenumbers", columnDefinition = "json")
- private String phoneNumbers; // TODO: check if we can really add multiple. format: { "office": "+49 40 12345-10", "fax": "+49 40 12345-05" }
-
- @Override
- public String toString() {
- return toString.apply(this);
- }
-
- @Override
- public String toShortString() {
- return label;
- }
-}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntityPatch.java b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntityPatch.java
deleted file mode 100644
index af6cfbc6..00000000
--- a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntityPatch.java
+++ /dev/null
@@ -1,22 +0,0 @@
-package net.hostsharing.hsadminng.hs.office.contact;
-
-import net.hostsharing.hsadminng.mapper.EntityPatcher;
-import net.hostsharing.hsadminng.mapper.OptionalFromJson;
-import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeContactPatchResource;
-
-class HsOfficeContactEntityPatch implements EntityPatcher {
-
- private final HsOfficeContactEntity entity;
-
- HsOfficeContactEntityPatch(final HsOfficeContactEntity entity) {
- this.entity = entity;
- }
-
- @Override
- public void apply(final HsOfficeContactPatchResource resource) {
- OptionalFromJson.of(resource.getLabel()).ifPresent(entity::setLabel);
- OptionalFromJson.of(resource.getPostalAddress()).ifPresent(entity::setPostalAddress);
- OptionalFromJson.of(resource.getEmailAddresses()).ifPresent(entity::setEmailAddresses);
- OptionalFromJson.of(resource.getPhoneNumbers()).ifPresent(entity::setPhoneNumbers);
- }
-}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntityPatcher.java b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntityPatcher.java
new file mode 100644
index 00000000..e08e6bae
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntityPatcher.java
@@ -0,0 +1,27 @@
+package net.hostsharing.hsadminng.hs.office.contact;
+
+import net.hostsharing.hsadminng.mapper.EntityPatcher;
+import net.hostsharing.hsadminng.mapper.KeyValueMap;
+import net.hostsharing.hsadminng.mapper.OptionalFromJson;
+import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeContactPatchResource;
+
+import java.util.Optional;
+
+class HsOfficeContactEntityPatcher implements EntityPatcher {
+
+ private final HsOfficeContactRbacEntity entity;
+
+ HsOfficeContactEntityPatcher(final HsOfficeContactRbacEntity entity) {
+ this.entity = entity;
+ }
+
+ @Override
+ public void apply(final HsOfficeContactPatchResource resource) {
+ OptionalFromJson.of(resource.getCaption()).ifPresent(entity::setCaption);
+ OptionalFromJson.of(resource.getPostalAddress()).ifPresent(entity::setPostalAddress);
+ Optional.ofNullable(resource.getEmailAddresses())
+ .ifPresent(r -> entity.getEmailAddresses().patch(KeyValueMap.from(resource.getEmailAddresses())));
+ Optional.ofNullable(resource.getPhoneNumbers())
+ .ifPresent(r -> entity.getPhoneNumbers().patch(KeyValueMap.from(resource.getPhoneNumbers())));
+ }
+}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacEntity.java
new file mode 100644
index 00000000..c4e934cc
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacEntity.java
@@ -0,0 +1,48 @@
+package net.hostsharing.hsadminng.hs.office.contact;
+
+import lombok.*;
+import lombok.experimental.SuperBuilder;
+import net.hostsharing.hsadminng.errors.DisplayAs;
+import net.hostsharing.hsadminng.rbac.rbacdef.RbacView;
+import net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL;
+
+import jakarta.persistence.*;
+import java.io.IOException;
+
+import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.GLOBAL;
+import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.*;
+import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.RbacUserReference.UserRole.CREATOR;
+import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.*;
+import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.rbacViewFor;
+
+@Entity
+@Table(name = "hs_office_contact_rv")
+@Getter
+@Setter
+@NoArgsConstructor
+@SuperBuilder(toBuilder = true)
+@DisplayAs("RbacContact")
+public class HsOfficeContactRbacEntity extends HsOfficeContact {
+
+ public static RbacView rbac() {
+ return rbacViewFor("contact", HsOfficeContactRbacEntity.class)
+ .withIdentityView(SQL.projection("caption"))
+ .withUpdatableColumns("caption", "postalAddress", "emailAddresses", "phoneNumbers")
+ .createRole(OWNER, (with) -> {
+ with.owningUser(CREATOR);
+ with.incomingSuperRole(GLOBAL, ADMIN);
+ with.permission(DELETE);
+ })
+ .createSubRole(ADMIN, (with) -> {
+ with.permission(UPDATE);
+ })
+ .createSubRole(REFERRER, (with) -> {
+ with.permission(SELECT);
+ })
+ .toRole(GLOBAL, GUEST).grantPermission(INSERT);
+ }
+
+ public static void main(String[] args) throws IOException {
+ rbac().generateWithBaseFileName("5-hs-office/501-contact/5013-hs-office-contact-rbac");
+ }
+}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepository.java
new file mode 100644
index 00000000..e893bced
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRbacRepository.java
@@ -0,0 +1,26 @@
+package net.hostsharing.hsadminng.hs.office.contact;
+
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.Repository;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+public interface HsOfficeContactRbacRepository extends Repository {
+
+ Optional