generate postgres trigger function + trigger for RbacView for simple objects
This commit is contained in:
parent
f11edc082d
commit
74071c15db
@ -0,0 +1,5 @@
|
|||||||
|
package net.hostsharing.hsadminng.rbac.rbacdef;
|
||||||
|
|
||||||
|
public enum PostgresTriggerReference {
|
||||||
|
NEW, OLD
|
||||||
|
}
|
@ -8,7 +8,7 @@ import net.hostsharing.hsadminng.persistence.HasUuid;
|
|||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
|
||||||
import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.RbacUserDefinition.UserRole.CREATOR;
|
import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.RbacUserReference.UserRole.CREATOR;
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
public class RbacView {
|
public class RbacView {
|
||||||
@ -17,7 +17,7 @@ public class RbacView {
|
|||||||
|
|
||||||
private final EntityAlias entityAlias;
|
private final EntityAlias entityAlias;
|
||||||
|
|
||||||
private final Set<RbacUserDefinition> userDefs = new HashSet<>();
|
private final Set<RbacUserReference> userDefs = new HashSet<>();
|
||||||
private final Set<RbacRoleDefinition> roleDefs = new HashSet<>();
|
private final Set<RbacRoleDefinition> roleDefs = new HashSet<>();
|
||||||
private final Set<RbacPermissionDefinition> permDefs = new HashSet<>();
|
private final Set<RbacPermissionDefinition> permDefs = new HashSet<>();
|
||||||
private final Map<String, EntityAlias> entityAliases = new HashMap<>() {
|
private final Map<String, EntityAlias> entityAliases = new HashMap<>() {
|
||||||
@ -43,7 +43,7 @@ public class RbacView {
|
|||||||
RbacView(final String alias, final Class entityClass) {
|
RbacView(final String alias, final Class entityClass) {
|
||||||
entityAlias = new EntityAlias(alias, entityClass);
|
entityAlias = new EntityAlias(alias, entityClass);
|
||||||
entityAliases.put(alias, entityAlias);
|
entityAliases.put(alias, entityAlias);
|
||||||
new RbacUserDefinition(CREATOR);
|
new RbacUserReference(CREATOR);
|
||||||
entityAliases.put("global", new EntityAlias("global"));
|
entityAliases.put("global", new EntityAlias("global"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,7 +58,7 @@ public class RbacView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public RbacRoleDefinition createRole(final Role role) {
|
public RbacRoleDefinition createRole(final Role role) {
|
||||||
return findRbacRole(entityAlias, role);
|
return findRbacRole(entityAlias, role).toCreate();
|
||||||
}
|
}
|
||||||
|
|
||||||
public RbacPermissionDefinition createPermission(final Permission permission) {
|
public RbacPermissionDefinition createPermission(final Permission permission) {
|
||||||
@ -157,7 +157,7 @@ public class RbacView {
|
|||||||
@Getter
|
@Getter
|
||||||
@EqualsAndHashCode
|
@EqualsAndHashCode
|
||||||
public class RbacGrantDefinition {
|
public class RbacGrantDefinition {
|
||||||
private final RbacUserDefinition userDef;
|
private final RbacUserReference userDef;
|
||||||
private final RbacRoleDefinition superRoleDef;
|
private final RbacRoleDefinition superRoleDef;
|
||||||
private final RbacRoleDefinition subRoleDef;
|
private final RbacRoleDefinition subRoleDef;
|
||||||
private final RbacPermissionDefinition permDef;
|
private final RbacPermissionDefinition permDef;
|
||||||
@ -187,7 +187,7 @@ public class RbacView {
|
|||||||
grantDefs.add(this);
|
grantDefs.add(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
public RbacGrantDefinition(final RbacRoleDefinition roleDef, final RbacUserDefinition userDef) {
|
public RbacGrantDefinition(final RbacRoleDefinition roleDef, final RbacUserReference userDef) {
|
||||||
this.userDef = userDef;
|
this.userDef = userDef;
|
||||||
this.subRoleDef = roleDef;
|
this.subRoleDef = roleDef;
|
||||||
this.superRoleDef = null;
|
this.superRoleDef = null;
|
||||||
@ -323,19 +323,19 @@ public class RbacView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public RbacUserDefinition currentUser() {
|
public RbacUserReference currentUser() {
|
||||||
return userDefs.stream().filter(u -> u.role == CREATOR).findFirst().orElseThrow();
|
return userDefs.stream().filter(u -> u.role == CREATOR).findFirst().orElseThrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
@EqualsAndHashCode
|
@EqualsAndHashCode
|
||||||
public class RbacUserDefinition {
|
public class RbacUserReference {
|
||||||
|
|
||||||
public enum UserRole {
|
public enum UserRole {
|
||||||
CREATOR
|
CREATOR
|
||||||
}
|
}
|
||||||
|
|
||||||
final UserRole role;
|
final UserRole role;
|
||||||
public RbacUserDefinition(final UserRole creator) {
|
public RbacUserReference(final UserRole creator) {
|
||||||
this.role = creator;
|
this.role = creator;
|
||||||
userDefs.add(this);
|
userDefs.add(this);
|
||||||
}
|
}
|
||||||
@ -354,14 +354,14 @@ public class RbacView {
|
|||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
|
|
||||||
private RbacRoleDefinition findRbacRole(final EntityAlias entityAlias, final Role role) {
|
public RbacRoleDefinition findRbacRole(final EntityAlias entityAlias, final Role role) {
|
||||||
return roleDefs.stream()
|
return roleDefs.stream()
|
||||||
.filter(r -> r.getEntityAlias() == entityAlias && r.getRole().equals(role))
|
.filter(r -> r.getEntityAlias() == entityAlias && r.getRole().equals(role))
|
||||||
.findFirst()
|
.findFirst()
|
||||||
.orElseGet(() -> new RbacRoleDefinition(entityAlias, role));
|
.orElseGet(() -> new RbacRoleDefinition(entityAlias, role));
|
||||||
}
|
}
|
||||||
|
|
||||||
private RbacRoleDefinition findRbacRole(final String entityAliasName, final Role role) {
|
public RbacRoleDefinition findRbacRole(final String entityAliasName, final Role role) {
|
||||||
return findRbacRole(findEntityAlias(entityAliasName), role);
|
return findRbacRole(findEntityAlias(entityAliasName), role);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -374,6 +374,10 @@ public class RbacView {
|
|||||||
public EntityAlias(final String aliasName, final Class<? extends HasUuid> entityClass) {
|
public EntityAlias(final String aliasName, final Class<? extends HasUuid> entityClass) {
|
||||||
this(aliasName, entityClass, null, null);
|
this(aliasName, entityClass, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
boolean isGlobal() {
|
||||||
|
return aliasName().equals("global");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public record Role(String roleName) {
|
public record Role(String roleName) {
|
||||||
|
@ -0,0 +1,47 @@
|
|||||||
|
package net.hostsharing.hsadminng.rbac.rbacdef;
|
||||||
|
|
||||||
|
import net.hostsharing.hsadminng.hs.office.bankaccount.HsOfficeBankAccountEntity;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.nio.file.StandardOpenOption;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
|
||||||
|
public class RbacViewPostgresGenerator {
|
||||||
|
|
||||||
|
|
||||||
|
private final RbacView rbacDef;
|
||||||
|
private final String liqibaseTagPrefix;
|
||||||
|
private final StringBuilder plPgSql = new StringBuilder();
|
||||||
|
|
||||||
|
public RbacViewPostgresGenerator(final RbacView forRbacDef) {
|
||||||
|
rbacDef = forRbacDef;
|
||||||
|
liqibaseTagPrefix = rbacDef.getEntityAlias().entityClass().getSimpleName();
|
||||||
|
plPgSql.append("""
|
||||||
|
--liquibase formatted sql
|
||||||
|
-- generated at: %{timestamp}
|
||||||
|
|
||||||
|
"""
|
||||||
|
.replace("%{timestamp}", LocalDateTime.now().toString()));
|
||||||
|
|
||||||
|
// generateSqlForRelatedRbacObject();
|
||||||
|
|
||||||
|
new RolesGrantsAndPermissionsGenerator(rbacDef, liqibaseTagPrefix).generateTo(plPgSql);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return plPgSql.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) throws IOException {
|
||||||
|
|
||||||
|
Files.writeString(
|
||||||
|
Paths.get("doc", "hsOfficeBankAccount.sql"),
|
||||||
|
new RbacViewPostgresGenerator(HsOfficeBankAccountEntity.hsOfficeBankAccount()).toString(),
|
||||||
|
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,238 @@
|
|||||||
|
package net.hostsharing.hsadminng.rbac.rbacdef;
|
||||||
|
|
||||||
|
import net.hostsharing.hsadminng.rbac.rbacdef.RbacView.RbacPermissionDefinition;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import static java.util.stream.Collectors.joining;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.rbacdef.PostgresTriggerReference.NEW;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.RbacGrantDefinition.GrantType.*;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.*;
|
||||||
|
import static org.apache.commons.lang3.StringUtils.capitalize;
|
||||||
|
import static org.apache.commons.lang3.StringUtils.uncapitalize;
|
||||||
|
|
||||||
|
class RolesGrantsAndPermissionsGenerator {
|
||||||
|
|
||||||
|
private final RbacView rbacDef;
|
||||||
|
private final String liquibaseTagPrefix;
|
||||||
|
private final Class<?> entityClass;
|
||||||
|
private final String simpleEntityName;
|
||||||
|
private final String simpleEntityVarName;
|
||||||
|
private final String rawTableName;
|
||||||
|
|
||||||
|
RolesGrantsAndPermissionsGenerator(final RbacView rbacDef, final String liquibaseTagPrefix) {
|
||||||
|
this.rbacDef = rbacDef;
|
||||||
|
this.liquibaseTagPrefix = liquibaseTagPrefix;
|
||||||
|
|
||||||
|
entityClass = rbacDef.getEntityAlias().entityClass();
|
||||||
|
simpleEntityName = withoutEntitySuffix(entityClass.getSimpleName());
|
||||||
|
simpleEntityVarName = uncapitalize(simpleEntityName);
|
||||||
|
rawTableName = withoutRvSuffix(entityClass.getAnnotation(Table.class).name());
|
||||||
|
}
|
||||||
|
|
||||||
|
void generateTo(final StringBuilder plPgSql) {
|
||||||
|
generateHeader(plPgSql);
|
||||||
|
generateTriggerFunction(plPgSql);
|
||||||
|
generageInsertTrigger(plPgSql);
|
||||||
|
generateFooter(plPgSql);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void generateHeader(final StringBuilder plPgSql) {
|
||||||
|
plPgSql.append("""
|
||||||
|
-- ============================================================================
|
||||||
|
--changeset %{liquibaseTagPrefix}-rbac-CREATE-ROLES-GRANTS-PERMISSIONS:1 endDelimiter:--//
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
"""
|
||||||
|
.replace("%{liquibaseTagPrefix}", liquibaseTagPrefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void generateTriggerFunction(final StringBuilder plPgSql) {
|
||||||
|
plPgSql.append("""
|
||||||
|
/*
|
||||||
|
Creates the roles, grants and permission for the AFTER INSERT TRIGGER.
|
||||||
|
*/
|
||||||
|
|
||||||
|
create or replace function createRbacRolesFor%{simpleEntityName}()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
strict as $$
|
||||||
|
begin
|
||||||
|
if TG_OP <> 'INSERT' then
|
||||||
|
raise exception 'invalid usage of TRIGGER AFTER INSERT function';
|
||||||
|
end if;
|
||||||
|
%{createRolesWithGrantsSql}
|
||||||
|
return NEW;
|
||||||
|
end; $$;
|
||||||
|
"""
|
||||||
|
.replace("%{simpleEntityName}", simpleEntityName)
|
||||||
|
.replace("%{createRolesWithGrantsSql}", createRolesWithGrantsSql())
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private String createRolesWithGrantsSql() {
|
||||||
|
final var plPgSql = new StringBuilder();
|
||||||
|
createRolesWithGrantsSql(plPgSql, OWNER);
|
||||||
|
createRolesWithGrantsSql(plPgSql, ADMIN);
|
||||||
|
createRolesWithGrantsSql(plPgSql, AGENT);
|
||||||
|
createRolesWithGrantsSql(plPgSql, TENANT);
|
||||||
|
createRolesWithGrantsSql(plPgSql, REFERRER);
|
||||||
|
return plPgSql.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createRolesWithGrantsSql(final StringBuilder plPgSql, final RbacView.Role role) {
|
||||||
|
|
||||||
|
final var isToCreate = rbacDef.getRoleDefs().stream()
|
||||||
|
.filter(roleDef -> rbacDef.isMainEntityAlias(roleDef.getEntityAlias()) && roleDef.getRole() == role )
|
||||||
|
.findFirst().map(RbacView.RbacRoleDefinition::isToCreate).orElse(false);
|
||||||
|
if (!isToCreate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
plPgSql.append("""
|
||||||
|
|
||||||
|
perform createRoleWithGrants(
|
||||||
|
%{simpleEntityVarName)%{roleSuffix}(NEW),
|
||||||
|
"""
|
||||||
|
.replace("%{simpleEntityVarName)", simpleEntityVarName)
|
||||||
|
.replace("%{roleSuffix}", capitalize(role.roleName())));
|
||||||
|
|
||||||
|
final var permissionsForRole = findPermissionsGrantsForRole(rbacDef.getEntityAlias(), role);
|
||||||
|
if (!permissionsForRole.isEmpty()) {
|
||||||
|
final var permissionsForRoleInPlPgSql = permissionsForRole.stream()
|
||||||
|
.map(RbacPermissionDefinition::getPermission)
|
||||||
|
.map(RbacView.Permission::permission)
|
||||||
|
.map(p -> "'" + p + "'")
|
||||||
|
.collect(joining(", "));
|
||||||
|
plPgSql.append(indent(3) + "permissions => array[" + permissionsForRoleInPlPgSql + "],\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
final var grantsToUsers = findGrantsToUserForRole(rbacDef.getEntityAlias(), role);
|
||||||
|
if (!grantsToUsers.isEmpty()) {
|
||||||
|
final var grantsToUsersPlPgSql = grantsToUsers.stream()
|
||||||
|
.map(u -> toPlPgSqlReference(u))
|
||||||
|
.collect(joining(", "));
|
||||||
|
plPgSql.append(indent(3) + "userUuids => array[" + grantsToUsersPlPgSql + "],\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
final var incomingGrants = findIncomingSuperRolesForRole(rbacDef.getEntityAlias(), role);
|
||||||
|
if (!incomingGrants.isEmpty()) {
|
||||||
|
final var incomingGrantsInPlPgSql = incomingGrants.stream()
|
||||||
|
.map(RbacView.RbacGrantDefinition::getSuperRoleDef)
|
||||||
|
.map(r -> toPlPgSqlReference(NEW, r))
|
||||||
|
.collect(joining(", "));
|
||||||
|
plPgSql.append(indent(3) + "incomingSuperRoles => array[" + incomingGrantsInPlPgSql + "],\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
final var outgoingGrants = findOutgoingSuperRolesForRole(rbacDef.getEntityAlias(), role);
|
||||||
|
if (!outgoingGrants.isEmpty()) {
|
||||||
|
final var outgoingGrantsInPlPgSql = outgoingGrants.stream()
|
||||||
|
.map(RbacView.RbacGrantDefinition::getSuperRoleDef)
|
||||||
|
.map(r -> toPlPgSqlReference(NEW, r))
|
||||||
|
.collect(joining(", "));
|
||||||
|
plPgSql.append(indent(3) + "outgoingSubRoles => array[" + outgoingGrantsInPlPgSql + "],\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
chopTail(plPgSql, ",\n");
|
||||||
|
plPgSql.append("\n" + indent(2) + ");\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<RbacPermissionDefinition> findPermissionsGrantsForRole(final RbacView.EntityAlias entityAlias, final RbacView.Role role) {
|
||||||
|
final var roleDef = rbacDef.findRbacRole(entityAlias, role);
|
||||||
|
return rbacDef.getGrantDefs().stream()
|
||||||
|
.filter(g -> g.grantType() == ROLE_TO_PERM && g.getSuperRoleDef()==roleDef )
|
||||||
|
.map(RbacView.RbacGrantDefinition::getPermDef)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<RbacView.RbacUserReference> findGrantsToUserForRole(final RbacView.EntityAlias entityAlias, final RbacView.Role role) {
|
||||||
|
final var roleDef = rbacDef.findRbacRole(entityAlias, role);
|
||||||
|
return rbacDef.getGrantDefs().stream()
|
||||||
|
.filter(g -> g.grantType() == USER_TO_ROLE && g.getSubRoleDef() == roleDef )
|
||||||
|
.map(RbacView.RbacGrantDefinition::getUserDef)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<RbacView.RbacGrantDefinition> findIncomingSuperRolesForRole(final RbacView.EntityAlias entityAlias, final RbacView.Role role) {
|
||||||
|
final var roleDef = rbacDef.findRbacRole(entityAlias, role);
|
||||||
|
return rbacDef.getGrantDefs().stream()
|
||||||
|
.filter(g -> g.grantType() == ROLE_TO_ROLE && g.getSubRoleDef()==roleDef )
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<RbacView.RbacGrantDefinition> findOutgoingSuperRolesForRole(final RbacView.EntityAlias entityAlias, final RbacView.Role role) {
|
||||||
|
final var roleDef = rbacDef.findRbacRole(entityAlias, role);
|
||||||
|
return rbacDef.getGrantDefs().stream()
|
||||||
|
.filter(g -> g.grantType() == ROLE_TO_ROLE && g.getSuperRoleDef()==roleDef )
|
||||||
|
.filter(g -> g.getSubRoleDef().getEntityAlias() != entityAlias)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void generageInsertTrigger(final StringBuilder plPgSql) {
|
||||||
|
plPgSql.append("""
|
||||||
|
/*
|
||||||
|
An AFTER INSERT TRIGGER which creates the role structure for a new %{simpleEntityName}
|
||||||
|
*/
|
||||||
|
|
||||||
|
create trigger createRbacRolesFor%{simpleEntityName}_Trigger
|
||||||
|
after insert
|
||||||
|
on %{rawTableName}
|
||||||
|
for each row
|
||||||
|
execute procedure createRbacRolesFor%{simpleEntityName}();
|
||||||
|
--//
|
||||||
|
"""
|
||||||
|
.replace("%{simpleEntityName}", simpleEntityName)
|
||||||
|
.replace("%{rawTableName}", rawTableName)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void generateFooter(final StringBuilder plPgSql) {
|
||||||
|
plPgSql.append("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String withoutRvSuffix(final String tableName) {
|
||||||
|
return tableName.substring(0, tableName.length()-"_rv".length());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String withoutEntitySuffix(final String simpleEntityName) {
|
||||||
|
return simpleEntityName.substring(0, simpleEntityName.length()-"Entity".length());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String toPlPgSqlReference(final RbacView.RbacUserReference userRef) {
|
||||||
|
return switch (userRef.role) {
|
||||||
|
case CREATOR -> "currentUserUuid()";
|
||||||
|
default -> throw new IllegalArgumentException("unknown user role: " + userRef);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private String toPlPgSqlReference(final PostgresTriggerReference triggerRef, final RbacView.RbacRoleDefinition roleDef) {
|
||||||
|
return toSimpleVarName(roleDef.getEntityAlias()) + StringUtils.capitalize(roleDef.getRole().roleName()) +
|
||||||
|
( roleDef.getEntityAlias().isGlobal() ? "()"
|
||||||
|
: rbacDef.isMainEntityAlias(roleDef.getEntityAlias()) ? ("("+triggerRef.name()+")")
|
||||||
|
: "(" + toTriggerReference(triggerRef, roleDef.getEntityAlias()) + ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String toTriggerReference(final PostgresTriggerReference triggerRef, final RbacView.EntityAlias entityAlias) {
|
||||||
|
return triggerRef.name().toLowerCase() + StringUtils.capitalize(entityAlias.aliasName());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String toSimpleVarName(final RbacView.EntityAlias entityAlias) {
|
||||||
|
return entityAlias.isGlobal()
|
||||||
|
? entityAlias.aliasName()
|
||||||
|
: uncapitalize(withoutEntitySuffix(entityAlias.entityClass().getSimpleName()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String indent(final int tabs) {
|
||||||
|
return " ".repeat(4*tabs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void chopTail(final StringBuilder plPgSql, final String tail) {
|
||||||
|
if (plPgSql.toString().endsWith(tail)) {
|
||||||
|
plPgSql.setLength(plPgSql.length() - tail.length());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user