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

Merged
hsh-michaelhoennig merged 11 commits from add-unix-user-hosting-asset-validation into master 2024-06-27 12:39:45 +02:00
12 changed files with 101 additions and 40 deletions
Showing only changes of commit c2cd6c2f23 - Show all commits

View File

@ -1,6 +1,7 @@
package net.hostsharing.hsadminng.hs.hosting.asset;
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRepository;
import net.hostsharing.hsadminng.hs.hosting.asset.validators.HsHostingAssetEntityValidatorRegistry;
import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.api.HsHostingAssetsApi;
import net.hostsharing.hsadminng.context.Context;
@ -23,7 +24,6 @@ import java.util.List;
import java.util.UUID;
import java.util.function.BiConsumer;
import static net.hostsharing.hsadminng.hs.hosting.asset.validators.HsHostingAssetEntityValidatorRegistry.cleanup;
import static net.hostsharing.hsadminng.hs.hosting.asset.validators.HsHostingAssetEntityValidatorRegistry.validated;
@RestController
@ -79,7 +79,7 @@ public class HsHostingAssetController implements HsHostingAssetsApi {
.path("/api/hs/hosting/assets/{id}")
.buildAndExpand(saved.getUuid())
.toUri();
final var mapped = mapper.map(saved, HsHostingAssetResource.class);
final var mapped = mapper.map(saved, HsHostingAssetResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
return ResponseEntity.created(uri).body(mapped);
}
@ -95,7 +95,7 @@ public class HsHostingAssetController implements HsHostingAssetsApi {
final var result = assetRepo.findByUuid(assetUuid);
return result
.map(assetEntity -> ResponseEntity.ok(
mapper.map(assetEntity, HsHostingAssetResource.class)))
mapper.map(assetEntity, HsHostingAssetResource.class, ENTITY_TO_RESOURCE_POSTMAPPER)))
.orElseGet(() -> ResponseEntity.notFound().build());
}
@ -127,6 +127,13 @@ public class HsHostingAssetController implements HsHostingAssetsApi {
new HsHostingAssetEntityPatcher(em, current).apply(body);
// validate(current)/ / self-validation, hashing passwords etc.
// .then(HsHostingAssetEntityValidatorRegistry::prepareForSave) // hashing passwords etc.
// .then(assetRepo::save)
// .then(HsHostingAssetEntityValidatorRegistry::validateInContext)
// .then(this::mapToResource) using postprocessProperties to remove write-only + add read-only properties
final var saved = validated(assetRepo.save(current));
final var mapped = mapper.map(saved, HsHostingAssetResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
return ResponseEntity.ok(mapped);
@ -146,7 +153,6 @@ public class HsHostingAssetController implements HsHostingAssetsApi {
}
};
final BiConsumer<HsHostingAssetEntity, HsHostingAssetResource> ENTITY_TO_RESOURCE_POSTMAPPER = (entity, resource) -> {
cleanup(entity, resource);
};
final BiConsumer<HsHostingAssetEntity, HsHostingAssetResource> ENTITY_TO_RESOURCE_POSTMAPPER
= HsHostingAssetEntityValidatorRegistry::postprocessProperties;
}

View File

@ -50,9 +50,9 @@ public class HsHostingAssetEntityValidatorRegistry {
return entityToSave;
}
public static void cleanup(final HsHostingAssetEntity entity, final HsHostingAssetResource resource) {
public static void postprocessProperties(final HsHostingAssetEntity entity, final HsHostingAssetResource resource) {
final var validator = HsHostingAssetEntityValidatorRegistry.forType(entity.getType());
final var config = validator.cleanup(asMap(resource));
final var config = validator.postprocess(entity, asMap(resource));
resource.setConfig(config);
}

View File

@ -2,6 +2,7 @@ package net.hostsharing.hsadminng.hs.hosting.asset.validators;
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetEntity;
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType;
import net.hostsharing.hsadminng.hs.validation.PropertiesProvider;
import java.util.regex.Pattern;
@ -12,6 +13,8 @@ import static net.hostsharing.hsadminng.hs.validation.StringProperty.stringPrope
class HsUnixUserHostingAssetValidator extends HsHostingAssetEntityValidator {
private static final int DASH_LENGTH = "-".length();
HsUnixUserHostingAssetValidator() {
super( BookingItem.mustBeNull(),
ParentAsset.mustBeOfType(HsHostingAssetType.MANAGED_WEBSPACE),
@ -25,7 +28,7 @@ class HsUnixUserHostingAssetValidator extends HsHostingAssetEntityValidator {
enumerationProperty("shell")
.values("/bin/false", "/bin/bash", "/bin/csh", "/bin/dash", "/usr/bin/tcsh", "/usr/bin/zsh", "/usr/bin/passwd")
.withDefault("/bin/false"),
stringProperty("homedir").readOnly(),
stringProperty("homedir").readOnly().computedBy(HsUnixUserHostingAssetValidator::computeHomedir),
stringProperty("totpKey").matchesRegEx("^0x([0-9A-Fa-f]{2})+$").minLength(20).maxLength(256).hidden().writeOnly().optional(),
passwordProperty("password").minLength(8).maxLength(40).writeOnly());
}
@ -35,4 +38,11 @@ class HsUnixUserHostingAssetValidator extends HsHostingAssetEntityValidator {
final var webspaceIdentifier = assetEntity.getParentAsset().getIdentifier();
return Pattern.compile("^"+webspaceIdentifier+"$|^"+webspaceIdentifier+"-[a-z0-9]+$");
}
private static String computeHomedir(final PropertiesProvider propertiesProvider) {
final var entity = (HsHostingAssetEntity) propertiesProvider;
final var webspaceName = entity.getParentAsset().getIdentifier();
return "/home/pacs/" + webspaceName
+ "/users/" + entity.getIdentifier().substring(webspaceName.length()+DASH_LENGTH);
}
}

View File

@ -11,7 +11,8 @@ import java.util.function.Supplier;
import static java.util.Arrays.stream;
import static java.util.Collections.emptyList;
public abstract class HsEntityValidator<E> {
// TODO.refa: rename to HsEntityProcessor, also subclasses
public abstract class HsEntityValidator<E extends PropertiesProvider> {
public final ValidatableProperty<?>[] propertyValidators;
@ -88,9 +89,16 @@ public abstract class HsEntityValidator<E> {
throw new IllegalArgumentException("Integer value (or null) expected, but got " + value);
}
public Map<String, Object> cleanup(final Map<String, Object> config) {
public Map<String, Object> postprocess(final E entity, final Map<String, Object> config) {
final var copy = new HashMap<>(config);
stream(propertyValidators).filter(p -> p.writeOnly).forEach(p -> copy.remove(p.propertyName));
stream(propertyValidators).forEach(p -> {
if ( p.writeOnly) {
hsh-michaelhoennig marked this conversation as resolved Outdated

isWriteOnly()

isWriteOnly()
copy.remove(p.propertyName);
}
if (p.isComputed()) {
copy.put(p.propertyName, p.compute(entity));
}
});
return copy;
}
}

View File

@ -23,6 +23,8 @@ public class PasswordProperty extends StringProperty {
validatePassword(result, propValue);
}
// TODO.impl: only a SHA512 hash should be stored in the database, not the password itself
@Override
protected String simpleTypeName() {
return "password";

View File

@ -32,6 +32,7 @@ public abstract class ValidatableProperty<T> {
private final String[] keyOrder;
private Boolean required;
private T defaultValue;
protected Function<PropertiesProvider, T> computedBy;
protected boolean readOnly;
hsh-michaelhoennig marked this conversation as resolved Outdated

private

private
protected boolean writeOnly;
@ -216,4 +217,17 @@ public abstract class ValidatableProperty<T> {
.flatMap(Collection::stream)
.toList();
}
public ValidatableProperty<T> computedBy(final Function<PropertiesProvider, T> compute) {
this.computedBy = compute;
return this;
}
public boolean isComputed() {
return computedBy != null;
}
public <E extends PropertiesProvider> T compute(final E entity) {
return computedBy.apply(entity);
}
}

View File

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

View File

@ -193,8 +193,8 @@ class HsBookingItemRepositoryIntegrationTest extends ContextBasedTestWithCleanup
// then:
exactlyTheseBookingItemsAreReturned(
result,
"HsBookingItemEntity(D-1000111:D-1000111 default project, MANAGED_SERVER, [2022-10-01,), separate ManagedServer, { CPUs: 2, RAM: 8, SSD: 500, Traffic: 500 })",
"HsBookingItemEntity(D-1000111:D-1000111 default project, MANAGED_WEBSPACE, [2022-10-01,), separate ManagedWebspace, { Daemons: 0, Multi: 1, SSD: 100, Traffic: 50 } )",
"HsBookingItemEntity(D-1000111:D-1000111 default project, MANAGED_SERVER, [2022-10-01,), separate ManagedServer, { CPUs: 2, RAM: 8, SSD: 500, Traffic: 500 } )",
"HsBookingItemEntity(D-1000111:D-1000111 default project, PRIVATE_CLOUD, [2024-04-01,), some PrivateCloud, { CPUs: 10, HDD: 10000, RAM: 32, SSD: 4000, Traffic: 2000 } )");
}
}
@ -348,13 +348,17 @@ class HsBookingItemRepositoryIntegrationTest extends ContextBasedTestWithCleanup
final List<HsBookingItemEntity> actualResult,
final String... bookingItemNames) {
assertThat(actualResult)
.extracting(bookingItemEntity -> bookingItemEntity.toString())
.extracting(HsBookingItemEntity::toString)
.extracting(string-> string.replaceAll("\\s+", " "))
.extracting(string-> string.replaceAll("\"", ""))
.containsExactlyInAnyOrder(bookingItemNames);
}
void allTheseBookingItemsAreReturned(final List<HsBookingItemEntity> actualResult, final String... bookingItemNames) {
assertThat(actualResult)
.extracting(bookingItemEntity -> bookingItemEntity.toString())
.extracting(HsBookingItemEntity::toString)
.extracting(string -> string.replaceAll("\\s+", " "))
.extracting(string -> string.replaceAll("\"", ""))
.contains(bookingItemNames);
}
}

View File

@ -476,9 +476,10 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup
"identifier": "vm2001",
"caption": "some test-asset",
"alarmContact": {
"uuid": "%s",
"caption": "second contact",
"emailAddresses": { "main": "contact-admin@secondcontact.example.com" }
"emailAddresses": {
"main": "contact-admin@secondcontact.example.com"
}
},
"config": {
"monit_max_cpu_usage": 90,
@ -487,7 +488,7 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup
"monit_min_free_ssd": 5
}
}
""".formatted(alarmContactUuid)));
"""));
// @formatter:on
// finally, the asset is actually updated
@ -495,10 +496,18 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup
context.define("superuser-alex@hostsharing.net");
assertThat(assetRepo.findByUuid(givenAsset.getUuid())).isPresent().get()
.matches(asset -> {
assertThat(asset.getAlarmContact().toString()).isEqualTo(
"contact(caption='second contact', emailAddresses='{ main: contact-admin@secondcontact.example.com }')");
assertThat(asset.getConfig().toString()).isEqualTo(
"{ monit_max_cpu_usage: 90, monit_max_ram_usage: 70, monit_max_ssd_usage: 85, monit_min_free_ssd: 5 }");
assertThat(asset.getAlarmContact()).isNotNull()
.extracting(c -> c.getEmailAddresses().get("main"))
.isEqualTo("contact-admin@secondcontact.example.com");
assertThat(asset.getConfig().toString())
.isEqualToIgnoringWhitespace("""
{
"monit_max_cpu_usage": 90,
"monit_max_ram_usage": 70,
"monit_max_ssd_usage": 85,
"monit_min_free_ssd": 5
}
""");
return true;
});
}
@ -542,6 +551,7 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup
"identifier": "fir01-temp",
"caption": "some patched test-unix-user",
"config": {
"homedir": "/home/pacs/fir01/users/temp",
"shell": "/bin/bash"
}
}
@ -549,6 +559,7 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup
// the config separately but not-leniently to make sure that no write-only-properties are listed
.body("config", strictlyEquals("""
{
"homedir": "/home/pacs/fir01/users/temp",
"shell": "/bin/bash"
}
"""))

View File

@ -57,14 +57,14 @@ class HsHostingAssetEntityUnitTest {
@Test
void toStringContainsAllPropertiesAndResourcesSortedByKey() {
assertThat(givenWebspace.toString()).isEqualTo(
"HsHostingAssetEntity(MANAGED_WEBSPACE, xyz00, some managed webspace, MANAGED_SERVER:vm1234, D-1234500:test project:test cloud server booking item, { CPUs: 2, HDD-storage: 2048, SSD-storage: 512 })");
assertThat(givenWebspace.toString()).isEqualToIgnoringWhitespace(
"HsHostingAssetEntity(MANAGED_WEBSPACE, xyz00, some managed webspace, MANAGED_SERVER:vm1234, D-1234500:test project:test cloud server booking item, { \"CPUs\": 2, \"HDD-storage\": 2048, \"SSD-storage\": 512 })");
assertThat(givenUnixUser.toString()).isEqualTo(
"HsHostingAssetEntity(UNIX_USER, xyz00-web, some unix-user, MANAGED_WEBSPACE:xyz00, { HDD-hard-quota: 512, HDD-soft-quota: 256, SSD-hard-quota: 256, SSD-soft-quota: 128 })");
assertThat(givenUnixUser.toString()).isEqualToIgnoringWhitespace(
"HsHostingAssetEntity(UNIX_USER, xyz00-web, some unix-user, MANAGED_WEBSPACE:xyz00, { \"HDD-hard-quota\": 512, \"HDD-soft-quota\": 256, \"SSD-hard-quota\": 256, \"SSD-soft-quota\": 128 })");
assertThat(givenDomainHttpSetup.toString()).isEqualTo(
"HsHostingAssetEntity(DOMAIN_HTTP_SETUP, example.org, some domain setup, MANAGED_WEBSPACE:xyz00, UNIX_USER:xyz00-web, { option-htdocsfallback: true, use-fcgiphpbin: /usr/lib/cgi-bin/php, validsubdomainnames: * })");
assertThat(givenDomainHttpSetup.toString()).isEqualToIgnoringWhitespace(
"HsHostingAssetEntity(DOMAIN_HTTP_SETUP, example.org, some domain setup, MANAGED_WEBSPACE:xyz00, UNIX_USER:xyz00-web, { \"option-htdocsfallback\": true, \"use-fcgiphpbin\": \"/usr/lib/cgi-bin/php\", \"validsubdomainnames\": \"*\" })");
}
@Test

View File

@ -407,6 +407,8 @@ class HsHostingAssetRepositoryIntegrationTest extends ContextBasedTestWithCleanu
final String... serverNames) {
assertThat(actualResult)
.extracting(HsHostingAssetEntity::toString)
.extracting(input -> input.replaceAll("\\s+", " "))
.extracting(input -> input.replaceAll("\"", ""))
.containsExactlyInAnyOrder(serverNames);
}

View File

@ -9,13 +9,15 @@ import org.json.JSONException;
import org.skyscreamer.jsonassert.JSONAssert;
import org.skyscreamer.jsonassert.JSONCompareMode;
import static com.fasterxml.jackson.databind.SerializationFeature.INDENT_OUTPUT;
public class JsonMatcher extends BaseMatcher<CharSequence> {
private final String expected;
private final String expectedJson;
private JSONCompareMode compareMode;
public JsonMatcher(final String expected, final JSONCompareMode compareMode) {
this.expected = expected;
public JsonMatcher(final String expectedJson, final JSONCompareMode compareMode) {
this.expectedJson = expectedJson;
this.compareMode = compareMode;
}
@ -47,11 +49,13 @@ public class JsonMatcher extends BaseMatcher<CharSequence> {
return false;
}
try {
final var actualJson = new ObjectMapper().writeValueAsString(actual);
JSONAssert.assertEquals(expected, actualJson, compareMode);
final var actualJson = new ObjectMapper().enable(INDENT_OUTPUT).writeValueAsString(actual);
JSONAssert.assertEquals(expectedJson, actualJson, compareMode);
return true;
} catch (final JSONException | JsonProcessingException e) {
throw new AssertionError(e);
} catch (final Exception e ) {
throw e;
}
}