mirror of
https://github.com/microsoft/regorus.git
synced 2026-08-05 02:16:11 +00:00
feat(rbac)!: add Azure RBAC engine, FFI API, and cross-language tests (#577)
- add Azure RBAC condition interpreter and builtin evaluation in core (expressions, parser updates, evaluator, and test harness) - introduce comprehensive RBAC YAML test suites and coverage for i - action/suboperation - strings - numbers - bools - IP - GUID - dates - times - lists - quantifiers (ForAnyOfAnyValues, ForAllOfAllValues) - expose RBAC evaluation through FFI with an `rbac` feature flag enabled by default - add C# `RbacEngine` wrapper + P/Invoke entrypoint and document usage in C# README - expand C# tests to execute all RBAC YAML cases with per-case logging - wire test assets into C# test output and centralize YAML dependency versions Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com>
This commit is contained in:
committed by
GitHub
parent
8814eda0ae
commit
47cc27ff49
@@ -10,5 +10,6 @@
|
||||
<PackageVersion Include="Regorus" Version="$(RegorusPackageVersion)$(RegorusPackageVersionSuffix)" />
|
||||
<PackageVersion Include="MSTest" Version="3.8.2" />
|
||||
<PackageVersion Include="System.Text.Json" Version="8.0.5" />
|
||||
<PackageVersion Include="YamlDotNet" Version="13.7.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -104,3 +104,49 @@ vm.SetInputJson(Input);
|
||||
var result = vm.Execute();
|
||||
Console.WriteLine($"allow: {result}");
|
||||
```
|
||||
|
||||
## Azure RBAC Condition Evaluation
|
||||
|
||||
Evaluate Azure RBAC condition expressions directly with a JSON evaluation context:
|
||||
|
||||
```csharp
|
||||
using Regorus;
|
||||
|
||||
const string Condition = "@Resource[owner] StringEquals 'alice'";
|
||||
const string ContextJson = """
|
||||
{
|
||||
"principal": {
|
||||
"id": "user-1",
|
||||
"principal_type": "User",
|
||||
"custom_security_attributes": {}
|
||||
},
|
||||
"resource": {
|
||||
"id": "/subscriptions/s1",
|
||||
"resource_type": "Microsoft.Storage/storageAccounts",
|
||||
"scope": "/subscriptions/s1",
|
||||
"attributes": {
|
||||
"owner": "alice",
|
||||
"confidential": true
|
||||
}
|
||||
},
|
||||
"request": {
|
||||
"action": "Microsoft.Storage/storageAccounts/read",
|
||||
"data_action": null,
|
||||
"attributes": {
|
||||
"clientIP": "10.0.0.1"
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
"is_private_link": null,
|
||||
"private_endpoint": null,
|
||||
"subnet": null,
|
||||
"utc_now": "2023-05-01T12:00:00Z"
|
||||
},
|
||||
"action": "Microsoft.Storage/storageAccounts/read",
|
||||
"suboperation": null
|
||||
}
|
||||
""";
|
||||
|
||||
var allowed = RbacEngine.EvaluateCondition(Condition, ContextJson);
|
||||
Console.WriteLine($"RBAC condition allowed: {allowed}");
|
||||
```
|
||||
|
||||
374
bindings/csharp/Regorus.Tests/RbacEngineTests.cs
Normal file
374
bindings/csharp/Regorus.Tests/RbacEngineTests.cs
Normal file
@@ -0,0 +1,374 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using Regorus;
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
namespace Regorus.Tests;
|
||||
|
||||
[TestClass]
|
||||
public class RbacEngineTests
|
||||
{
|
||||
public TestContext? TestContext { get; set; }
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private const string BaseContextJson = """
|
||||
{
|
||||
"principal": {
|
||||
"id": "user-1",
|
||||
"principal_type": "User",
|
||||
"custom_security_attributes": {
|
||||
"department": "eng",
|
||||
"levels": ["L1", "L2"]
|
||||
}
|
||||
},
|
||||
"resource": {
|
||||
"id": "/subscriptions/s1",
|
||||
"resource_type": "Microsoft.Storage/storageAccounts",
|
||||
"scope": "/subscriptions/s1",
|
||||
"attributes": {
|
||||
"owner": "alice",
|
||||
"tags": ["a", "b"],
|
||||
"count": 5,
|
||||
"enabled": false,
|
||||
"ip": "10.0.0.5",
|
||||
"guid": "a1b2c3d4-0000-0000-0000-000000000000"
|
||||
}
|
||||
},
|
||||
"request": {
|
||||
"action": "Microsoft.Storage/storageAccounts/read",
|
||||
"data_action": "Microsoft.Storage/storageAccounts/read",
|
||||
"attributes": {
|
||||
"owner": "alice",
|
||||
"text": "HelloWorld",
|
||||
"tags": ["prod", "gold"],
|
||||
"count": 10,
|
||||
"ratio": 2.5,
|
||||
"enabled": true,
|
||||
"ip": "10.0.0.8",
|
||||
"guid": "A1B2C3D4-0000-0000-0000-000000000000",
|
||||
"time": "12:30:15",
|
||||
"date": "2023-05-01T12:00:00Z",
|
||||
"numbers": [1, 2, 3],
|
||||
"letters": ["a", "b"]
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
"is_private_link": false,
|
||||
"private_endpoint": null,
|
||||
"subnet": null,
|
||||
"utc_now": "2023-05-01T12:00:00Z"
|
||||
},
|
||||
"action": "Microsoft.Storage/storageAccounts/read",
|
||||
"suboperation": "sub/read"
|
||||
}
|
||||
""";
|
||||
|
||||
[TestMethod]
|
||||
public void Rbac_engine_evaluates_all_yaml_cases()
|
||||
{
|
||||
var cases = LoadEvalTestCases().ToList();
|
||||
Assert.IsTrue(cases.Count > 0, "No RBAC test cases were loaded.");
|
||||
|
||||
foreach (var testCase in cases)
|
||||
{
|
||||
TestContext?.WriteLine($"RBAC case: {testCase.Name} -> {testCase.Condition}");
|
||||
var context = BuildBaseContext();
|
||||
if (testCase.Context != null)
|
||||
{
|
||||
ApplyOverrides(context, testCase.Context);
|
||||
}
|
||||
|
||||
var contextJson = context.ToJsonString(JsonOptions);
|
||||
var result = RbacEngine.EvaluateCondition(testCase.Condition, contextJson);
|
||||
|
||||
Assert.AreEqual(
|
||||
testCase.Expected,
|
||||
result,
|
||||
$"RBAC test '{testCase.Name}' failed for condition '{testCase.Condition}'.");
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonObject BuildBaseContext()
|
||||
{
|
||||
var node = JsonNode.Parse(BaseContextJson) as JsonObject;
|
||||
if (node is null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to parse base context JSON.");
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
private static void ApplyOverrides(JsonObject context, EvalContextOverrides overrides)
|
||||
{
|
||||
var principal = (JsonObject?)context["principal"]
|
||||
?? throw new InvalidOperationException("Missing principal section.");
|
||||
var resource = (JsonObject?)context["resource"]
|
||||
?? throw new InvalidOperationException("Missing resource section.");
|
||||
var request = (JsonObject?)context["request"]
|
||||
?? throw new InvalidOperationException("Missing request section.");
|
||||
var environment = (JsonObject?)context["environment"]
|
||||
?? throw new InvalidOperationException("Missing environment section.");
|
||||
|
||||
if (!string.IsNullOrEmpty(overrides.Action))
|
||||
{
|
||||
context["action"] = overrides.Action;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(overrides.Suboperation))
|
||||
{
|
||||
context["suboperation"] = overrides.Suboperation;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(overrides.RequestAction))
|
||||
{
|
||||
request["action"] = overrides.RequestAction;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(overrides.DataAction))
|
||||
{
|
||||
request["data_action"] = overrides.DataAction;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(overrides.PrincipalId))
|
||||
{
|
||||
principal["id"] = overrides.PrincipalId;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(overrides.PrincipalType))
|
||||
{
|
||||
principal["principal_type"] = overrides.PrincipalType;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(overrides.ResourceId))
|
||||
{
|
||||
resource["id"] = overrides.ResourceId;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(overrides.ResourceType))
|
||||
{
|
||||
resource["resource_type"] = overrides.ResourceType;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(overrides.ResourceScope))
|
||||
{
|
||||
resource["scope"] = overrides.ResourceScope;
|
||||
}
|
||||
|
||||
if (overrides.RequestAttributes != null)
|
||||
{
|
||||
request["attributes"] = ConvertToJsonNode(overrides.RequestAttributes);
|
||||
}
|
||||
|
||||
if (overrides.ResourceAttributes != null)
|
||||
{
|
||||
resource["attributes"] = ConvertToJsonNode(overrides.ResourceAttributes);
|
||||
}
|
||||
|
||||
if (overrides.PrincipalCustomSecurityAttributes != null)
|
||||
{
|
||||
principal["custom_security_attributes"] = ConvertToJsonNode(overrides.PrincipalCustomSecurityAttributes);
|
||||
}
|
||||
|
||||
if (overrides.Environment != null)
|
||||
{
|
||||
if (overrides.Environment.IsPrivateLink.HasValue)
|
||||
{
|
||||
environment["is_private_link"] = overrides.Environment.IsPrivateLink.Value;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(overrides.Environment.PrivateEndpoint))
|
||||
{
|
||||
environment["private_endpoint"] = overrides.Environment.PrivateEndpoint;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(overrides.Environment.Subnet))
|
||||
{
|
||||
environment["subnet"] = overrides.Environment.Subnet;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(overrides.Environment.UtcNow))
|
||||
{
|
||||
environment["utc_now"] = overrides.Environment.UtcNow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<EvalTestCase> LoadEvalTestCases()
|
||||
{
|
||||
var baseDir = Path.Combine(AppContext.BaseDirectory, "test_cases");
|
||||
if (!Directory.Exists(baseDir))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"RBAC test case directory not found: {baseDir}");
|
||||
}
|
||||
|
||||
var deserializer = new DeserializerBuilder()
|
||||
.IgnoreUnmatchedProperties()
|
||||
.Build();
|
||||
|
||||
var files = Directory.EnumerateFiles(baseDir, "*.yaml")
|
||||
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
var yaml = File.ReadAllText(file);
|
||||
var suite = deserializer.Deserialize<EvalTestSuite>(yaml);
|
||||
if (suite?.TestCases is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var testCase in suite.TestCases)
|
||||
{
|
||||
yield return testCase;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonNode? ConvertToJsonNode(object? value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (value)
|
||||
{
|
||||
case JsonNode node:
|
||||
return node;
|
||||
case string text:
|
||||
return JsonValue.Create(text);
|
||||
case bool boolean:
|
||||
return JsonValue.Create(boolean);
|
||||
case int intValue:
|
||||
return JsonValue.Create(intValue);
|
||||
case long longValue:
|
||||
return JsonValue.Create(longValue);
|
||||
case double doubleValue:
|
||||
return JsonValue.Create(doubleValue);
|
||||
case float floatValue:
|
||||
return JsonValue.Create(floatValue);
|
||||
case decimal decimalValue:
|
||||
return JsonValue.Create(decimalValue);
|
||||
case DateTime dateTime:
|
||||
return JsonValue.Create(dateTime.ToString("O"));
|
||||
case IDictionary dictionary:
|
||||
{
|
||||
var obj = new JsonObject();
|
||||
foreach (DictionaryEntry entry in dictionary)
|
||||
{
|
||||
var key = entry.Key?.ToString() ?? string.Empty;
|
||||
obj[key] = ConvertToJsonNode(entry.Value);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
case IEnumerable enumerable:
|
||||
{
|
||||
if (value is string)
|
||||
{
|
||||
return JsonValue.Create(value.ToString());
|
||||
}
|
||||
|
||||
var array = new JsonArray();
|
||||
foreach (var item in enumerable)
|
||||
{
|
||||
array.Add(ConvertToJsonNode(item));
|
||||
}
|
||||
return array;
|
||||
}
|
||||
default:
|
||||
return JsonValue.Create(value.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class EvalTestSuite
|
||||
{
|
||||
[YamlMember(Alias = "test_cases")]
|
||||
public List<EvalTestCase> TestCases { get; set; } = new();
|
||||
}
|
||||
|
||||
private sealed class EvalTestCase
|
||||
{
|
||||
[YamlMember(Alias = "name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[YamlMember(Alias = "condition")]
|
||||
public string Condition { get; set; } = string.Empty;
|
||||
|
||||
[YamlMember(Alias = "expected")]
|
||||
public bool Expected { get; set; }
|
||||
|
||||
[YamlMember(Alias = "context")]
|
||||
public EvalContextOverrides? Context { get; set; }
|
||||
}
|
||||
|
||||
private sealed class EvalContextOverrides
|
||||
{
|
||||
[YamlMember(Alias = "action")]
|
||||
public string? Action { get; set; }
|
||||
|
||||
[YamlMember(Alias = "suboperation")]
|
||||
public string? Suboperation { get; set; }
|
||||
|
||||
[YamlMember(Alias = "request_action")]
|
||||
public string? RequestAction { get; set; }
|
||||
|
||||
[YamlMember(Alias = "data_action")]
|
||||
public string? DataAction { get; set; }
|
||||
|
||||
[YamlMember(Alias = "principal_id")]
|
||||
public string? PrincipalId { get; set; }
|
||||
|
||||
[YamlMember(Alias = "principal_type")]
|
||||
public string? PrincipalType { get; set; }
|
||||
|
||||
[YamlMember(Alias = "resource_id")]
|
||||
public string? ResourceId { get; set; }
|
||||
|
||||
[YamlMember(Alias = "resource_type")]
|
||||
public string? ResourceType { get; set; }
|
||||
|
||||
[YamlMember(Alias = "resource_scope")]
|
||||
public string? ResourceScope { get; set; }
|
||||
|
||||
[YamlMember(Alias = "request_attributes")]
|
||||
public object? RequestAttributes { get; set; }
|
||||
|
||||
[YamlMember(Alias = "resource_attributes")]
|
||||
public object? ResourceAttributes { get; set; }
|
||||
|
||||
[YamlMember(Alias = "principal_custom_security_attributes")]
|
||||
public object? PrincipalCustomSecurityAttributes { get; set; }
|
||||
|
||||
[YamlMember(Alias = "environment")]
|
||||
public EvalEnvironmentOverrides? Environment { get; set; }
|
||||
}
|
||||
|
||||
private sealed class EvalEnvironmentOverrides
|
||||
{
|
||||
[YamlMember(Alias = "is_private_link")]
|
||||
public bool? IsPrivateLink { get; set; }
|
||||
|
||||
[YamlMember(Alias = "private_endpoint")]
|
||||
public string? PrivateEndpoint { get; set; }
|
||||
|
||||
[YamlMember(Alias = "subnet")]
|
||||
public string? Subnet { get; set; }
|
||||
|
||||
[YamlMember(Alias = "utc_now")]
|
||||
public string? UtcNow { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,14 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MSTest" />
|
||||
<PackageReference Include="YamlDotNet" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Regorus" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="../../../src/languages/azure_rbac/test_cases/*.yaml" Link="test_cases/%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -492,6 +492,16 @@ namespace Regorus.Internal
|
||||
|
||||
#endregion
|
||||
|
||||
#region RBAC Methods
|
||||
|
||||
/// <summary>
|
||||
/// Evaluate an Azure RBAC condition expression against a JSON evaluation context.
|
||||
/// </summary>
|
||||
[DllImport(LibraryName, EntryPoint = "regorus_rbac_engine_eval_condition", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)]
|
||||
internal static extern RegorusResult regorus_rbac_engine_eval_condition(byte* condition, byte* context_json);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Target Registry Methods
|
||||
|
||||
/// <summary>
|
||||
|
||||
45
bindings/csharp/Regorus/RbacEngine.cs
Normal file
45
bindings/csharp/Regorus/RbacEngine.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
using System;
|
||||
using Regorus.Internal;
|
||||
|
||||
#nullable enable
|
||||
namespace Regorus
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides helpers for evaluating Azure RBAC condition expressions.
|
||||
/// </summary>
|
||||
public static unsafe class RbacEngine
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluate an Azure RBAC condition expression against a JSON evaluation context.
|
||||
/// </summary>
|
||||
/// <param name="condition">Azure RBAC condition expression.</param>
|
||||
/// <param name="contextJson">JSON encoded EvaluationContext.</param>
|
||||
/// <returns>True if the condition evaluates to true; otherwise false.</returns>
|
||||
/// <exception cref="Exception">Thrown when evaluation fails.</exception>
|
||||
public static bool EvaluateCondition(string condition, string contextJson)
|
||||
{
|
||||
if (condition is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(condition));
|
||||
}
|
||||
|
||||
if (contextJson is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(contextJson));
|
||||
}
|
||||
|
||||
return Utf8Marshaller.WithUtf8(condition, conditionPtr =>
|
||||
Utf8Marshaller.WithUtf8(contextJson, contextPtr =>
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
var result = Internal.API.regorus_rbac_engine_eval_condition((byte*)conditionPtr, (byte*)contextPtr);
|
||||
return ResultHelpers.GetBoolResult(result);
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ default = [
|
||||
"coverage",
|
||||
"allocator-memory-limits",
|
||||
"rvm",
|
||||
"rbac",
|
||||
"regorus/arc",
|
||||
"regorus/full-opa",
|
||||
"contention_checks",
|
||||
@@ -43,6 +44,7 @@ coverage = ["regorus/coverage"]
|
||||
allocator-memory-limits = ["regorus/allocator-memory-limits"]
|
||||
contention_checks = ["parking_lot"]
|
||||
rvm = ["regorus/rvm"]
|
||||
rbac = ["regorus/azure-rbac"]
|
||||
custom_allocator = []
|
||||
|
||||
[build-dependencies]
|
||||
|
||||
@@ -14,6 +14,8 @@ mod engine;
|
||||
mod limits;
|
||||
mod lock;
|
||||
mod panic_guard;
|
||||
#[cfg(feature = "rbac")]
|
||||
mod rbac;
|
||||
#[cfg(feature = "rvm")]
|
||||
pub(crate) mod rvm;
|
||||
mod schema_registry;
|
||||
|
||||
59
bindings/ffi/src/rbac.rs
Normal file
59
bindings/ffi/src/rbac.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
use crate::common::{from_c_str, RegorusResult, RegorusStatus};
|
||||
use crate::panic_guard::with_unwind_guard;
|
||||
use alloc::format;
|
||||
use core::ffi::c_char;
|
||||
|
||||
use regorus::languages::azure_rbac::ast::EvaluationContext;
|
||||
use regorus::languages::azure_rbac::interpreter::ConditionInterpreter;
|
||||
|
||||
#[no_mangle]
|
||||
/// Evaluate an Azure RBAC condition expression against a JSON evaluation context.
|
||||
///
|
||||
/// * `condition`: RBAC condition string.
|
||||
/// * `context_json`: JSON representation of EvaluationContext.
|
||||
pub extern "C" fn regorus_rbac_engine_eval_condition(
|
||||
condition: *const c_char,
|
||||
context_json: *const c_char,
|
||||
) -> RegorusResult {
|
||||
with_unwind_guard(|| {
|
||||
let condition = match from_c_str(condition) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
return RegorusResult::err_with_message(
|
||||
RegorusStatus::InvalidArgument,
|
||||
format!("{err}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
let context_json = match from_c_str(context_json) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
return RegorusResult::err_with_message(
|
||||
RegorusStatus::InvalidArgument,
|
||||
format!("{err}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
let context: EvaluationContext = match serde_json::from_str(&context_json) {
|
||||
Ok(context) => context,
|
||||
Err(err) => {
|
||||
return RegorusResult::err_with_message(
|
||||
RegorusStatus::InvalidDataFormat,
|
||||
format!("invalid context json: {err}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let interpreter = ConditionInterpreter::new(&context);
|
||||
match interpreter.evaluate_str(&condition) {
|
||||
Ok(result) => RegorusResult::ok_bool(result),
|
||||
Err(err) => RegorusResult::err_with_message(
|
||||
RegorusStatus::Error,
|
||||
format!("condition evaluation failed: {err}"),
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user