fix(acl): authorize scripted alarm commands at their owning equipment's scope (#797)
Scripted condition NodeIds are opaque ScriptedAlarmIds, so splitting them as a topology path could never match an Equipment grant: a narrow equipment grant was denied while a Cluster grant passed. - OtOpcUaNodeManager binds each LIVE scripted condition instance to the EquipmentId it was materialised under (only when it hangs under that folder); dropped on remove / drop-recreate / equipment-subtree removal / rebuild; a same-id re-materialise under another equipment re-parents and re-binds. - DataPlaneAuthoritySnapshot carries EquipmentScopePaths (EquipmentId -> [uns, Area, Line, Equipment]) decoded from the artifact by the same AclScopePathResolver call that plants Equipment grants. - DataPlaneAuthorizer.AuthorizeScriptedCondition re-reads key, grant and generation per call (uncached) and evaluates at the owner path; unbound, replaced or unplaceable owners deny under every grant. No id-split or realm-root fallback. - Native conditions (RawPath) and the ACL-off role gate are unchanged. NodeIds and AlarmCommand.AlarmId are unchanged. Sdk2OpaqueScriptedScopeBaselineTests deliberately flipped: it pinned the denial and now pins owner-grant allow / sibling deny / cluster allow. Follow-up #808 (folder ids + Server-object event visibility for narrow UNS grants).
This commit is contained in:
@@ -66,6 +66,23 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
/// a native re-materialise adds, and <see cref="RebuildAddressSpace"/> clears it alongside
|
||||
/// <see cref="_alarmConditions"/> so a re-materialise as the other kind is correct.</summary>
|
||||
private readonly HashSet<RealmKey> _nativeAlarmNodeIds = new();
|
||||
/// <summary>
|
||||
/// Gitea #797 — the explicit binding from each LIVE scripted condition to the equipment that owns it.
|
||||
/// A scripted condition's NodeId is its opaque <c>ScriptedAlarmId</c> and cannot be split into an ACL
|
||||
/// scope, so with <c>Security:Acl:Enabled</c> its Part 9 commands are authorized at the owner's scope
|
||||
/// instead (<see cref="Security.DataPlaneAuthorizer.AuthorizeScriptedCondition"/>), resolved through the
|
||||
/// CURRENT authority generation at each call.
|
||||
/// <para>
|
||||
/// Written only by <see cref="MaterialiseAlarmCondition"/>, from the deployed composition's
|
||||
/// <c>EquipmentId</c>, and only when the condition actually hangs under that equipment's folder.
|
||||
/// Each entry pins the <see cref="AlarmConditionState"/> instance it was made for, so a call that
|
||||
/// reaches a removed or replaced instance (rebuild, surgical remove, delete + recreate under another
|
||||
/// equipment) finds no binding and is denied. Removed in lock-step with <see cref="_alarmConditions"/>
|
||||
/// on every teardown path; cleared by <see cref="RebuildAddressSpace"/>. Never populated for native
|
||||
/// conditions, which keep their RawPath-scoped decision. Guarded by <c>Lock</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private readonly Dictionary<RealmKey, ScriptedAlarmOwner> _scriptedAlarmOwners = new();
|
||||
/// <summary>Every NATIVE condition's live state, captured by <see cref="RebuildAddressSpace"/>
|
||||
/// just before it clears <see cref="_alarmConditions"/>, waiting for <see cref="MaterialiseAlarmCondition"/>
|
||||
/// to re-create the same id as native in the same apply. Whatever is unclaimed when
|
||||
@@ -1493,7 +1510,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// re-severity/type change arrives as a ChangedAlarms delta ⇒ full rebuild (the map is cleared
|
||||
// first), so it never reaches this path with a stale-but-present entry; the drop-and-recreate
|
||||
// stays ONLY for the kind-swap (native↔scripted) case, which flips the native flag.
|
||||
if (_alarmConditions.ContainsKey(conditionKey) && _nativeAlarmNodeIds.Contains(conditionKey) == isNative)
|
||||
// Gitea #797: a scripted condition whose OWNER changed (same id, different equipment) is not "the same"
|
||||
// and falls through to the drop-and-recreate, so it is re-parented and re-bound rather than left
|
||||
// under — and authorized as — the equipment it no longer belongs to.
|
||||
if (_alarmConditions.ContainsKey(conditionKey) && _nativeAlarmNodeIds.Contains(conditionKey) == isNative
|
||||
&& (isNative || SameScriptedOwner(conditionKey, equipmentNodeId, realm)))
|
||||
return;
|
||||
// Review 01 H2 — a variable or folder already holding this id in this realm wins; never shadow it.
|
||||
if (!_alarmConditions.ContainsKey(conditionKey) && IsHeldByAnotherKind(conditionKey, "alarm condition"))
|
||||
@@ -1522,6 +1543,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// H6a: re-materialising the same id as the OTHER kind (native↔scripted) must reflect the new
|
||||
// kind, so always drop the stale native flag first and only re-add it below when isNative.
|
||||
_nativeAlarmNodeIds.Remove(conditionKey);
|
||||
// Gitea #797: the dropped instance's owner binding goes with it; the new one is bound below.
|
||||
_scriptedAlarmOwners.Remove(conditionKey);
|
||||
|
||||
var parent = ResolveParentFolder(equipmentNodeId, realm);
|
||||
|
||||
@@ -1709,6 +1732,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// H6a: record native (driver-fed) conditions so a later task can route their inbound
|
||||
// Acknowledge to the driver rather than the scripted engine.
|
||||
if (isNative) _nativeAlarmNodeIds.Add(conditionKey);
|
||||
// Gitea #797: bind the scripted condition to its owning equipment — this exact instance, and only
|
||||
// when it really hangs under that equipment's folder (an unresolved parent leaves it unbound ⇒ every
|
||||
// ACL-enforced command on it is denied, never authorized at some guessed scope).
|
||||
else if (BindableScriptedOwner(equipmentNodeId, realm) is { } owner && ReferenceEquals(parent, owner.Folder))
|
||||
_scriptedAlarmOwners[conditionKey] = new ScriptedAlarmOwner(alarm, owner.EquipmentId);
|
||||
|
||||
// A native condition the rebuild in this apply discarded gets its live state back. Removed
|
||||
// whatever the kind, so a native→scripted swap on the same id drops the capture instead of leaving it
|
||||
@@ -2089,6 +2117,50 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
lock (Lock) return _nativeAlarmNodeIds.Contains(MapKey(realm, alarmNodeId));
|
||||
}
|
||||
|
||||
/// <summary>Gitea #797 — the owning equipment a live scripted condition is bound to, for tests and diagnostics.</summary>
|
||||
/// <param name="alarmNodeId">The scripted condition's id (its <c>ScriptedAlarmId</c>).</param>
|
||||
/// <param name="realm">The realm the condition lives in.</param>
|
||||
/// <returns>The bound <c>EquipmentId</c>, or null when the condition is unknown, native or unbound.</returns>
|
||||
internal string? ScriptedAlarmOwnerOf(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
var key = MapKey(realm, alarmNodeId);
|
||||
return _scriptedAlarmOwners.TryGetValue(key, out var b)
|
||||
&& _alarmConditions.TryGetValue(key, out var live)
|
||||
&& ReferenceEquals(b.Alarm, live)
|
||||
? b.EquipmentId
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The equipment folder a scripted condition materialised with <paramref name="equipmentNodeId"/> would be
|
||||
/// bound to: only a UNS condition naming an EXISTING folder qualifies. Call under <c>Lock</c>.
|
||||
/// </summary>
|
||||
private (string EquipmentId, FolderState Folder)? BindableScriptedOwner(string? equipmentNodeId, AddressSpaceRealm realm) =>
|
||||
realm == AddressSpaceRealm.Uns
|
||||
&& !string.IsNullOrEmpty(equipmentNodeId)
|
||||
&& _folders.TryGetValue(MapKey(realm, equipmentNodeId), out var folder)
|
||||
? (equipmentNodeId, folder)
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// Whether re-materialising the existing scripted condition at <paramref name="key"/> under
|
||||
/// <paramref name="equipmentNodeId"/> leaves its owner binding exactly as it is. Call under <c>Lock</c>.
|
||||
/// </summary>
|
||||
private bool SameScriptedOwner(RealmKey key, string? equipmentNodeId, AddressSpaceRealm realm)
|
||||
{
|
||||
var current = _scriptedAlarmOwners.TryGetValue(key, out var b) ? b.EquipmentId : null;
|
||||
var wanted = BindableScriptedOwner(equipmentNodeId, realm)?.EquipmentId;
|
||||
return string.Equals(current, wanted, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>Gitea #797 — one live scripted condition instance and the equipment that owns it.</summary>
|
||||
/// <param name="Alarm">The exact condition instance the binding was made for.</param>
|
||||
/// <param name="EquipmentId">The owning equipment's logical id (its UNS folder's <c>s=</c> id).</param>
|
||||
private sealed record ScriptedAlarmOwner(AlarmConditionState Alarm, string EquipmentId);
|
||||
|
||||
/// <summary>
|
||||
/// Shared body for every inbound Part 9 alarm method handler (T18). Resolves the calling
|
||||
/// principal off the SDK <paramref name="context"/>, applies the <c>AlarmAck</c> role gate
|
||||
@@ -2218,8 +2290,33 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
{
|
||||
if (AclEnforced)
|
||||
{
|
||||
return _authorizer!.Authorize(
|
||||
identity, AlarmOperationFor(operation), condition.NodeId, RealmOf(condition.NodeId));
|
||||
// Native conditions (ConditionId = RawPath) keep the NodeId-scoped decision unchanged: their id IS
|
||||
// the raw tag's path. Everything else is a scripted condition with an opaque id, authorized ONLY at
|
||||
// the scope of the equipment its exact live instance is bound to (Gitea #797) — no binding, no
|
||||
// authority. Never a fallback to splitting the opaque id.
|
||||
var native = false;
|
||||
string? owner = null;
|
||||
lock (Lock)
|
||||
{
|
||||
var key = MapKey(condition.NodeId);
|
||||
if (_nativeAlarmNodeIds.Contains(key))
|
||||
{
|
||||
native = true;
|
||||
}
|
||||
else if (_scriptedAlarmOwners.TryGetValue(key, out var binding)
|
||||
&& ReferenceEquals(binding.Alarm, condition)
|
||||
&& _alarmConditions.TryGetValue(key, out var live)
|
||||
&& ReferenceEquals(live, condition))
|
||||
{
|
||||
owner = binding.EquipmentId;
|
||||
}
|
||||
}
|
||||
|
||||
return native
|
||||
? _authorizer!.Authorize(
|
||||
identity, AlarmOperationFor(operation), condition.NodeId, RealmOf(condition.NodeId))
|
||||
: _authorizer!.AuthorizeScriptedCondition(
|
||||
identity, AlarmOperationFor(operation), condition.NodeId, owner);
|
||||
}
|
||||
|
||||
// The activation-time AlarmAck role is honoured only while the key that earned it is
|
||||
@@ -3755,6 +3852,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// H6a: drop the native-alarm flags in lock-step with the conditions they classify, so a
|
||||
// re-materialise on the next apply (possibly as the other kind) starts from a clean slate.
|
||||
_nativeAlarmNodeIds.Clear();
|
||||
_scriptedAlarmOwners.Clear();
|
||||
|
||||
foreach (var f in _folders.Values) PredefinedNodes?.Remove(f.NodeId);
|
||||
_folders.Clear();
|
||||
@@ -3913,6 +4011,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
condition.Parent?.RemoveChild(condition);
|
||||
ForgetPredefinedSubtree(condition);
|
||||
_nativeAlarmNodeIds.Remove(key);
|
||||
_scriptedAlarmOwners.Remove(key);
|
||||
LowerAllEventsMonitoring(condition);
|
||||
e = BuildNodesRemovedModelChange(alarmNodeId, realm);
|
||||
}
|
||||
@@ -4021,6 +4120,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
if (c.Parent is { } parent && !removedParents.Contains(parent)) parent.RemoveChild(c);
|
||||
ForgetPredefinedSubtree(c);
|
||||
_nativeAlarmNodeIds.Remove(key);
|
||||
_scriptedAlarmOwners.Remove(key);
|
||||
LowerAllEventsMonitoring(c);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -239,6 +239,91 @@ public sealed class DataPlaneAuthorizer
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authorizes a Part 9 command on a <b>scripted</b> alarm condition at the scope of the equipment the
|
||||
/// node manager has bound that exact live condition to (Gitea #797) — never at a scope parsed out of
|
||||
/// the condition's NodeId.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A scripted condition's NodeId is its opaque, operator-authored <c>ScriptedAlarmId</c>
|
||||
/// (<c>^[A-Za-z0-9_-]+$</c>, e.g. <c>scripted-eqa-hi</c>). <see cref="Authorize"/> would split it as
|
||||
/// a topology path — <c>[uns, scripted-eqa-hi]</c> — which no <c>Equipment</c> grant can ever
|
||||
/// match, so before this entry point a session holding the owning equipment's grant was denied
|
||||
/// while a <c>Cluster</c> grant passed. Worse, that one-segment scope would be satisfied by an
|
||||
/// unrelated Area that happened to share the alarm's id.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Every input is re-read at each call, and every gap denies.</b> The current snapshot (so the
|
||||
/// current generation, trie and grants), the calling key's usability against this authorizer's
|
||||
/// clock, and the owner's path in THAT generation's <see cref="DataPlaneAuthoritySnapshot.EquipmentScopePaths"/>.
|
||||
/// No owner binding (an unknown, removed or replaced condition), an owner the generation cannot
|
||||
/// place, or a path that does not name an equipment each deny — there is no fallback to the
|
||||
/// realm root, the cluster, or the identifier. The decision cache is deliberately not used: the
|
||||
/// binding lives in the address space, not in the generation, so a cached verdict keyed on the
|
||||
/// NodeId could outlive a re-bind (delete + recreate of the same id under another equipment).
|
||||
/// Alarm commands are not a request-rate seam, so the uncached walk costs nothing that matters.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The scope is the equipment's OWN node, not the equipment plus a leaf: grants on the equipment
|
||||
/// or any ancestor (line, area, UNS namespace, cluster) confer the command, and a grant on a single
|
||||
/// tag beneath the equipment does not — a tag grant is about that signal, not the equipment's alarms.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="identity">The role-carrying identity off the SDK context, or null.</param>
|
||||
/// <param name="operation">The alarm operation being attempted.</param>
|
||||
/// <param name="conditionNodeId">The condition the method targets (for the denial log only — never parsed).</param>
|
||||
/// <param name="ownerEquipmentId">The <c>EquipmentId</c> the live condition is bound to, or null when the
|
||||
/// node manager holds no binding for this exact instance.</param>
|
||||
/// <returns><c>null</c> to proceed; otherwise the veto to return to the SDK.</returns>
|
||||
public ServiceResult? AuthorizeScriptedCondition(
|
||||
RoleCarryingUserIdentity? identity,
|
||||
OpcUaOperation operation,
|
||||
NodeId conditionNodeId,
|
||||
string? ownerEquipmentId)
|
||||
{
|
||||
if (identity is null)
|
||||
return Deny(operation, conditionNodeId, principal: null, "the session carries no role-carrying identity");
|
||||
|
||||
if (string.IsNullOrEmpty(identity.Principal))
|
||||
return Deny(operation, conditionNodeId, principal: null, "the session resolved no ACL principal");
|
||||
|
||||
var snapshot = _authority.Current;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(snapshot.ClusterId))
|
||||
{
|
||||
return Deny(operation, conditionNodeId, identity.Principal,
|
||||
"this node has no cluster identity, so no permission trie exists to evaluate against");
|
||||
}
|
||||
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
if (identity.ApiKeyId is { Length: > 0 } keyId && snapshot.FindUsableKey(keyId, now) is null)
|
||||
return DenyRevokedKey(operation, conditionNodeId, identity.Principal, keyId, snapshot.GenerationId, cached: false);
|
||||
|
||||
if (string.IsNullOrEmpty(ownerEquipmentId))
|
||||
{
|
||||
return Deny(operation, conditionNodeId, identity.Principal,
|
||||
"the scripted condition has no live owner binding (unknown, removed or replaced)");
|
||||
}
|
||||
|
||||
if (!snapshot.TryGetEquipmentUnsSegments(ownerEquipmentId, out var segments))
|
||||
{
|
||||
return Deny(operation, conditionNodeId, identity.Principal,
|
||||
$"owner equipment {ownerEquipmentId} has no UNS path in generation {snapshot.GenerationId}");
|
||||
}
|
||||
|
||||
var scope = new NodeScope
|
||||
{
|
||||
ClusterId = snapshot.ClusterId!,
|
||||
Kind = NodeHierarchyKind.Uns,
|
||||
Segments = segments,
|
||||
};
|
||||
|
||||
return Walk(snapshot, identity, operation, scope, now)
|
||||
? null
|
||||
: DenyNoGrant(operation, conditionNodeId, identity.Principal, snapshot.GenerationId, cached: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The decision itself. Ordered so the cached seams reach their answer having allocated nothing:
|
||||
/// every check that must run per call comes first, the memoized verdict is consulted next, and the
|
||||
|
||||
@@ -62,6 +62,16 @@ public sealed record DataPlaneAuthorityProjection(
|
||||
IReadOnlyList<NodeAcl> Grants,
|
||||
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> ScopePaths)
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>EquipmentId → [uns, Area, Line, Equipment]</c> for every equipment in the artifact whose UNS
|
||||
/// chain resolves — the owner paths scripted alarm conditions are authorized at (Gitea #797). Resolved by
|
||||
/// the SAME <see cref="AclScopePathResolver"/> instance as <see cref="ScopePaths"/>, so an
|
||||
/// <c>Equipment</c> grant's planted path and the owner path of that equipment's scripted alarms are
|
||||
/// equal by construction. Empty when the artifact carries no grants (nothing could be authorized).
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, NodeAclPath> EquipmentScopePaths { get; init; } =
|
||||
new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>The projection of an absent or unparseable artifact: no cluster, no keys, no grants.</summary>
|
||||
public static readonly DataPlaneAuthorityProjection Empty =
|
||||
new(null, [], [], new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>());
|
||||
@@ -907,8 +917,11 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
|
||||
grants.Add(acl);
|
||||
}
|
||||
|
||||
return new DataPlaneAuthorityProjection(
|
||||
scope.ClusterId, keys, grants, BuildAclScopePaths(root, grants));
|
||||
var (scopePaths, equipmentScopePaths) = BuildAclScopePaths(root, grants);
|
||||
return new DataPlaneAuthorityProjection(scope.ClusterId, keys, grants, scopePaths)
|
||||
{
|
||||
EquipmentScopePaths = equipmentScopePaths,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -916,11 +929,18 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
|
||||
/// topology, using the same <c>RawPathResolver</c> the address space is built through — so an ACL
|
||||
/// path and the RawPath that forms a node's NodeId cannot drift apart.
|
||||
/// </summary>
|
||||
private static IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> BuildAclScopePaths(
|
||||
/// <returns>The grant scope paths, plus every equipment's own UNS path (the scripted-alarm owner lookup,
|
||||
/// Gitea #797) from the same resolver.</returns>
|
||||
private static (IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> ScopePaths,
|
||||
IReadOnlyDictionary<string, NodeAclPath> EquipmentScopePaths) BuildAclScopePaths(
|
||||
JsonElement root,
|
||||
IReadOnlyList<NodeAcl> grants)
|
||||
{
|
||||
if (grants.Count == 0) return new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>();
|
||||
if (grants.Count == 0)
|
||||
{
|
||||
return (new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>(),
|
||||
new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
var folders = new Dictionary<string, (string? ParentId, string Name)>(StringComparer.Ordinal);
|
||||
foreach (var el in EnumerateArray(root, "RawFolders"))
|
||||
@@ -1011,7 +1031,17 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
|
||||
|
||||
var resolver = new AclScopePathResolver(
|
||||
folders, drivers, devices, groups, tags, areas, lines, equipment, references);
|
||||
return resolver.Resolve(grants);
|
||||
|
||||
// Every equipment's own path, through the SAME resolver call an Equipment grant's path takes — the
|
||||
// scripted-alarm owner binding authorizes at exactly the node an Equipment grant is planted at. An
|
||||
// equipment whose chain does not resolve is simply absent (its scripted conditions then deny).
|
||||
var equipmentPaths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var equipmentId in equipment.Keys)
|
||||
{
|
||||
if (resolver.ResolveOne(NodeAclScopeKind.Equipment, equipmentId) is [var path]) equipmentPaths[equipmentId] = path;
|
||||
}
|
||||
|
||||
return (resolver.Resolve(grants), equipmentPaths);
|
||||
}
|
||||
|
||||
/// <summary>Reads one <c>ApiKeys</c> element; null when a field the credential check depends on is
|
||||
|
||||
@@ -1425,7 +1425,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
// would be a different byte sequence and would not match the deployment's RevisionHash.
|
||||
var revision = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(artifactBytes));
|
||||
var snapshot = _dataPlaneAuthority.Apply(
|
||||
projection.ClusterId, revision, projection.Keys, projection.Grants, projection.ScopePaths);
|
||||
projection.ClusterId, revision, projection.Keys, projection.Grants, projection.ScopePaths,
|
||||
projection.EquipmentScopePaths);
|
||||
|
||||
_log.Info(
|
||||
"OpcUaPublish: data-plane authority generation {Generation} installed (cluster={Cluster}, revision={Revision}, keys={Keys}, grants={Grants}, resolvedScopes={ResolvedScopes})",
|
||||
|
||||
Reference in New Issue
Block a user