add-unix-user-hosting-asset-validation #66
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
||||
copy.remove(p.propertyName);
|
||||
}
|
||||
if (p.isComputed()) {
|
||||
copy.put(p.propertyName, p.compute(entity));
|
||||
}
|
||||
});
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
|
@ -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";
|
||||
|
@ -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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -170,9 +170,9 @@ class HsBookingItemRepositoryIntegrationTest extends ContextBasedTestWithCleanup
|
||||
// then
|
||||
allTheseBookingItemsAreReturned(
|
||||
result,
|
||||
"HsBookingItemEntity(D-1000212:D-1000212 default project, MANAGED_WEBSPACE, [2022-10-01,), separate ManagedWebspace, { Daemons: 0, Multi: 1, SSD: 100, Traffic: 50 })",
|
||||
"HsBookingItemEntity(D-1000212:D-1000212 default project, MANAGED_SERVER, [2022-10-01,), separate ManagedServer, { CPUs: 2, RAM: 8, SSD: 500, Traffic: 500 })",
|
||||
"HsBookingItemEntity(D-1000212:D-1000212 default project, PRIVATE_CLOUD, [2024-04-01,), some PrivateCloud, { CPUs: 10, HDD: 10000, RAM: 32, SSD: 4000, Traffic: 2000 })");
|
||||
"HsBookingItemEntity(D-1000212:D-1000212 default project, MANAGED_WEBSPACE, [2022-10-01,), separate ManagedWebspace, { Daemons: 0, Multi: 1, SSD: 100, Traffic: 50 } )",
|
||||
"HsBookingItemEntity(D-1000212:D-1000212 default project, MANAGED_SERVER, [2022-10-01,), separate ManagedServer, { CPUs: 2, RAM: 8, SSD: 500, Traffic: 500 } )",
|
||||
"HsBookingItemEntity(D-1000212:D-1000212 default project, PRIVATE_CLOUD, [2024-04-01,), some PrivateCloud, { CPUs: 10, HDD: 10000, RAM: 32, SSD: 4000, Traffic: 2000 } )");
|
||||
assertThat(result.stream().filter(bi -> bi.getRelatedHostingAsset()!=null).findAny())
|
||||
.as("at least one relatedProject expected, but none found => fetching relatedProject does not work")
|
||||
.isNotEmpty();
|
||||
@ -193,9 +193,9 @@ class HsBookingItemRepositoryIntegrationTest extends ContextBasedTestWithCleanup
|
||||
// then:
|
||||
exactlyTheseBookingItemsAreReturned(
|
||||
result,
|
||||
"HsBookingItemEntity(D-1000111:D-1000111 default project, MANAGED_SERVER, [2022-10-01,), separate ManagedServer, { CPUs: 2, RAM: 8, SSD: 500, Traffic: 500 })",
|
||||
"HsBookingItemEntity(D-1000111:D-1000111 default project, MANAGED_WEBSPACE, [2022-10-01,), separate ManagedWebspace, { Daemons: 0, Multi: 1, SSD: 100, Traffic: 50 })",
|
||||
"HsBookingItemEntity(D-1000111:D-1000111 default project, PRIVATE_CLOUD, [2024-04-01,), some PrivateCloud, { CPUs: 10, HDD: 10000, RAM: 32, SSD: 4000, Traffic: 2000 })");
|
||||
"HsBookingItemEntity(D-1000111:D-1000111 default project, MANAGED_WEBSPACE, [2022-10-01,), separate ManagedWebspace, { Daemons: 0, Multi: 1, SSD: 100, Traffic: 50 } )",
|
||||
"HsBookingItemEntity(D-1000111:D-1000111 default project, MANAGED_SERVER, [2022-10-01,), separate ManagedServer, { CPUs: 2, RAM: 8, SSD: 500, Traffic: 500 } )",
|
||||
"HsBookingItemEntity(D-1000111:D-1000111 default project, PRIVATE_CLOUD, [2024-04-01,), some PrivateCloud, { CPUs: 10, HDD: 10000, RAM: 32, SSD: 4000, Traffic: 2000 } )");
|
||||
}
|
||||
}
|
||||
|
||||
@ -348,13 +348,17 @@ class HsBookingItemRepositoryIntegrationTest extends ContextBasedTestWithCleanup
|
||||
final List<HsBookingItemEntity> actualResult,
|
||||
final String... bookingItemNames) {
|
||||
assertThat(actualResult)
|
||||
.extracting(bookingItemEntity -> bookingItemEntity.toString())
|
||||
.extracting(HsBookingItemEntity::toString)
|
||||
.extracting(string-> string.replaceAll("\\s+", " "))
|
||||
.extracting(string-> string.replaceAll("\"", ""))
|
||||
.containsExactlyInAnyOrder(bookingItemNames);
|
||||
}
|
||||
|
||||
void allTheseBookingItemsAreReturned(final List<HsBookingItemEntity> actualResult, final String... bookingItemNames) {
|
||||
assertThat(actualResult)
|
||||
.extracting(bookingItemEntity -> bookingItemEntity.toString())
|
||||
.extracting(HsBookingItemEntity::toString)
|
||||
.extracting(string -> string.replaceAll("\\s+", " "))
|
||||
.extracting(string -> string.replaceAll("\"", ""))
|
||||
.contains(bookingItemNames);
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
}
|
||||
"""))
|
||||
|
@ -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
|
||||
|
@ -195,7 +195,7 @@ class HsHostingAssetRepositoryIntegrationTest extends ContextBasedTestWithCleanu
|
||||
exactlyTheseAssetsAreReturned(
|
||||
result,
|
||||
"HsHostingAssetEntity(MANAGED_WEBSPACE, fir01, some Webspace, MANAGED_SERVER:vm1011, D-1000111:D-1000111 default project:separate ManagedWebspace)",
|
||||
"HsHostingAssetEntity(MANAGED_SERVER, vm1011, some ManagedServer, D-1000111:D-1000111 default project:separate ManagedServer, { monit_max_cpu_usage: 90, monit_max_ram_usage: 80, monit_max_ssd_usage: 70 })");
|
||||
"HsHostingAssetEntity(MANAGED_SERVER, vm1011, some ManagedServer, D-1000111:D-1000111 default project:separate ManagedServer, { monit_max_cpu_usage: 90, monit_max_ram_usage: 80, monit_max_ssd_usage: 70 } )");
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user