fix(acl): filter Server-object (i=2253) event subscriptions per event through the data-plane ACL (#586)
Server-object (all-events) items bypassed the ACL entirely: the SDK attached them to every root notifier's fan-out via SubscribeToAllEvents, and a root notifier with no direct subscriber forwarded to the core Server object, which this node manager never sees. - SubscribeToAllEvents override records the item and attaches it to nothing. - Each condition's own OnReportEvent (fires once per event) queues the event to each recorded item whose session holds Subscribe on the condition (RawPath) or any notifier folder it reports through -- the per-folder path's question; otherwise mirrors MonitoredNode2.OnReportEvent. - The root-notifier forward to the core Server object (OnReportEvent override) is dropped. - ConditionRefresh on an i=2253 item is filtered the same way (once per condition). - Revalidation is implicit: every event re-asks the authorizer (per-generation cache, key expiry and revocation re-checked per call). Inert with Security:Acl:Enabled=false. Over-the-wire tests: no-grant vs granted (positive control), created-before-deploy, revoke/restore by deployment, key expiry with no tick, refresh, and flag-off. Falsified: disabling the filter, or restoring the forward, turns the enforcement tests red. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+347
-7
@@ -47,8 +47,9 @@ public sealed class AclEventSubscriptionEnforcementTests
|
||||
private const string KeyPrincipal = "expiry-probe";
|
||||
private const string KeyId = "k-event-expiry";
|
||||
|
||||
// [0]=EventId, [1]=Message — the message is each transition's discriminator.
|
||||
// [0]=EventId, [1]=Message — the message is each transition's discriminator; [2]=EventType.
|
||||
private const int MessageFieldIndex = 1;
|
||||
private const int EventTypeFieldIndex = 2;
|
||||
|
||||
private static readonly DateTime Start = new(2026, 10, 7, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
@@ -268,8 +269,326 @@ public sealed class AclEventSubscriptionEnforcementTests
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
// ─────────────────── Server-object (i=2253) subscriptions — Gitea #586 ───────────────────
|
||||
|
||||
/// <summary>
|
||||
/// A session holding no grant receives nothing through a Server-object event item, while a granted
|
||||
/// session's Server-object item receives the same transition.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The Server object carries every alarm in the server — each condition reports once and its one
|
||||
/// copy reaches every all-events item — so before #586 this was the one feed that ignored the ACL
|
||||
/// entirely. The granted item is the positive control: it proves the transition was fired and
|
||||
/// published before the denied item's silence is read as anything.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task A_server_object_subscription_receives_only_the_alarms_its_session_is_granted()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await Rig.StartAsync("AclServerEvents", ct);
|
||||
try
|
||||
{
|
||||
rig.Enforce([UnexpiringKey()], ClusterGrant(KeyPrincipal, "acl-key"));
|
||||
rig.Materialise();
|
||||
|
||||
using var granted = await rig.ConnectAsync(anonymous: false, ct);
|
||||
using var ungranted = await rig.ConnectAsync(anonymous: true, ct);
|
||||
|
||||
var grantedEvents = new EventCollector();
|
||||
var grantedSubscription = await CreateSubscriptionAsync(granted, grantedEvents, ct);
|
||||
var grantedItem = AddEventItem(grantedSubscription, ObjectIds.Server);
|
||||
await grantedSubscription.ApplyChangesAsync(ct);
|
||||
grantedItem.Status.Error.ShouldBeNull();
|
||||
|
||||
var ungrantedEvents = new EventCollector();
|
||||
var ungrantedSubscription = await CreateSubscriptionAsync(ungranted, ungrantedEvents, ct);
|
||||
var ungrantedItem = AddEventItem(ungrantedSubscription, ObjectIds.Server);
|
||||
await ungrantedSubscription.ApplyChangesAsync(ct);
|
||||
// Creation is not refused: an all-events item is legitimate for a session with partial grants,
|
||||
// so what is withheld is each event the session may not see, not the item.
|
||||
ungrantedItem.Status.Error.ShouldBeNull();
|
||||
|
||||
rig.Transition("t1", active: true);
|
||||
await WaitUntilAsync(() => grantedEvents.Has(grantedItem, "t1"));
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(700), ct);
|
||||
ungrantedEvents.Has(ungrantedItem, "t1").ShouldBeFalse(
|
||||
"a session with no grant must not receive alarms through the Server object");
|
||||
grantedEvents.Count(grantedItem, "t1").ShouldBe(1, "the condition still reaches the Server object exactly once");
|
||||
|
||||
await ungrantedSubscription.DeleteAsync(true, ct);
|
||||
await grantedSubscription.DeleteAsync(true, ct);
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A Server-object item created <b>before</b> the alarm is deployed is gated the same way, and still
|
||||
/// receives exactly one copy when granted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The ordering a long-lived consumer actually sees — it subscribes once, deployments come later. The
|
||||
/// SDK never attaches such an item to a root notifier registered after it (its <c>AddRootNotifier</c>
|
||||
/// subscribes existing all-events items with <c>unsubscribe: true</c>), so before #586 these items were
|
||||
/// served only by the root notifier's forward to the core Server object — a path this manager never
|
||||
/// saw, and the one now dropped. This proves the replacement delivery covers it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task A_server_object_subscription_created_before_the_deployment_is_gated_and_served()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await Rig.StartAsync("AclServerEventsEarly", ct);
|
||||
try
|
||||
{
|
||||
rig.Enforce([UnexpiringKey()], ClusterGrant(KeyPrincipal, "acl-key"));
|
||||
|
||||
using var granted = await rig.ConnectAsync(anonymous: false, ct);
|
||||
using var ungranted = await rig.ConnectAsync(anonymous: true, ct);
|
||||
|
||||
var grantedEvents = new EventCollector();
|
||||
var grantedSubscription = await CreateSubscriptionAsync(granted, grantedEvents, ct);
|
||||
var grantedItem = AddEventItem(grantedSubscription, ObjectIds.Server);
|
||||
await grantedSubscription.ApplyChangesAsync(ct);
|
||||
|
||||
var ungrantedEvents = new EventCollector();
|
||||
var ungrantedSubscription = await CreateSubscriptionAsync(ungranted, ungrantedEvents, ct);
|
||||
var ungrantedItem = AddEventItem(ungrantedSubscription, ObjectIds.Server);
|
||||
await ungrantedSubscription.ApplyChangesAsync(ct);
|
||||
|
||||
rig.Materialise();
|
||||
|
||||
rig.Transition("t1", active: true);
|
||||
await WaitUntilAsync(() => grantedEvents.Has(grantedItem, "t1"));
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(700), ct);
|
||||
ungrantedEvents.Has(ungrantedItem, "t1").ShouldBeFalse();
|
||||
grantedEvents.Count(grantedItem, "t1").ShouldBe(1);
|
||||
|
||||
await ungrantedSubscription.DeleteAsync(true, ct);
|
||||
await grantedSubscription.DeleteAsync(true, ct);
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The deployment axis on a Server-object item: an equipment grant admits the alarm, a deployment that
|
||||
/// revokes it stops the feed, and one that restores it resumes the feed with no client action.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The grant is an <b>Equipment</b> grant on a UNS folder the condition is wired to, not a grant on the
|
||||
/// raw condition itself — the decision has to match the per-folder path, where that grant admits the
|
||||
/// same alarm through the equipment's notifier. A cluster-granted key session is the positive control.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task Revoking_then_restoring_a_grant_stops_then_resumes_a_server_object_alarm_feed()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await Rig.StartAsync("AclServerEventsRevoke", ct);
|
||||
try
|
||||
{
|
||||
var key = UnexpiringKey();
|
||||
var state = rig.Enforce([key], EquipmentGrant(EqA), ClusterGrant(KeyPrincipal, "acl-key"));
|
||||
rig.Materialise();
|
||||
|
||||
using var control = await rig.ConnectAsync(anonymous: false, ct);
|
||||
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
||||
|
||||
var controlEvents = new EventCollector();
|
||||
var controlSubscription = await CreateSubscriptionAsync(control, controlEvents, ct);
|
||||
var controlItem = AddEventItem(controlSubscription, ObjectIds.Server);
|
||||
await controlSubscription.ApplyChangesAsync(ct);
|
||||
|
||||
var events = new EventCollector();
|
||||
var subscription = await CreateSubscriptionAsync(session, events, ct);
|
||||
var item = AddEventItem(subscription, ObjectIds.Server);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
|
||||
rig.Transition("t1", active: true);
|
||||
await WaitUntilAsync(() => events.Has(item, "t1") && controlEvents.Has(controlItem, "t1"));
|
||||
|
||||
state.Apply(Cluster, "rev-2", [key], [ClusterGrant(KeyPrincipal, "acl-key")], ScopePaths());
|
||||
|
||||
rig.Transition("t2", active: false);
|
||||
await WaitUntilAsync(() => controlEvents.Has(controlItem, "t2"));
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(700), ct);
|
||||
events.Has(item, "t2").ShouldBeFalse("the revoked session must not receive alarms after the deployment");
|
||||
|
||||
state.Apply(Cluster, "rev-3", [key], [EquipmentGrant(EqA), ClusterGrant(KeyPrincipal, "acl-key")], ScopePaths());
|
||||
|
||||
rig.Transition("t3", active: true);
|
||||
await WaitUntilAsync(() => events.Has(item, "t3") && controlEvents.Has(controlItem, "t3"));
|
||||
events.Has(item, "t2").ShouldBeFalse("nothing withheld while revoked is replayed on restore");
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
await controlSubscription.DeleteAsync(true, ct);
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The clock axis on a Server-object item: once the session's key expires the alarm feed stops on the
|
||||
/// very next event — with no deployment and no revalidation tick — while an independent session keeps
|
||||
/// receiving the same event.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Server-object events are decided per event at delivery, and the authorizer re-checks key expiry on
|
||||
/// every call, so revalidation is implicit rather than driven by either trigger. Deliberately no
|
||||
/// <c>TickRevalidation()</c> here: a build that only stood items down on the tick would pass a test
|
||||
/// that ticked.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task An_expired_key_stops_a_server_object_alarm_feed_on_the_next_event()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await Rig.StartAsync("AclServerEventsExpiry", ct);
|
||||
try
|
||||
{
|
||||
var key = UnexpiringKey(expiresUtc: Start.AddMinutes(10));
|
||||
var state = rig.Enforce(
|
||||
[key], ClusterGrant(KeyPrincipal, "acl-key"), ClusterGrant(OpcUaPrincipals.Anonymous, "acl-anon"));
|
||||
rig.Materialise();
|
||||
|
||||
using var keySession = await rig.ConnectAsync(anonymous: false, ct);
|
||||
using var controlSession = await rig.ConnectAsync(anonymous: true, ct);
|
||||
|
||||
var keyEvents = new EventCollector();
|
||||
var keySubscription = await CreateSubscriptionAsync(keySession, keyEvents, ct);
|
||||
var keyItem = AddEventItem(keySubscription, ObjectIds.Server);
|
||||
await keySubscription.ApplyChangesAsync(ct);
|
||||
|
||||
var controlEvents = new EventCollector();
|
||||
var controlSubscription = await CreateSubscriptionAsync(controlSession, controlEvents, ct);
|
||||
var controlItem = AddEventItem(controlSubscription, ObjectIds.Server);
|
||||
await controlSubscription.ApplyChangesAsync(ct);
|
||||
|
||||
rig.Transition("t1", active: true);
|
||||
await WaitUntilAsync(() => keyEvents.Has(keyItem, "t1") && controlEvents.Has(controlItem, "t1"));
|
||||
|
||||
rig.Advance(TimeSpan.FromMinutes(20));
|
||||
|
||||
rig.Transition("t2", active: false);
|
||||
await WaitUntilAsync(() => controlEvents.Has(controlItem, "t2"));
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(700), ct);
|
||||
keyEvents.Has(keyItem, "t2").ShouldBeFalse("the expired key's Server-object feed must stop");
|
||||
state.Current.GenerationId.ShouldBe(1);
|
||||
|
||||
await controlSubscription.DeleteAsync(true, ct);
|
||||
await keySubscription.DeleteAsync(true, ct);
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// With <c>Security:Acl:Enabled</c> false the Server object stays ungated: a session with no grant
|
||||
/// receives the alarm exactly as a granted one does.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Without this the tests above also pass on a build that withholds every Server-object event from
|
||||
/// every session — the flag-off posture leaves reads and subscriptions ungated, and the alarm consumers
|
||||
/// of every fleet that has not opted in depend on it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task With_acl_enforcement_off_a_server_object_subscription_receives_every_alarm()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await Rig.StartAsync("AclServerEventsOff", ct);
|
||||
try
|
||||
{
|
||||
rig.Enforce(enabled: false, [UnexpiringKey()], ClusterGrant(KeyPrincipal, "acl-key"));
|
||||
rig.Materialise();
|
||||
|
||||
using var granted = await rig.ConnectAsync(anonymous: false, ct);
|
||||
using var ungranted = await rig.ConnectAsync(anonymous: true, ct);
|
||||
|
||||
var grantedEvents = new EventCollector();
|
||||
var grantedSubscription = await CreateSubscriptionAsync(granted, grantedEvents, ct);
|
||||
var grantedItem = AddEventItem(grantedSubscription, ObjectIds.Server);
|
||||
await grantedSubscription.ApplyChangesAsync(ct);
|
||||
|
||||
var ungrantedEvents = new EventCollector();
|
||||
var ungrantedSubscription = await CreateSubscriptionAsync(ungranted, ungrantedEvents, ct);
|
||||
var ungrantedItem = AddEventItem(ungrantedSubscription, ObjectIds.Server);
|
||||
await ungrantedSubscription.ApplyChangesAsync(ct);
|
||||
|
||||
rig.Transition("t1", active: true);
|
||||
await WaitUntilAsync(() => grantedEvents.Has(grantedItem, "t1") && ungrantedEvents.Has(ungrantedItem, "t1"));
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(300), ct);
|
||||
ungrantedEvents.Count(ungrantedItem, "t1").ShouldBe(1);
|
||||
|
||||
await ungrantedSubscription.DeleteAsync(true, ct);
|
||||
await grantedSubscription.DeleteAsync(true, ct);
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A <c>ConditionRefresh</c> on a Server-object subscription replays only the conditions the session is
|
||||
/// granted: the granted session gets the current state, the ungranted one gets the refresh brackets
|
||||
/// and nothing between them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Refresh is the other way a Server-object item learns about a condition — it is how a client
|
||||
/// connecting after a transition gets the current alarm picture — and it walks every condition in the
|
||||
/// server. The ungranted session's <c>RefreshEndEvent</c> is the positive control on its own item: it
|
||||
/// proves the refresh ran for that subscription and completed, so the missing condition is withheld,
|
||||
/// not late.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task A_condition_refresh_on_the_server_object_replays_only_granted_conditions()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await Rig.StartAsync("AclServerRefresh", ct);
|
||||
try
|
||||
{
|
||||
rig.Enforce([UnexpiringKey()], ClusterGrant(KeyPrincipal, "acl-key"));
|
||||
rig.Materialise();
|
||||
// The transition precedes every subscription, so only a refresh can deliver it.
|
||||
rig.Transition("before", active: true);
|
||||
|
||||
using var granted = await rig.ConnectAsync(anonymous: false, ct);
|
||||
using var ungranted = await rig.ConnectAsync(anonymous: true, ct);
|
||||
|
||||
var grantedEvents = new EventCollector();
|
||||
var grantedSubscription = await CreateSubscriptionAsync(granted, grantedEvents, ct);
|
||||
var grantedItem = AddEventItem(grantedSubscription, ObjectIds.Server);
|
||||
await grantedSubscription.ApplyChangesAsync(ct);
|
||||
|
||||
var ungrantedEvents = new EventCollector();
|
||||
var ungrantedSubscription = await CreateSubscriptionAsync(ungranted, ungrantedEvents, ct);
|
||||
var ungrantedItem = AddEventItem(ungrantedSubscription, ObjectIds.Server);
|
||||
await ungrantedSubscription.ApplyChangesAsync(ct);
|
||||
|
||||
await RefreshAsync(granted, grantedSubscription, ct);
|
||||
await RefreshAsync(ungranted, ungrantedSubscription, ct);
|
||||
|
||||
await WaitUntilAsync(() =>
|
||||
grantedEvents.Has(grantedItem, "before")
|
||||
&& ungrantedEvents.HasType(ungrantedItem, ObjectTypeIds.RefreshEndEventType));
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(300), ct);
|
||||
ungrantedEvents.Has(ungrantedItem, "before").ShouldBeFalse(
|
||||
"a refresh must not replay a condition the session is not granted");
|
||||
|
||||
await ungrantedSubscription.DeleteAsync(true, ct);
|
||||
await grantedSubscription.DeleteAsync(true, ct);
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
// ───────────────────────────── helpers ─────────────────────────────
|
||||
|
||||
private static ApiKey UnexpiringKey(DateTime? expiresUtc = null) => new()
|
||||
{
|
||||
ApiKeyId = KeyId,
|
||||
Name = KeyId,
|
||||
SecretHash = "hash",
|
||||
Principal = KeyPrincipal,
|
||||
Enabled = true,
|
||||
CreatedUtc = Start.AddDays(-1),
|
||||
ExpiresUtc = expiresUtc,
|
||||
};
|
||||
|
||||
private static async Task RefreshAsync(ISession session, Subscription subscription, CancellationToken ct) =>
|
||||
await session.CallAsync(
|
||||
ObjectTypeIds.ConditionType, MethodIds.ConditionType_ConditionRefresh, ct, subscription.Id);
|
||||
|
||||
private static NodeAcl EquipmentGrant(string equipment) => new()
|
||||
{
|
||||
NodeAclId = $"acl-{equipment}",
|
||||
@@ -311,6 +630,7 @@ public sealed class AclEventSubscriptionEnforcementTests
|
||||
var filter = new EventFilter();
|
||||
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.EventId);
|
||||
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message);
|
||||
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.EventType);
|
||||
|
||||
var item = new MonitoredItem(subscription.DefaultItem)
|
||||
{
|
||||
@@ -335,11 +655,12 @@ public sealed class AclEventSubscriptionEnforcementTests
|
||||
condition().ShouldBeTrue("condition not met within timeout");
|
||||
}
|
||||
|
||||
/// <summary>Records which transition messages reached which monitored item.</summary>
|
||||
/// <summary>Records which transition messages, and which event types, reached which monitored item.</summary>
|
||||
private sealed class EventCollector
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly HashSet<(uint Handle, string Message)> _seen = [];
|
||||
private readonly List<(uint Handle, string Message)> _seen = [];
|
||||
private readonly HashSet<(uint Handle, NodeId Type)> _types = [];
|
||||
|
||||
public void OnEvents(Subscription subscription, EventNotificationList notification, IList<string> stringTable)
|
||||
{
|
||||
@@ -352,13 +673,26 @@ public sealed class AclEventSubscriptionEnforcementTests
|
||||
{
|
||||
_seen.Add((e.ClientHandle, text));
|
||||
}
|
||||
|
||||
if (e.EventFields.Count > EventTypeFieldIndex
|
||||
&& e.EventFields[EventTypeFieldIndex].Value is NodeId type)
|
||||
{
|
||||
_types.Add((e.ClientHandle, type));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool Has(MonitoredItem item, string message)
|
||||
public bool Has(MonitoredItem item, string message) => Count(item, message) > 0;
|
||||
|
||||
public int Count(MonitoredItem item, string message)
|
||||
{
|
||||
lock (_gate) return _seen.Contains((item.ClientHandle, message));
|
||||
lock (_gate) return _seen.Count(s => s.Handle == item.ClientHandle && s.Message == message);
|
||||
}
|
||||
|
||||
public bool HasType(MonitoredItem item, NodeId type)
|
||||
{
|
||||
lock (_gate) return _types.Contains((item.ClientHandle, type));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,14 +742,20 @@ public sealed class AclEventSubscriptionEnforcementTests
|
||||
/// Switches enforcement on, wiring the authorizer, the authority source (the deployment trigger)
|
||||
/// and the host's revalidation timer (the clock trigger) over one hand-advanced clock.
|
||||
/// </summary>
|
||||
public DataPlaneAuthorityState Enforce(ApiKey[] keys, params NodeAcl[] grants)
|
||||
public DataPlaneAuthorityState Enforce(ApiKey[] keys, params NodeAcl[] grants) => Enforce(true, keys, grants);
|
||||
|
||||
/// <summary>
|
||||
/// As <see cref="Enforce(ApiKey[], NodeAcl[])"/>, with <paramref name="enabled"/> standing in for
|
||||
/// <c>Security:Acl:Enabled</c> — false wires the same grants with enforcement switched off.
|
||||
/// </summary>
|
||||
public DataPlaneAuthorityState Enforce(bool enabled, ApiKey[] keys, params NodeAcl[] grants)
|
||||
{
|
||||
var state = new DataPlaneAuthorityState();
|
||||
state.Apply(Cluster, "rev-1", keys, grants, ScopePaths());
|
||||
|
||||
Nm.DataPlaneAuthority = state;
|
||||
Nm.Authorizer = new DataPlaneAuthorizer(
|
||||
state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance, _clock);
|
||||
state, new TriePermissionEvaluator(state.Tries), enabled, NullLogger.Instance, _clock);
|
||||
|
||||
// A long interval: the tick is driven by hand through the same component the host arms.
|
||||
_timer = new AclRevalidationTimer(
|
||||
|
||||
Reference in New Issue
Block a user