Decouple scopes from tenancy schema
This commit is contained in:
@@ -5,7 +5,7 @@ from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.auth import ApiPrincipal
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.change_sequence import record_change
|
||||
from govoplan_core.core.events import current_event_trace, normalize_trace_id
|
||||
from govoplan_core.privacy.retention import sanitize_audit_details_for_policy
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""Core auth dependency facade.
|
||||
|
||||
Routers depend on this module instead of a concrete access-provider package.
|
||||
The current implementation delegates to the access module; replacing this
|
||||
facade is the remaining step toward a fully provider-neutral auth kernel.
|
||||
"""
|
||||
|
||||
from govoplan_access.auth import ApiPrincipal, get_api_principal, has_scope, require_any_scope, require_scope
|
||||
|
||||
__all__ = [
|
||||
"ApiPrincipal",
|
||||
"get_api_principal",
|
||||
"has_scope",
|
||||
"require_any_scope",
|
||||
"require_scope",
|
||||
]
|
||||
|
||||
@@ -25,6 +25,9 @@ CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER = "access.governanceMaterializer"
|
||||
CAPABILITY_TENANCY_TENANT_RESOLVER = "tenancy.tenantResolver"
|
||||
CAPABILITY_AUDIT_SINK = "audit.sink"
|
||||
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER = "auth.principalResolver"
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR = "auth.permissionEvaluator"
|
||||
|
||||
ACCESS_CAPABILITY_NAMES = frozenset(
|
||||
{
|
||||
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER,
|
||||
@@ -38,6 +41,15 @@ ACCESS_CAPABILITY_NAMES = frozenset(
|
||||
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER,
|
||||
CAPABILITY_TENANCY_TENANT_RESOLVER,
|
||||
CAPABILITY_AUDIT_SINK,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
}
|
||||
)
|
||||
|
||||
AUTH_CAPABILITY_NAMES = frozenset(
|
||||
{
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from govoplan_core.server.registry import parse_enabled_modules
|
||||
|
||||
MODULE_SETTINGS_KEY = "module_management"
|
||||
INSTALL_PLAN_KEY = "install_plan"
|
||||
REQUIRED_PLATFORM_MODULES = ("tenancy", "access")
|
||||
REQUIRED_PLATFORM_MODULES = ("access",)
|
||||
PROTECTED_MODULES = (*REQUIRED_PLATFORM_MODULES, "admin")
|
||||
INSTALL_PLAN_ACTIONS = ("install", "uninstall")
|
||||
INSTALL_PLAN_STATUSES = ("planned", "applied", "blocked")
|
||||
|
||||
@@ -215,6 +215,8 @@ class ModuleManifest:
|
||||
version: str
|
||||
dependencies: tuple[str, ...] = ()
|
||||
optional_dependencies: tuple[str, ...] = ()
|
||||
required_capabilities: tuple[str, ...] = ()
|
||||
optional_capabilities: tuple[str, ...] = ()
|
||||
permissions: tuple[PermissionDefinition, ...] = ()
|
||||
role_templates: tuple[RoleTemplate, ...] = ()
|
||||
route_factory: RouteFactory | None = None
|
||||
|
||||
@@ -19,6 +19,7 @@ class OrganizationUnitRef:
|
||||
tenant_id: str
|
||||
slug: str
|
||||
name: str
|
||||
unit_type_id: str | None = None
|
||||
parent_id: str | None = None
|
||||
description: str | None = None
|
||||
status: OrganizationStatus = "active"
|
||||
@@ -31,6 +32,7 @@ class OrganizationFunctionRef:
|
||||
organization_unit_id: str
|
||||
slug: str
|
||||
name: str
|
||||
function_type_id: str | None = None
|
||||
description: str | None = None
|
||||
delegable: bool = False
|
||||
act_in_place_allowed: bool = False
|
||||
@@ -41,10 +43,10 @@ class OrganizationFunctionRef:
|
||||
class OrganizationFunctionAssignmentRef:
|
||||
id: str
|
||||
tenant_id: str
|
||||
account_id: str
|
||||
identity_id: str
|
||||
function_id: str
|
||||
organization_unit_id: str
|
||||
identity_id: str | None = None
|
||||
account_id: str | None = None
|
||||
applies_to_subunits: bool = False
|
||||
source: FunctionAssignmentSource = "direct"
|
||||
delegated_from_assignment_id: str | None = None
|
||||
@@ -76,6 +78,14 @@ class OrganizationDirectory(Protocol):
|
||||
def get_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
|
||||
...
|
||||
|
||||
def function_assignments_for_identity(
|
||||
self,
|
||||
identity_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
) -> Sequence[OrganizationFunctionAssignmentRef]:
|
||||
...
|
||||
|
||||
def function_assignments_for_account(
|
||||
self,
|
||||
account_id: str,
|
||||
|
||||
@@ -152,12 +152,16 @@ class PlatformRegistry:
|
||||
|
||||
def validate(self) -> RegistrySnapshot:
|
||||
ordered = tuple(self._topologically_sorted())
|
||||
available_capabilities = set(self._capability_factories)
|
||||
seen_permissions: dict[str, PermissionDefinition] = {}
|
||||
for manifest in ordered:
|
||||
_validate_manifest_shape(manifest)
|
||||
for dependency in manifest.dependencies:
|
||||
if dependency not in self._manifests:
|
||||
raise RegistryError(f"Module {manifest.id!r} depends on unknown module {dependency!r}")
|
||||
for capability in manifest.required_capabilities:
|
||||
if capability not in available_capabilities:
|
||||
raise RegistryError(f"Module {manifest.id!r} requires unavailable capability {capability!r}")
|
||||
for dependency in manifest.optional_dependencies:
|
||||
if dependency == manifest.id:
|
||||
raise RegistryError(f"Module {manifest.id!r} cannot list itself as an optional dependency")
|
||||
@@ -230,10 +234,16 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
||||
|
||||
_validate_dependency_list(manifest.id, "dependencies", manifest.dependencies)
|
||||
_validate_dependency_list(manifest.id, "optional_dependencies", manifest.optional_dependencies)
|
||||
_validate_capability_list(manifest.id, "required_capabilities", manifest.required_capabilities)
|
||||
_validate_capability_list(manifest.id, "optional_capabilities", manifest.optional_capabilities)
|
||||
overlap = set(manifest.dependencies) & set(manifest.optional_dependencies)
|
||||
if overlap:
|
||||
joined = ", ".join(sorted(overlap))
|
||||
raise RegistryError(f"Module {manifest.id!r} lists dependencies as both required and optional: {joined}")
|
||||
capability_overlap = set(manifest.required_capabilities) & set(manifest.optional_capabilities)
|
||||
if capability_overlap:
|
||||
joined = ", ".join(sorted(capability_overlap))
|
||||
raise RegistryError(f"Module {manifest.id!r} lists capabilities as both required and optional: {joined}")
|
||||
|
||||
if manifest.migration_spec is not None:
|
||||
if manifest.migration_spec.module_id != manifest.id:
|
||||
@@ -275,6 +285,14 @@ def _validate_dependency_list(module_id: str, field_name: str, dependencies: tup
|
||||
raise RegistryError(f"Module {module_id!r} has invalid dependency id {dependency!r}")
|
||||
|
||||
|
||||
def _validate_capability_list(module_id: str, field_name: str, capabilities: tuple[str, ...]) -> None:
|
||||
if len(capabilities) != len(set(capabilities)):
|
||||
raise RegistryError(f"Module {module_id!r} has duplicate {field_name}")
|
||||
for capability in capabilities:
|
||||
if not capability.strip() or any(part == "" for part in capability.split(".")):
|
||||
raise RegistryError(f"Module {module_id!r} has invalid capability name {capability!r}")
|
||||
|
||||
|
||||
def _validate_nav_item(module_id: str, item: NavItem) -> None:
|
||||
if not item.path.startswith("/"):
|
||||
raise RegistryError(f"Navigation item for module {module_id!r} must start with '/': {item.path!r}")
|
||||
|
||||
@@ -11,9 +11,9 @@ from govoplan_core.security.permissions import TENANT_SCOPES
|
||||
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
|
||||
from govoplan_core.settings import settings
|
||||
from govoplan_access.backend.admin.service import ensure_default_roles, get_or_create_account
|
||||
from govoplan_access.backend.db.models import Role, SystemRoleAssignment, User, UserRoleAssignment
|
||||
from govoplan_access.backend.db.models import Role, SystemRoleAssignment, Tenant, User, UserRoleAssignment
|
||||
from govoplan_access.backend.security.api_keys import CreatedApiKey, create_api_key
|
||||
from govoplan_tenancy.backend.db.models import Tenant
|
||||
from govoplan_core.tenancy.scope import create_scope_tables
|
||||
|
||||
DEFAULT_SCOPES = sorted(TENANT_SCOPES)
|
||||
|
||||
@@ -38,6 +38,7 @@ def create_all_tables() -> None:
|
||||
build_platform_registry(enabled_modules)
|
||||
|
||||
engine = get_database().engine
|
||||
create_scope_tables(engine)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ REVISION_FILE_FOLDERS = "4e5f6a7b8c9d"
|
||||
REVISION_NAMESPACE_PLATFORM_TABLES = "2e3f4a5b6c7d"
|
||||
REVISION_CORE_CHANGE_SEQUENCE = "3f4a5b6c7d8e"
|
||||
REVISION_HIERARCHICAL_SETTINGS = "f5a6b7c8d9e0"
|
||||
LEGACY_SCOPE_TABLE = "tenancy_tenants"
|
||||
CORE_SCOPE_TABLE = "core_scopes"
|
||||
|
||||
_NAMESPACE_TABLE_RENAMES = (
|
||||
("tenants", "tenancy_tenants"),
|
||||
@@ -126,12 +128,13 @@ _CREATE_ALL_THROUGH_HIERARCHICAL_COLUMNS = {
|
||||
"access_users": {"account_id", "settings", "mail_profile_policy"},
|
||||
"access_groups": {"system_template_id", "system_required", "settings", "mail_profile_policy"},
|
||||
"access_roles": {"system_template_id", "system_required"},
|
||||
"tenancy_tenants": {"settings", "allow_custom_groups", "allow_custom_roles", "allow_api_keys"},
|
||||
"campaigns": {"settings", "mail_profile_policy"},
|
||||
"mail_server_profiles": {"scope_type", "scope_id"},
|
||||
"core_system_settings": {"settings", "allow_tenant_custom_groups", "allow_tenant_custom_roles", "allow_tenant_api_keys"},
|
||||
}
|
||||
|
||||
_SCOPE_TABLE_COLUMNS = {"settings", "allow_custom_groups", "allow_custom_roles", "allow_api_keys"}
|
||||
|
||||
_CREATE_ALL_THROUGH_HIERARCHICAL_INDEXES = {
|
||||
"file_folders": {"uq_file_folders_active_user_path", "uq_file_folders_active_group_path"},
|
||||
"campaign_versions": {
|
||||
@@ -254,6 +257,11 @@ def _has_indexes(inspector, table_name: str, required: set[str]) -> bool:
|
||||
def _has_create_all_schema_through_hierarchical_settings(inspector, tables: set[str]) -> bool:
|
||||
if not _CREATE_ALL_THROUGH_HIERARCHICAL_TABLES.issubset(tables):
|
||||
return False
|
||||
if not any(
|
||||
table_name in tables and _has_columns(inspector, table_name, _SCOPE_TABLE_COLUMNS)
|
||||
for table_name in (CORE_SCOPE_TABLE, LEGACY_SCOPE_TABLE)
|
||||
):
|
||||
return False
|
||||
for table_name, required_columns in _CREATE_ALL_THROUGH_HIERARCHICAL_COLUMNS.items():
|
||||
if table_name not in tables or not _has_columns(inspector, table_name, required_columns):
|
||||
return False
|
||||
@@ -295,6 +303,53 @@ def _rename_table(connection, old_name: str, new_name: str) -> None:
|
||||
connection.execute(text(f"ALTER TABLE {quoted_old} RENAME TO {quoted_new}"))
|
||||
|
||||
|
||||
def _reconcile_scope_table_names(connection, tables: set[str]) -> bool:
|
||||
if LEGACY_SCOPE_TABLE not in tables:
|
||||
return False
|
||||
|
||||
changed = False
|
||||
if CORE_SCOPE_TABLE in tables:
|
||||
if _row_count(connection, CORE_SCOPE_TABLE) == 0:
|
||||
_drop_table(connection, CORE_SCOPE_TABLE)
|
||||
tables.remove(CORE_SCOPE_TABLE)
|
||||
changed = True
|
||||
elif _row_count(connection, LEGACY_SCOPE_TABLE) == 0:
|
||||
_drop_table(connection, LEGACY_SCOPE_TABLE)
|
||||
tables.remove(LEGACY_SCOPE_TABLE)
|
||||
changed = True
|
||||
else:
|
||||
raise RuntimeError(f"Cannot reconcile non-empty {LEGACY_SCOPE_TABLE} over non-empty {CORE_SCOPE_TABLE}")
|
||||
|
||||
if LEGACY_SCOPE_TABLE in tables and CORE_SCOPE_TABLE not in tables:
|
||||
_rename_table(connection, LEGACY_SCOPE_TABLE, CORE_SCOPE_TABLE)
|
||||
tables.remove(LEGACY_SCOPE_TABLE)
|
||||
tables.add(CORE_SCOPE_TABLE)
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def reconcile_scope_table_retirement_drift(database_url: str | None = None) -> bool:
|
||||
"""Repair databases that are stamped after the scope-table rename.
|
||||
|
||||
The real Alembic migration performs the same rename. This helper covers
|
||||
development databases that were manually stamped or partially created via
|
||||
create_all(), where the version marker can be newer than the table name.
|
||||
It runs after Alembic upgrades so older migrations can still use the
|
||||
historical table name while they execute.
|
||||
"""
|
||||
|
||||
url = database_url or settings.database_url
|
||||
changed = False
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
with engine.begin() as connection:
|
||||
tables = set(inspect(connection).get_table_names())
|
||||
changed = _reconcile_scope_table_names(connection, tables)
|
||||
finally:
|
||||
engine.dispose()
|
||||
return changed
|
||||
|
||||
|
||||
def reconcile_namespace_table_drift(database_url: str | None = None) -> bool:
|
||||
"""Repair dev databases stamped past the namespace-table migration.
|
||||
|
||||
@@ -466,6 +521,8 @@ def migrate_database(
|
||||
),
|
||||
"heads",
|
||||
)
|
||||
if reconcile_legacy_schema:
|
||||
reconcile_scope_table_retirement_drift(url)
|
||||
current = database_revision(url)
|
||||
return MigrationResult(
|
||||
previous_revision=previous,
|
||||
|
||||
@@ -23,7 +23,7 @@ from govoplan_access.backend.db.models import Group, User
|
||||
from govoplan_audit.backend.db.models import AuditLog
|
||||
from govoplan_core.admin.models import SystemSettings
|
||||
from govoplan_core.settings import settings
|
||||
from govoplan_tenancy.backend.db.models import Tenant
|
||||
from govoplan_core.tenancy.scope import Tenant
|
||||
|
||||
PRIVACY_POLICY_SETTINGS_KEY = "privacy_retention_policy"
|
||||
RETENTION_DAY_KEYS = (
|
||||
|
||||
@@ -5,7 +5,7 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import Depends, FastAPI
|
||||
from sqlalchemy.engine import make_url
|
||||
|
||||
from govoplan_access.auth import ApiPrincipal, require_scope
|
||||
from govoplan_core.auth import ApiPrincipal, require_scope
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.db.bootstrap import bootstrap_dev_data, create_all_tables
|
||||
from govoplan_core.db.session import get_database
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterable, Sequence
|
||||
import importlib
|
||||
|
||||
from govoplan_access.backend.manifest import get_manifest as get_access_manifest
|
||||
from govoplan_core.core.discovery import discover_module_manifests
|
||||
from govoplan_core.core.modules import ModuleManifest
|
||||
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||
from govoplan_tenancy.backend.manifest import get_manifest as get_tenancy_manifest
|
||||
|
||||
ManifestFactory = Callable[[], ModuleManifest]
|
||||
|
||||
_BUILTIN_MANIFESTS = {
|
||||
"access": "govoplan_access.backend.manifest:get_manifest",
|
||||
"tenancy": "govoplan_tenancy.backend.manifest:get_manifest",
|
||||
}
|
||||
|
||||
|
||||
def parse_enabled_modules(value: str | Iterable[str]) -> list[str]:
|
||||
if isinstance(value, str):
|
||||
@@ -32,8 +36,20 @@ def available_module_manifests(
|
||||
ignore_load_errors=ignore_load_errors,
|
||||
)
|
||||
}
|
||||
manifests.setdefault("tenancy", get_tenancy_manifest())
|
||||
manifests.setdefault("access", get_access_manifest())
|
||||
loading_all = enabled_modules is None
|
||||
builtin_ids = set(_BUILTIN_MANIFESTS) if loading_all else set(parse_enabled_modules(enabled_modules))
|
||||
for module_id in sorted(builtin_ids & set(_BUILTIN_MANIFESTS)):
|
||||
if module_id in manifests:
|
||||
continue
|
||||
try:
|
||||
manifests[module_id] = _load_builtin_manifest(module_id)
|
||||
except ModuleNotFoundError as exc:
|
||||
module_root = _BUILTIN_MANIFESTS[module_id].split(":", 1)[0].split(".", 1)[0]
|
||||
if exc.name != module_root or (not ignore_load_errors and not loading_all):
|
||||
raise
|
||||
except Exception:
|
||||
if not ignore_load_errors:
|
||||
raise
|
||||
for factory in manifest_factories:
|
||||
manifest = factory()
|
||||
manifests[manifest.id] = manifest
|
||||
@@ -42,15 +58,8 @@ def available_module_manifests(
|
||||
|
||||
def build_platform_registry(enabled_modules: str | Iterable[str], *, manifest_factories: Sequence[ManifestFactory] = ()) -> PlatformRegistry:
|
||||
requested = parse_enabled_modules(enabled_modules)
|
||||
if "tenancy" not in requested:
|
||||
requested.insert(0, "tenancy")
|
||||
if "access" not in requested:
|
||||
tenancy_index = requested.index("tenancy")
|
||||
requested.insert(tenancy_index + 1, "access")
|
||||
elif requested.index("access") < requested.index("tenancy"):
|
||||
requested.remove("access")
|
||||
tenancy_index = requested.index("tenancy")
|
||||
requested.insert(tenancy_index + 1, "access")
|
||||
requested.insert(0, "access")
|
||||
|
||||
available = available_module_manifests(manifest_factories, enabled_modules=requested)
|
||||
registry = PlatformRegistry()
|
||||
@@ -61,3 +70,14 @@ def build_platform_registry(enabled_modules: str | Iterable[str], *, manifest_fa
|
||||
registry.register(manifest)
|
||||
registry.validate()
|
||||
return registry
|
||||
|
||||
|
||||
def _load_builtin_manifest(module_id: str) -> ModuleManifest:
|
||||
target = _BUILTIN_MANIFESTS[module_id]
|
||||
module_name, function_name = target.split(":", 1)
|
||||
module = importlib.import_module(module_name)
|
||||
factory = getattr(module, function_name)
|
||||
manifest = factory()
|
||||
if not isinstance(manifest, ModuleManifest):
|
||||
raise RegistryError(f"Built-in module manifest factory returned invalid value: {module_id}")
|
||||
return manifest
|
||||
|
||||
50
src/govoplan_core/tenancy/scope.py
Normal file
50
src/govoplan_core/tenancy/scope.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Boolean, JSON, MetaData, String, Text
|
||||
from sqlalchemy.types import DateTime
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import Mapped, mapped_column, registry
|
||||
|
||||
from govoplan_core.db.base import NAMING_CONVENTION, utcnow
|
||||
|
||||
scope_registry = registry(metadata=MetaData(naming_convention=NAMING_CONVENTION))
|
||||
|
||||
|
||||
def new_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
@scope_registry.mapped
|
||||
class Tenant:
|
||||
"""Core-owned scope row used when the tenancy module is not installed.
|
||||
|
||||
The table was historically named ``tenancy_tenants``. It is now owned by
|
||||
core as ``core_scopes`` so access, auth and policy code can use a stable
|
||||
scope identifier without importing or requiring the tenancy module.
|
||||
"""
|
||||
|
||||
__tablename__ = "core_scopes"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
default_locale: Mapped[str] = mapped_column(String(20), default="en", nullable=False)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
allow_custom_groups: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
allow_custom_roles: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
allow_api_keys: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False)
|
||||
|
||||
|
||||
def create_scope_tables(engine: Engine) -> None:
|
||||
scope_registry.metadata.create_all(bind=engine, checkfirst=True)
|
||||
|
||||
|
||||
__all__ = ["Tenant", "create_scope_tables", "new_uuid", "scope_registry"]
|
||||
@@ -8,7 +8,7 @@ from govoplan_core.admin.common import AdminValidationError
|
||||
from govoplan_core.admin.settings import get_system_settings
|
||||
from govoplan_core.core.runtime import get_registry
|
||||
from govoplan_access.backend.db.models import ApiKey, Group, User
|
||||
from govoplan_tenancy.backend.db.models import Tenant
|
||||
from govoplan_core.tenancy.scope import Tenant
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
Reference in New Issue
Block a user