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

View File

@ -50,9 +50,9 @@ public class HsHostingAssetEntityValidatorRegistry {
return entityToSave; 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 validator = HsHostingAssetEntityValidatorRegistry.forType(entity.getType());
final var config = validator.cleanup(asMap(resource)); final var config = validator.postprocess(entity, asMap(resource));
resource.setConfig(config); 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.HsHostingAssetEntity;
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType; import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType;
import net.hostsharing.hsadminng.hs.validation.PropertiesProvider;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@ -12,6 +13,8 @@ import static net.hostsharing.hsadminng.hs.validation.StringProperty.stringPrope
class HsUnixUserHostingAssetValidator extends HsHostingAssetEntityValidator { class HsUnixUserHostingAssetValidator extends HsHostingAssetEntityValidator {
private static final int DASH_LENGTH = "-".length();
HsUnixUserHostingAssetValidator() { HsUnixUserHostingAssetValidator() {
super( BookingItem.mustBeNull(), super( BookingItem.mustBeNull(),
ParentAsset.mustBeOfType(HsHostingAssetType.MANAGED_WEBSPACE), ParentAsset.mustBeOfType(HsHostingAssetType.MANAGED_WEBSPACE),
@ -25,7 +28,7 @@ class HsUnixUserHostingAssetValidator extends HsHostingAssetEntityValidator {
enumerationProperty("shell") enumerationProperty("shell")
.values("/bin/false", "/bin/bash", "/bin/csh", "/bin/dash", "/usr/bin/tcsh", "/usr/bin/zsh", "/usr/bin/passwd") .values("/bin/false", "/bin/bash", "/bin/csh", "/bin/dash", "/usr/bin/tcsh", "/usr/bin/zsh", "/usr/bin/passwd")
.withDefault("/bin/false"), .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(), stringProperty("totpKey").matchesRegEx("^0x([0-9A-Fa-f]{2})+$").minLength(20).maxLength(256).hidden().writeOnly().optional(),
passwordProperty("password").minLength(8).maxLength(40).writeOnly()); passwordProperty("password").minLength(8).maxLength(40).writeOnly());
} }
@ -35,4 +38,11 @@ class HsUnixUserHostingAssetValidator extends HsHostingAssetEntityValidator {
final var webspaceIdentifier = assetEntity.getParentAsset().getIdentifier(); final var webspaceIdentifier = assetEntity.getParentAsset().getIdentifier();
return Pattern.compile("^"+webspaceIdentifier+"$|^"+webspaceIdentifier+"-[a-z0-9]+$"); 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.Arrays.stream;
import static java.util.Collections.emptyList; 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; public final ValidatableProperty<?>[] propertyValidators;
@ -88,9 +89,16 @@ public abstract class HsEntityValidator<E> {
throw new IllegalArgumentException("Integer value (or null) expected, but got " + value); 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); 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) {
copy.remove(p.propertyName);
}
if (p.isComputed()) {
copy.put(p.propertyName, p.compute(entity));
}
});
return copy; return copy;
} }
} }

View File

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

View File

@ -32,6 +32,7 @@ public abstract class ValidatableProperty<T> {
private final String[] keyOrder; private final String[] keyOrder;
private Boolean required; private Boolean required;
private T defaultValue; private T defaultValue;
protected Function<PropertiesProvider, T> computedBy;
protected boolean readOnly; protected boolean readOnly;
protected boolean writeOnly; protected boolean writeOnly;
@ -216,4 +217,17 @@ public abstract class ValidatableProperty<T> {
.flatMap(Collection::stream) .flatMap(Collection::stream)
.toList(); .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() { void toStringContainsAllPropertiesAndResourcesSortedByKey() {
final var result = givenBookingItem.toString(); 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 @Test

View File

@ -193,8 +193,8 @@ class HsBookingItemRepositoryIntegrationTest extends ContextBasedTestWithCleanup
// then: // then:
exactlyTheseBookingItemsAreReturned( exactlyTheseBookingItemsAreReturned(
result, 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_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 } )"); "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 List<HsBookingItemEntity> actualResult,
final String... bookingItemNames) { final String... bookingItemNames) {
assertThat(actualResult) assertThat(actualResult)
.extracting(bookingItemEntity -> bookingItemEntity.toString()) .extracting(HsBookingItemEntity::toString)
.extracting(string-> string.replaceAll("\\s+", " "))
.extracting(string-> string.replaceAll("\"", ""))
.containsExactlyInAnyOrder(bookingItemNames); .containsExactlyInAnyOrder(bookingItemNames);
} }
void allTheseBookingItemsAreReturned(final List<HsBookingItemEntity> actualResult, final String... bookingItemNames) { void allTheseBookingItemsAreReturned(final List<HsBookingItemEntity> actualResult, final String... bookingItemNames) {
assertThat(actualResult) assertThat(actualResult)
.extracting(bookingItemEntity -> bookingItemEntity.toString()) .extracting(HsBookingItemEntity::toString)
.extracting(string -> string.replaceAll("\\s+", " "))
.extracting(string -> string.replaceAll("\"", ""))
.contains(bookingItemNames); .contains(bookingItemNames);
} }
} }

View File

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

View File

@ -57,14 +57,14 @@ class HsHostingAssetEntityUnitTest {
@Test @Test
void toStringContainsAllPropertiesAndResourcesSortedByKey() { void toStringContainsAllPropertiesAndResourcesSortedByKey() {
assertThat(givenWebspace.toString()).isEqualTo( 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 })"); "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( 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 })"); "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( 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: * })"); "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 @Test

View File

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

View File

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