From 3a1b95dbc54b139bef37a1bc1dc96d4e84ead5bc Mon Sep 17 00:00:00 2001 From: ahmed Date: Wed, 15 Jul 2026 02:42:16 +0300 Subject: [PATCH] Managers --- Libraries/Esiur/Core/InvocationContext.cs | 9 + Libraries/Esiur/Data/Types/EventDef.cs | 2 + Libraries/Esiur/Data/Types/FunctionDef.cs | 2 + Libraries/Esiur/Data/Types/MemberDef.cs | 13 + Libraries/Esiur/Data/Types/PropertyDef.cs | 7 +- .../Esiur/Protocol/EpConnectionProtocol.cs | 505 +++++++++++++----- .../Attributes/AuditingManagerAttribute.cs | 5 +- .../Attributes/PermissionsManagerAttribute.cs | 5 +- .../Attributes/RateControlManagerAttribute.cs | 5 +- .../Attributes/ResourceManagerAttribute.cs | 24 + .../Resource/IResourceManagersContext.cs | 14 + Libraries/Esiur/Resource/Instance.cs | 71 ++- Libraries/Esiur/Resource/ResourceContext.cs | 34 +- Libraries/Esiur/Resource/Warehouse.cs | 378 ++++++++++++- .../Management/IRateControlManager.cs | 3 +- .../Management/ResourceManagerContext.cs | 28 +- .../Management/ResourceManagerEvaluation.cs | 43 ++ .../Esiur/Security/Permissions/ActionType.cs | 9 +- .../Permissions/StorePermissionsManager.cs | 25 +- .../RateLimiting/NamedRateControlManager.cs | 53 ++ .../EsiurExtensions.cs | 8 +- .../EsiurProxyRewrite.cs | 8 +- Tests/Features/Functional/ManagerScenarios.cs | 365 +++++++++++++ Tests/Features/Functional/Program.cs | 56 +- Tests/Features/Functional/README.md | 2 + .../RateControlIntegrationTests.cs | 5 +- Tests/Unit/ResourceManagerPipelineTests.cs | 285 ++++++++++ 27 files changed, 1767 insertions(+), 197 deletions(-) create mode 100644 Libraries/Esiur/Resource/Attributes/ResourceManagerAttribute.cs create mode 100644 Libraries/Esiur/Resource/IResourceManagersContext.cs create mode 100644 Libraries/Esiur/Security/Management/ResourceManagerEvaluation.cs create mode 100644 Libraries/Esiur/Security/RateLimiting/NamedRateControlManager.cs create mode 100644 Tests/Features/Functional/ManagerScenarios.cs create mode 100644 Tests/Unit/ResourceManagerPipelineTests.cs diff --git a/Libraries/Esiur/Core/InvocationContext.cs b/Libraries/Esiur/Core/InvocationContext.cs index 73dbe73..0a2df09 100644 --- a/Libraries/Esiur/Core/InvocationContext.cs +++ b/Libraries/Esiur/Core/InvocationContext.cs @@ -1,5 +1,6 @@ using Esiur.Data.Types; using Esiur.Protocol; +using Esiur.Resource; using System; using System.Collections; using System.Collections.Generic; @@ -23,6 +24,8 @@ namespace Esiur.Core internal StreamMode StreamMode { get; private set; } internal bool Pausable { get; private set; } + internal IResource Resource { get; private set; } + internal FunctionDef Function { get; private set; } internal volatile bool Ended; @@ -75,6 +78,12 @@ namespace Esiur.Core CallbackId = callbackId; } + internal void BindOperation(IResource resource, FunctionDef function) + { + Resource = resource; + Function = function; + } + internal void InitializeStream(StreamMode streamMode, bool pausable) { StreamMode = streamMode; diff --git a/Libraries/Esiur/Data/Types/EventDef.cs b/Libraries/Esiur/Data/Types/EventDef.cs index 537c56f..df4ee01 100644 --- a/Libraries/Esiur/Data/Types/EventDef.cs +++ b/Libraries/Esiur/Data/Types/EventDef.cs @@ -209,12 +209,14 @@ public class EventDef : MemberDef return DefinitionAttributeReader.Apply(ei, new EventDef() { + Definition = schema, Name = name, ArgumentType = evtType, Index = index, Inherited = ei.DeclaringType != type, Annotations = annotations, EventInfo = ei, + MemberPolicyAttributes = Attribute.GetCustomAttributes(ei, true), AutoDelivered = autoDeliveryAttr != null, Historical = historicalAttr != null, OrderingControl = orderingAttr?.Control ?? OrderingControl.Strict, diff --git a/Libraries/Esiur/Data/Types/FunctionDef.cs b/Libraries/Esiur/Data/Types/FunctionDef.cs index b3f8de3..52e1b53 100644 --- a/Libraries/Esiur/Data/Types/FunctionDef.cs +++ b/Libraries/Esiur/Data/Types/FunctionDef.cs @@ -367,6 +367,7 @@ public class FunctionDef : MemberDef return DefinitionAttributeReader.Apply(mi, new FunctionDef() { + Definition = schema, Name = name, Index = index, Inherited = mi.DeclaringType != type, @@ -379,6 +380,7 @@ public class FunctionDef : MemberDef Idempotent = mi.GetCustomAttribute(true) != null, Cancellable = mi.GetCustomAttribute(true) != null, RatePolicyName = mi.GetCustomAttribute(true)?.PolicyName, + MemberPolicyAttributes = Attribute.GetCustomAttributes(mi, true), StreamMode = streamMode, Pausable = pausable, }); diff --git a/Libraries/Esiur/Data/Types/MemberDef.cs b/Libraries/Esiur/Data/Types/MemberDef.cs index 8bdcb7a..af9a648 100644 --- a/Libraries/Esiur/Data/Types/MemberDef.cs +++ b/Libraries/Esiur/Data/Types/MemberDef.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; namespace Esiur.Data.Types; public class MemberDef { + IReadOnlyList memberPolicyAttributes = Array.Empty(); // Core fields public byte Index { get; set; } @@ -24,6 +25,18 @@ public class MemberDef /// public string? RatePolicyName { get; set; } + /// + /// Local reflection attributes supplied to resource managers while this member + /// is evaluated. This execution metadata is not serialized to remote peers. + /// + public IReadOnlyList MemberPolicyAttributes + { + get => memberPolicyAttributes; + internal set => memberPolicyAttributes = value is null + ? Array.Empty() + : Array.AsReadOnly(value.ToArray()); + } + public TypeDef Definition { get; set; } = null!; // Human-readable metadata diff --git a/Libraries/Esiur/Data/Types/PropertyDef.cs b/Libraries/Esiur/Data/Types/PropertyDef.cs index 4512396..c291de0 100644 --- a/Libraries/Esiur/Data/Types/PropertyDef.cs +++ b/Libraries/Esiur/Data/Types/PropertyDef.cs @@ -312,12 +312,17 @@ public class PropertyDef : MemberDef return DefinitionAttributeReader.Apply(pi, new PropertyDef() { + Definition = typeDef, Name = name, Index = index, Inherited = pi.DeclaringType != type, ValueType = propType, PropertyInfo = pi, - RatePolicyName = pi.GetCustomAttribute(true)?.PolicyName, + RatePolicyName = (Attribute.GetCustomAttribute( + pi, + typeof(RateControlAttribute), + true) as RateControlAttribute)?.PolicyName, + MemberPolicyAttributes = Attribute.GetCustomAttributes(pi, true), ReadOnly = readOnlyAttr != null, Volatile = volatileAttr != null, Historical = historicalAttr != null, diff --git a/Libraries/Esiur/Protocol/EpConnectionProtocol.cs b/Libraries/Esiur/Protocol/EpConnectionProtocol.cs index f760ede..6309e9c 100644 --- a/Libraries/Esiur/Protocol/EpConnectionProtocol.cs +++ b/Libraries/Esiur/Protocol/EpConnectionProtocol.cs @@ -30,6 +30,7 @@ using Esiur.Net; using Esiur.Net.Packets; using Esiur.Resource; using Esiur.Security.Authority; +using Esiur.Security.Management; using Esiur.Security.Permissions; using Esiur.Security.RateLimiting; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -55,63 +56,110 @@ partial class EpConnection internal bool IsRateControlBlocked => Volatile.Read(ref _rateControlBlocked) != 0; - bool TryApplyRateControl( + bool TryApplyManagers( MemberDef member, IResource? resource, ActionType action, uint callback, - out TimeSpan delay) + ErrorType denialErrorType, + ExceptionCode denialCode, + out TimeSpan delay, + IEnumerable managers = null, + bool supportsDelay = false) { delay = TimeSpan.Zero; - if (string.IsNullOrWhiteSpace(member.RatePolicyName)) - return true; - if (IsRateControlBlocked) - return false; - - var warehouse = Instance.Warehouse; - var policy = warehouse.TryGetRatePolicy(member.RatePolicyName); - - if (policy == null) { - DenyRateControlledRequest( + SendError( + ErrorType.Management, callback, - $"Rate policy `{member.RatePolicyName}` is not registered."); + (ushort)ExceptionCode.RateLimitExceeded, + "The connection is blocked by rate control."); return false; } + var warehouse = Instance.Warehouse; + try { - var context = new RateControlContext( + if (resource == null && member?.Definition is LocalTypeDef localTypeDef) + managers ??= warehouse.ResolveResourceManagers(localTypeDef.DefinedType, null); + else if (resource == null && member is FunctionDef functionDef) + managers ??= warehouse.ResolveResourceManagers(functionDef.MethodInfo?.DeclaringType, null); + + var context = new ResourceManagerContext( warehouse, this, _session, resource, member, - action); + action, + this, + member?.MemberPolicyAttributes); - if (policy.Applicable(context) == Ruling.Denied) + var evaluation = warehouse.EvaluateManagers(context, managers); + delay = evaluation.Delay; + + if (evaluation.Permissions == Ruling.Denied || + evaluation.Auditing == Ruling.Denied) { - DenyRateControlledRequest( - callback, - $"Rate policy `{member.RatePolicyName}` denied `{member.Fullname}`."); + if (evaluation.RateControl == Ruling.Denied) + DenyRateControlledRequest( + callback, + evaluation.RateControlDenialReason ?? "Rate control denied the operation.", + false); + + SendError(denialErrorType, callback, (ushort)denialCode); return false; } - delay = context.Delay > TimeSpan.Zero ? context.Delay : TimeSpan.Zero; - return true; + if (evaluation.RateControl == Ruling.Denied) + { + DenyRateControlledRequest( + callback, + evaluation.RateControlDenialReason ?? "Rate control denied the operation."); + return false; + } + + if (delay > TimeSpan.Zero && !supportsDelay) + { + DenyRateControlledRequest( + callback, + $"Delayed `{action}` operations are not supported."); + return false; + } + + return evaluation.IsAllowed; } catch (Exception exception) { - DenyRateControlledRequest( - callback, - $"Rate policy `{member.RatePolicyName}` failed: {exception.Message}"); + Global.Log(exception); + SendError(denialErrorType, callback, (ushort)denialCode); return false; } } - void DenyRateControlledRequest(uint callback, string message) + bool IsOperationAllowed( + IResource resource, + MemberDef member, + ActionType action, + object inquirer = null) + { + var context = new ResourceManagerContext( + Instance.Warehouse, + this, + _session, + resource, + member, + action, + inquirer ?? this, + member?.MemberPolicyAttributes); + + return Instance.Warehouse.EvaluateManagers(context).IsAllowed; + } + + void DenyRateControlledRequest(uint callback, string message, bool sendError = true) { var configuration = Instance.Warehouse.Configuration.RateControl; var now = DateTime.UtcNow; @@ -136,11 +184,12 @@ partial class EpConnection Interlocked.Exchange(ref _rateControlBlocked, 1) == 0; } - SendError( - ErrorType.Management, - callback, - (ushort)ExceptionCode.RateLimitExceeded, - message); + if (sendError) + SendError( + ErrorType.Management, + callback, + (ushort)ExceptionCode.RateLimitExceeded, + message); if (shouldBlock) _ = CloseRateControlledConnectionAsync(configuration.ConnectionBlockDelay); @@ -944,11 +993,15 @@ partial class EpConnection { if (res != null) { - if (res.Instance.Applicable(_session, ActionType.Attach, null) == Ruling.Denied) - { - SendError(ErrorType.Management, callback, 6); + if (!TryApplyManagers( + null, + res, + ActionType.Attach, + callback, + ErrorType.Management, + ExceptionCode.AttachDenied, + out _)) return; - } var r = res as IResource; @@ -1007,11 +1060,15 @@ partial class EpConnection { if (res != null) { - if (res.Instance.Applicable(_session, ActionType.Attach, null) == Ruling.Denied) - { - SendError(ErrorType.Management, callback, 6); + if (!TryApplyManagers( + null, + res, + ActionType.Attach, + callback, + ErrorType.Management, + ExceptionCode.AttachDenied, + out _)) return; - } var r = res as IResource; @@ -1059,6 +1116,15 @@ partial class EpConnection { if (res != null) { + if (!TryApplyManagers( + null, + res, + ActionType.Detach, + callback, + ErrorType.Management, + ExceptionCode.NotAllowed, + out _)) + return; // unsubscribe Unsubscribe(res); @@ -1121,11 +1187,15 @@ partial class EpConnection var store = r.Instance.Store; // check security - if (store.Instance.Applicable(_session, ActionType.CreateResource, null) != Ruling.Allowed) - { - SendError(ErrorType.Management, callback, (ushort)ExceptionCode.CreateDenied); + if (!TryApplyManagers( + null, + store, + ActionType.CreateResource, + callback, + ErrorType.Management, + ExceptionCode.CreateDenied, + out _)) return; - } Instance.Warehouse.New(localTypeDef.DefinedType, path, new ResourceContext(0, @@ -1166,11 +1236,20 @@ partial class EpConnection return; } - if (r.Instance.Store.Instance.Applicable(_session, ActionType.Delete, null) != Ruling.Allowed) - { - SendError(ErrorType.Management, callback, (ushort)ExceptionCode.DeleteDenied); + var deleteManagers = r.Instance.Managers + .ToArray() + .Concat(r.Instance.Store.Instance.Managers.ToArray()); + + if (!TryApplyManagers( + null, + r, + ActionType.Delete, + callback, + ErrorType.Management, + ExceptionCode.DeleteDenied, + out _, + deleteManagers)) return; - } if (Instance.Warehouse.Remove(r)) SendReply(EpPacketReply.Completed, callback); @@ -1205,11 +1284,15 @@ partial class EpConnection return; } - if (resource.Instance.Applicable(this._session, ActionType.Rename, null) != Ruling.Allowed) - { - SendError(ErrorType.Management, callback, (ushort)ExceptionCode.RenameDenied); + if (!TryApplyManagers( + null, + resource, + ActionType.Rename, + callback, + ErrorType.Management, + ExceptionCode.RenameDenied, + out _)) return; - } resource.Instance.Name = name; @@ -1240,11 +1323,15 @@ partial class EpConnection return; } - if (r.Instance.Applicable(_session, ActionType.ViewTypeDef, null) == Ruling.Denied) - { - SendError(ErrorType.Management, callback, (ushort)ExceptionCode.NotAllowed); + if (!TryApplyManagers( + null, + r, + ActionType.ViewTypeDef, + callback, + ErrorType.Management, + ExceptionCode.NotAllowed, + out _)) return; - } // make sure the resource is a local type def. if (r.Instance.Definition is LocalTypeDef localTypeDef) @@ -1272,19 +1359,33 @@ partial class EpConnection var classNames = (string[])value; - var typeDefs = new List(); + var typeDefs = new List(); foreach (var className in classNames) { //@TODO: need to search in remoteTypeDefs as well var typeDef = Instance.Warehouse.GetLocalTypeDefByName(className); if (typeDef != null) - typeDefs.Add(typeDef.Id); + typeDefs.Add(typeDef); } if (typeDefs.Count > 0) { - SendReply(EpPacketReply.Completed, callback, typeDefs.ToArray()); + var managers = typeDefs + .SelectMany(typeDef => Instance.Warehouse.ResolveResourceManagers(typeDef.DefinedType, null)); + + if (!TryApplyManagers( + null, + null, + ActionType.ViewTypeDef, + callback, + ErrorType.Management, + ExceptionCode.NotAllowed, + out _, + managers)) + return; + + SendReply(EpPacketReply.Completed, callback, typeDefs.Select(typeDef => typeDef.Id).ToArray()); } else { @@ -1304,6 +1405,18 @@ partial class EpConnection if (t != null) { + var managers = Instance.Warehouse.ResolveResourceManagers(t.DefinedType, null); + if (!TryApplyManagers( + null, + null, + ActionType.ViewTypeDef, + callback, + ErrorType.Management, + ExceptionCode.NotAllowed, + out _, + managers)) + return; + SendReply(EpPacketReply.Completed, callback, t.Compose(this)); } else @@ -1326,6 +1439,16 @@ partial class EpConnection { if (r != null) { + if (!TryApplyManagers( + null, + r, + ActionType.ViewTypeDef, + callback, + ErrorType.Management, + ExceptionCode.NotAllowed, + out _)) + return; + SendReply(EpPacketReply.Completed, callback, r.Instance.Definition.Compose(this)); } else @@ -1349,11 +1472,15 @@ partial class EpConnection SendError(ErrorType.Management, callback, (ushort)ExceptionCode.ResourceNotFound); else { - if (r.Instance.Applicable(_session, ActionType.Attach, null) == Ruling.Denied) - { - SendError(ErrorType.Management, callback, (ushort)ExceptionCode.ResourceNotFound); + if (!TryApplyManagers( + null, + r, + ActionType.Attach, + callback, + ErrorType.Management, + ExceptionCode.ResourceNotFound, + out _)) return; - } SendReply(EpPacketReply.Completed, callback, r); } @@ -1378,15 +1505,21 @@ partial class EpConnection SendError(ErrorType.Management, callback, (ushort)ExceptionCode.ResourceNotFound); else { - if (r.Instance.Applicable(_session, ActionType.Attach, null) == Ruling.Denied) - { - SendError(ErrorType.Management, callback, (ushort)ExceptionCode.NotAllowed); + if (!TryApplyManagers( + null, + r, + ActionType.Attach, + callback, + ErrorType.Management, + ExceptionCode.NotAllowed, + out _)) return; - } r.Instance.Children().Then(children => { - var list = children.Where(x => x.Instance.Applicable(_session, ActionType.Attach, null) != Ruling.Denied).ToArray(); + var list = children + .Where(x => IsOperationAllowed(x, null, ActionType.Attach)) + .ToArray(); SendReply(EpPacketReply.Completed, callback, list); }).Error(e => { @@ -1445,6 +1578,17 @@ partial class EpConnection return; } + if (!TryApplyManagers( + call.Value.Definition, + call.Value.Delegate.Target as IResource, + ActionType.Execute, + callback, + ErrorType.Management, + ExceptionCode.InvokeDenied, + out var managerDelay, + supportsDelay: true)) + return; + Codec.ParseAsync(tdu.Data, offset, this, null).Then(pr => { if (pr.Value is AsyncReply reply) @@ -1464,7 +1608,7 @@ partial class EpConnection // return; //} - InvokeFunction(call.Value.Definition, callback, results, EpPacketRequest.ProcedureCall, call.Value.Delegate.Target); + InvokeFunction(call.Value.Definition, callback, results, EpPacketRequest.ProcedureCall, managerDelay, call.Value.Delegate.Target); }).Error(x => { @@ -1479,7 +1623,7 @@ partial class EpConnection this.Socket.Unhold(); // @TODO: Make managers for procedure calls - InvokeFunction(call.Value.Definition, callback, pr.Value, EpPacketRequest.ProcedureCall, call.Value.Delegate.Target); + InvokeFunction(call.Value.Definition, callback, pr.Value, EpPacketRequest.ProcedureCall, managerDelay, call.Value.Delegate.Target); } }).Error(x => SendError(ErrorType.Management, callback, (ushort)ExceptionCode.ParseError)); } @@ -1518,6 +1662,18 @@ partial class EpConnection return; } + if (!TryApplyManagers( + fd, + null, + ActionType.Execute, + callback, + ErrorType.Management, + ExceptionCode.InvokeDenied, + out var managerDelay, + managers: Instance.Warehouse.ResolveResourceManagers(typeDef.DefinedType, null), + supportsDelay: true)) + return; + Codec.ParseAsync(tdu.Data, offset, this, null).Then(pr => { if (pr.Value is AsyncReply reply) @@ -1538,7 +1694,7 @@ partial class EpConnection // return; //} - InvokeFunction(fd, callback, results, EpPacketRequest.StaticCall, null); + InvokeFunction(fd, callback, results, EpPacketRequest.StaticCall, managerDelay, null); }).Error(x => { @@ -1555,7 +1711,7 @@ partial class EpConnection // @TODO: Make managers for static calls - InvokeFunction(fd, callback, pr.Value, EpPacketRequest.StaticCall, null); + InvokeFunction(fd, callback, pr.Value, EpPacketRequest.StaticCall, managerDelay, null); } }).Error(x => SendError(ErrorType.Management, callback, (ushort)ExceptionCode.ParseError)); } @@ -1586,6 +1742,17 @@ partial class EpConnection return; } + if (!TryApplyManagers( + ft, + r, + ActionType.Execute, + callback, + ErrorType.Management, + ExceptionCode.InvokeDenied, + out var managerDelay, + supportsDelay: true)) + return; + Codec.ParseAsync(tdu.Data, offset, this, null).Then(pr => { if (pr.Value is AsyncReply asyncReply) @@ -1599,30 +1766,26 @@ partial class EpConnection if (r is EpResource) { - var rt = (r as EpResource)._Invoke(index, result); - if (rt != null) + ExecuteRateControlled(callback, managerDelay, () => { - rt.Then(res => + var rt = (r as EpResource)._Invoke(index, result); + if (rt != null) { - SendReply(EpPacketReply.Completed, callback, res); - }); - } - else - { - // function not found on a distributed object - SendError(ErrorType.Management, callback, (ushort)ExceptionCode.MethodNotFound); - } + rt.Then(res => + { + SendReply(EpPacketReply.Completed, callback, res); + }); + } + else + { + // function not found on a distributed object + SendError(ErrorType.Management, callback, (ushort)ExceptionCode.MethodNotFound); + } + }); } else { - if (r.Instance.Applicable(_session, ActionType.Execute, ft) == Ruling.Denied) - { - SendError(ErrorType.Management, callback, - (ushort)ExceptionCode.InvokeDenied); - return; - } - - InvokeFunction(ft, callback, result, EpPacketRequest.InvokeFunction, r); + InvokeFunction(ft, callback, result, EpPacketRequest.InvokeFunction, managerDelay, r); } }); } @@ -1635,30 +1798,26 @@ partial class EpConnection if (r is EpResource) { - var rt = (r as EpResource)._Invoke(index, pr.Value); - if (rt != null) + ExecuteRateControlled(callback, managerDelay, () => { - rt.Then(res => + var rt = (r as EpResource)._Invoke(index, pr.Value); + if (rt != null) { - SendReply(EpPacketReply.Completed, callback, res); - }); - } - else - { - // function not found on a distributed object - SendError(ErrorType.Management, callback, (ushort)ExceptionCode.MethodNotFound); - } + rt.Then(res => + { + SendReply(EpPacketReply.Completed, callback, res); + }); + } + else + { + // function not found on a distributed object + SendError(ErrorType.Management, callback, (ushort)ExceptionCode.MethodNotFound); + } + }); } else { - if (r.Instance.Applicable(_session, ActionType.Execute, ft) == Ruling.Denied) - { - SendError(ErrorType.Management, callback, - (ushort)ExceptionCode.InvokeDenied); - return; - } - - InvokeFunction(ft, callback, pr.Value, EpPacketRequest.InvokeFunction, r); + InvokeFunction(ft, callback, pr.Value, EpPacketRequest.InvokeFunction, managerDelay, r); } } }).Error(x => SendError(ErrorType.Management, callback, (ushort)ExceptionCode.ParseError)); ; @@ -1667,19 +1826,17 @@ partial class EpConnection - void InvokeFunction(FunctionDef ft, uint callback, object arguments, EpPacketRequest actionType, object target = null) + void InvokeFunction( + FunctionDef ft, + uint callback, + object arguments, + EpPacketRequest actionType, + TimeSpan managerDelay, + object target = null) { - if (!TryApplyRateControl( - ft, - target as IResource, - ActionType.Execute, - callback, - out var delay)) - return; - ExecuteRateControlled( callback, - delay, + managerDelay, () => InvokeFunctionCore(ft, callback, arguments, actionType, target)); } @@ -1811,6 +1968,8 @@ partial class EpConnection object rt; + context?.BindOperation(target as IResource, ft); + try { rt = ft.MethodInfo.Invoke(target, args); @@ -1827,6 +1986,7 @@ partial class EpConnection if (ft.StreamMode != StreamMode.None) { context ??= new InvocationContext(this, callback); + context.BindOperation(target as IResource, ft); context.InitializeStream(ft.StreamMode, ft.Pausable); if (ft.StreamMode == StreamMode.Pull && context.SetAsyncEnumerable(rt)) @@ -2041,10 +2201,24 @@ partial class EpConnection return; } + if (!TryApplyManagers( + context.Function, + context.Resource, + ActionType.PullStream, + callback, + ErrorType.Management, + ExceptionCode.NotAllowed, + out var managerDelay, + supportsDelay: true)) + return; + Task.Run(async () => { try { + if (managerDelay > TimeSpan.Zero) + await Task.Delay(managerDelay).ConfigureAwait(false); + var next = await context.PullAsync(); if (next.HasValue) SendChunk(executionCallback, next.Value); @@ -2070,7 +2244,25 @@ partial class EpConnection { var executionCallback = ParseExecutionCallback(tdu); - if (!TakeInvocation(executionCallback, null, out var context)) + var activeContext = GetInvocation(executionCallback); + if (activeContext == null) + { + SendError(ErrorType.Management, callback, (ushort)ExceptionCode.NotAllowed, + "The target execution is not active."); + return; + } + + if (!TryApplyManagers( + activeContext.Function, + activeContext.Resource, + ActionType.TerminateExecution, + callback, + ErrorType.Management, + ExceptionCode.NotAllowed, + out _)) + return; + + if (!TakeInvocation(executionCallback, activeContext, out var context)) { SendError(ErrorType.Management, callback, (ushort)ExceptionCode.NotAllowed, "The target execution is not active."); @@ -2099,7 +2291,24 @@ partial class EpConnection var executionCallback = ParseExecutionCallback(tdu); var context = GetInvocation(executionCallback); - if (context == null || !context.Halt()) + if (context == null) + { + SendError(ErrorType.Management, callback, (ushort)ExceptionCode.NotAllowed, + "The target execution is not pausable or is already halted."); + return; + } + + if (!TryApplyManagers( + context.Function, + context.Resource, + ActionType.HaltExecution, + callback, + ErrorType.Management, + ExceptionCode.NotAllowed, + out _)) + return; + + if (!context.Halt()) { SendError(ErrorType.Management, callback, (ushort)ExceptionCode.NotAllowed, "The target execution is not pausable or is already halted."); @@ -2114,7 +2323,24 @@ partial class EpConnection var executionCallback = ParseExecutionCallback(tdu); var context = GetInvocation(executionCallback); - if (context == null || !context.Resume()) + if (context == null) + { + SendError(ErrorType.Management, callback, (ushort)ExceptionCode.NotAllowed, + "The target execution is not pausable or is not halted."); + return; + } + + if (!TryApplyManagers( + context.Function, + context.Resource, + ActionType.ResumeExecution, + callback, + ErrorType.Management, + ExceptionCode.NotAllowed, + out _)) + return; + + if (!context.Resume()) { SendError(ErrorType.Management, callback, (ushort)ExceptionCode.NotAllowed, "The target execution is not pausable or is not halted."); @@ -2151,6 +2377,16 @@ partial class EpConnection return; } + if (!TryApplyManagers( + et, + r, + ActionType.Subscribe, + callback, + ErrorType.Management, + ExceptionCode.NotAllowed, + out _)) + return; + if (r is EpResource) { (r as EpResource).Subscribe(et).Then(x => @@ -2210,6 +2446,16 @@ partial class EpConnection return; } + if (!TryApplyManagers( + et, + r, + ActionType.Unsubscribe, + callback, + ErrorType.Management, + ExceptionCode.NotAllowed, + out _)) + return; + if (r is EpResource) { (r as EpResource).Unsubscribe(et).Then(x => @@ -2274,18 +2520,15 @@ partial class EpConnection return; } - if (r.Instance.Applicable(_session, ActionType.SetProperty, pt, this) == Ruling.Denied) - { - SendError(ErrorType.Exception, callback, (ushort)ExceptionCode.SetPropertyDenied); - return; - } - - if (!TryApplyRateControl( + if (!TryApplyManagers( pt, r, ActionType.SetProperty, callback, - out var rateControlDelay)) + ErrorType.Exception, + ExceptionCode.SetPropertyDenied, + out var rateControlDelay, + supportsDelay: true)) return; @@ -3359,7 +3602,7 @@ partial class EpConnection if (!info.Receivers(_session)) return; - if (info.Resource.Instance.Applicable(_session, ActionType.ReceiveEvent, info.EventDef, info.Issuer) == Ruling.Denied) + if (!IsOperationAllowed(info.Resource, info.EventDef, ActionType.ReceiveEvent, info.Issuer)) return; @@ -3385,7 +3628,7 @@ partial class EpConnection } } - if (info.Resource.Instance.Applicable(_session, ActionType.ReceiveEvent, info.Definition, null) == Ruling.Denied) + if (!IsOperationAllowed(info.Resource, info.Definition, ActionType.ReceiveEvent)) return; // compose the packet diff --git a/Libraries/Esiur/Resource/Attributes/AuditingManagerAttribute.cs b/Libraries/Esiur/Resource/Attributes/AuditingManagerAttribute.cs index eb4784b..4d7c8a5 100644 --- a/Libraries/Esiur/Resource/Attributes/AuditingManagerAttribute.cs +++ b/Libraries/Esiur/Resource/Attributes/AuditingManagerAttribute.cs @@ -7,7 +7,10 @@ namespace Esiur.Resource; /// Associates a registered auditing-manager implementation with a resource type. /// [AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] -public sealed class AuditingManagerAttribute : Attribute +public sealed class AuditingManagerAttribute : ResourceManagerAttribute where T : IAuditingManager { + public AuditingManagerAttribute() : base(typeof(T)) + { + } } diff --git a/Libraries/Esiur/Resource/Attributes/PermissionsManagerAttribute.cs b/Libraries/Esiur/Resource/Attributes/PermissionsManagerAttribute.cs index 58135a6..3dd9dbc 100644 --- a/Libraries/Esiur/Resource/Attributes/PermissionsManagerAttribute.cs +++ b/Libraries/Esiur/Resource/Attributes/PermissionsManagerAttribute.cs @@ -7,7 +7,10 @@ namespace Esiur.Resource; /// Associates a registered permissions-manager implementation with a resource type. /// [AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] -public sealed class PermissionsManagerAttribute : Attribute +public sealed class PermissionsManagerAttribute : ResourceManagerAttribute where T : IPermissionsManager { + public PermissionsManagerAttribute() : base(typeof(T)) + { + } } diff --git a/Libraries/Esiur/Resource/Attributes/RateControlManagerAttribute.cs b/Libraries/Esiur/Resource/Attributes/RateControlManagerAttribute.cs index 79bb495..cbe0463 100644 --- a/Libraries/Esiur/Resource/Attributes/RateControlManagerAttribute.cs +++ b/Libraries/Esiur/Resource/Attributes/RateControlManagerAttribute.cs @@ -7,7 +7,10 @@ namespace Esiur.Resource; /// Associates a registered rate-control-manager implementation with a resource type. /// [AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] -public sealed class RateControlManagerAttribute : Attribute +public sealed class RateControlManagerAttribute : ResourceManagerAttribute where T : IRateControlManager { + public RateControlManagerAttribute() : base(typeof(T)) + { + } } diff --git a/Libraries/Esiur/Resource/Attributes/ResourceManagerAttribute.cs b/Libraries/Esiur/Resource/Attributes/ResourceManagerAttribute.cs new file mode 100644 index 0000000..1c2044e --- /dev/null +++ b/Libraries/Esiur/Resource/Attributes/ResourceManagerAttribute.cs @@ -0,0 +1,24 @@ +using Esiur.Security.Management; +using System; + +namespace Esiur.Resource; + +/// +/// Base metadata used to resolve a resource manager from its owning Warehouse. +/// Manager attributes never create instances directly; the declared type must be +/// registered with the Warehouse before the resource is created. +/// +public abstract class ResourceManagerAttribute : Attribute +{ + public Type ManagerType { get; } + + protected ResourceManagerAttribute(Type managerType) + { + ManagerType = managerType ?? throw new ArgumentNullException(nameof(managerType)); + + if (!typeof(IResourceManager).IsAssignableFrom(managerType)) + throw new ArgumentException( + $"Manager type `{managerType}` must implement {nameof(IResourceManager)}.", + nameof(managerType)); + } +} diff --git a/Libraries/Esiur/Resource/IResourceManagersContext.cs b/Libraries/Esiur/Resource/IResourceManagersContext.cs new file mode 100644 index 0000000..0b52f32 --- /dev/null +++ b/Libraries/Esiur/Resource/IResourceManagersContext.cs @@ -0,0 +1,14 @@ +using Esiur.Security.Management; +using System.Collections.Generic; + +namespace Esiur.Resource; + +/// +/// Optional trusted creation context that binds registered managers to a resource. +/// Keeping this separate from preserves compatibility +/// with existing context implementations. +/// +public interface IResourceManagersContext +{ + IReadOnlyList ResourceManagers { get; } +} diff --git a/Libraries/Esiur/Resource/Instance.cs b/Libraries/Esiur/Resource/Instance.cs index ea1b9a6..84f9bc3 100644 --- a/Libraries/Esiur/Resource/Instance.cs +++ b/Libraries/Esiur/Resource/Instance.cs @@ -8,6 +8,7 @@ using System.Runtime.CompilerServices; using System.Reflection; using Esiur.Misc; using Esiur.Security.Permissions; +using Esiur.Security.Management; using Esiur.Security.Authority; using Esiur.Proxy; using Esiur.Core; @@ -30,7 +31,7 @@ public class Instance WeakReference resource; IStore store; TypeDef definition; - AutoList managers; + AutoList managers; public event PropertyModifiedEvent PropertyModified; @@ -696,36 +697,55 @@ public class Instance /// Ruling. public Ruling Applicable(Session session, ActionType action, MemberDef member, object inquirer = null) { - IResource res; - if (this.resource.TryGetTarget(out res)) - { - //return store.Applicable(res, session, action, member, inquirer); + var context = MakeManagerContext(session, action, member, inquirer); + return Warehouse.EvaluatePermissions(context, managers.ToArray()); + } - foreach (IPermissionsManager manager in managers) - { - var r = manager.Applicable(res, session, action, member, inquirer); - if (r != Ruling.DontCare) - return r; - } - - } - - // Apply default permissions if no manager is applicable or if the resource is not available. - if (action == ActionType.GetProperty - || action == ActionType.ViewTypeDef - || action == ActionType.ReceiveEvent - || action == ActionType.Attach - || action == ActionType.Execute) - return Ruling.Allowed; - else - return Ruling.Denied; + /// + /// Evaluates the Warehouse defaults and this resource's manager snapshot. + /// + public ResourceManagerEvaluation EvaluateManagers( + Session session, + ActionType action, + MemberDef member, + object inquirer = null) + { + var context = MakeManagerContext(session, action, member, inquirer); + return Warehouse.EvaluateManagers(context, managers.ToArray()); + } + ResourceManagerContext MakeManagerContext( + Session session, + ActionType action, + MemberDef member, + object inquirer) + { + resource.TryGetTarget(out var res); + return new ResourceManagerContext( + Warehouse, + inquirer as EpConnection, + session, + res, + member, + action, + inquirer, + member?.MemberPolicyAttributes); } /// /// Execution managers. /// - public AutoList Managers => managers; + public AutoList Managers => managers; + + void ManagerAdded(Instance instance, IResourceManager manager) + { + if (manager != null && Warehouse.IsRegisteredManager(manager)) + return; + + managers.Remove(manager); + throw new InvalidOperationException( + $"Resource manager `{manager?.GetType()}` must be registered with this Warehouse before it is attached."); + } public readonly Warehouse Warehouse; /// @@ -747,7 +767,8 @@ public class Instance //this.attributes = new KeyList(this); //children = new AutoList(this); //parents = new AutoList(this); - managers = new AutoList(this); + managers = new AutoList(this); + managers.OnAdd += ManagerAdded; //children.OnAdd += Children_OnAdd; //children.OnRemoved += Children_OnRemoved; //parents.OnAdd += Parents_OnAdd; diff --git a/Libraries/Esiur/Resource/ResourceContext.cs b/Libraries/Esiur/Resource/ResourceContext.cs index 0326f5e..cb5332c 100644 --- a/Libraries/Esiur/Resource/ResourceContext.cs +++ b/Libraries/Esiur/Resource/ResourceContext.cs @@ -1,19 +1,22 @@ using Esiur.Data; using Esiur.Security.Authority; using Esiur.Security.Permissions; +using Esiur.Security.Management; using System; using System.Collections.Generic; +using System.Linq; using System.Security.Principal; using System.Text; namespace Esiur.Resource { - public class ResourceContext:IResourceContext + public class ResourceContext : IResourceContext, IResourceManagersContext { public ulong Age { get; } public Map Attributes { get; } public Map Properties { get; } public IPermissionsManager PermissionsManager { get; } + public IReadOnlyList ResourceManagers { get; } public ResourceContext(ulong age, Map attributes, Map properties, IPermissionsManager permissionsManager) { @@ -21,6 +24,35 @@ namespace Esiur.Resource Attributes = attributes; Properties = properties; PermissionsManager = permissionsManager; + ResourceManagers = permissionsManager is null + ? Array.Empty() + : new IResourceManager[] { permissionsManager }; + } + + /// + /// Creates a context with one or more locally supplied managers. Every manager + /// must already be registered with the target Warehouse. + /// + public ResourceContext( + IEnumerable resourceManagers, + ulong age = 0, + Map attributes = null, + Map properties = null) + { + if (resourceManagers == null) + throw new ArgumentNullException(nameof(resourceManagers)); + + var managers = resourceManagers.ToArray(); + if (managers.Any(manager => manager == null)) + throw new ArgumentException( + "Resource managers cannot contain null values.", + nameof(resourceManagers)); + + Age = age; + Attributes = attributes; + Properties = properties; + ResourceManagers = Array.AsReadOnly(managers); + PermissionsManager = managers.OfType().FirstOrDefault(); } //public virtual void Build() diff --git a/Libraries/Esiur/Resource/Warehouse.cs b/Libraries/Esiur/Resource/Warehouse.cs index 3472cc7..60ee576 100644 --- a/Libraries/Esiur/Resource/Warehouse.cs +++ b/Libraries/Esiur/Resource/Warehouse.cs @@ -31,6 +31,7 @@ using Esiur.Protocol; using Esiur.Proxy; using Esiur.Security.Authority; using Esiur.Security.Cryptography; +using Esiur.Security.Management; using Esiur.Security.Permissions; using Esiur.Security.RateLimiting; using Org.BouncyCastle.Asn1.Cms; @@ -91,7 +92,10 @@ public class Warehouse Map _authenticationProviders = new Map(); readonly ConcurrentDictionary _encryptionProviders = new ConcurrentDictionary(StringComparer.Ordinal); - List _permissionsManagers = new List(); + readonly ConcurrentDictionary _resourceManagers + = new ConcurrentDictionary(); + readonly ConcurrentDictionary _defaultResourceManagerTypes + = new ConcurrentDictionary(); readonly ConcurrentDictionary _ratePolicies = new ConcurrentDictionary(StringComparer.Ordinal); @@ -151,11 +155,374 @@ public class Warehouse } - public void RegisterPermissionsManager(IPermissionsManager manager) + /// + /// Registers a manager instance by its concrete type. Type attributes and + /// ResourceContext bindings can only reference instances registered here. + /// + public void RegisterManager(IResourceManager manager, bool useAsDefault = false) { - _permissionsManagers.Add(manager); + if (manager == null) + throw new ArgumentNullException(nameof(manager)); + + var managerType = manager.GetType(); + var categoryCount = + (manager is IPermissionsManager ? 1 : 0) + + (manager is IRateControlManager ? 1 : 0) + + (manager is IAuditingManager ? 1 : 0); + + if (categoryCount == 0) + throw new ArgumentException( + $"Manager `{managerType}` does not implement a supported manager category.", + nameof(manager)); + if (categoryCount > 1) + throw new ArgumentException( + $"Manager `{managerType}` must implement exactly one manager category.", + nameof(manager)); + + if (!_resourceManagers.TryAdd(managerType, manager)) + throw new InvalidOperationException( + $"A resource manager of type `{managerType}` is already registered."); + + if (useAsDefault) + _defaultResourceManagerTypes.TryAdd(managerType, 0); } + public void RegisterManager(TManager manager, bool useAsDefault = false) + where TManager : class, IResourceManager + => RegisterManager((IResourceManager)manager, useAsDefault); + + public TManager RegisterManager(bool useAsDefault = false) + where TManager : class, IResourceManager, new() + { + var manager = new TManager(); + RegisterManager(manager, useAsDefault); + return manager; + } + + /// + /// Compatibility registration for Warehouse-wide permissions. Registered + /// permission managers are defaults, matching the original API's intent. + /// + public void RegisterPermissionsManager(IPermissionsManager manager) + => RegisterManager(manager, true); + + public void RegisterPermissionsManager( + IPermissionsManager manager, + bool useAsDefault) + => RegisterManager(manager, useAsDefault); + + public void RegisterRateControlManager(IRateControlManager manager, bool useAsDefault = false) + => RegisterManager(manager, useAsDefault); + + public void RegisterAuditingManager(IAuditingManager manager, bool useAsDefault = false) + => RegisterManager(manager, useAsDefault); + + /// + /// Enables or disables a registered manager as a Warehouse-wide default. + /// Multiple defaults may be enabled for the same manager category. + /// + public void SetDefaultManager(Type managerType, bool enabled = true) + { + if (managerType == null) + throw new ArgumentNullException(nameof(managerType)); + if (!_resourceManagers.ContainsKey(managerType)) + throw new InvalidOperationException( + $"Resource manager `{managerType}` is not registered."); + if (!enabled && typeof(NamedRateControlManager).IsAssignableFrom(managerType)) + throw new InvalidOperationException( + "The built-in named rate-control manager cannot be disabled."); + + if (enabled) + _defaultResourceManagerTypes.TryAdd(managerType, 0); + else + _defaultResourceManagerTypes.TryRemove(managerType, out _); + } + + public void SetDefaultManager(bool enabled = true) + where TManager : IResourceManager + => SetDefaultManager(typeof(TManager), enabled); + + public IResourceManager? TryGetManager(Type managerType) + => managerType != null && _resourceManagers.TryGetValue(managerType, out var manager) + ? manager + : null; + + public TManager? TryGetManager() where TManager : class, IResourceManager + => TryGetManager(typeof(TManager)) as TManager; + + public bool RemoveManager(Type managerType) + { + if (managerType == null) + return false; + if (typeof(NamedRateControlManager).IsAssignableFrom(managerType)) + return false; + + _defaultResourceManagerTypes.TryRemove(managerType, out _); + return _resourceManagers.TryRemove(managerType, out _); + } + + public Type[] GetRegisteredManagerTypes() + => _resourceManagers.Keys.OrderBy(type => type.FullName, StringComparer.Ordinal).ToArray(); + + public IResourceManager[] GetDefaultManagers() + => _defaultResourceManagerTypes.Keys + .OrderBy(type => type.FullName, StringComparer.Ordinal) + .Select(type => TryGetManager(type)) + .Where(manager => manager != null) + .ToArray()!; + + internal bool IsRegisteredManager(IResourceManager manager) + => manager != null && + _resourceManagers.TryGetValue(manager.GetType(), out var registered) && + ReferenceEquals(manager, registered); + + internal IResourceManager[] ResolveResourceManagers( + Type resourceType, + IResourceContext resourceContext) + { + if (resourceType == null) + throw new ArgumentNullException(nameof(resourceType)); + + var resolved = new List(); + var baseType = ResourceProxy.GetBaseType(resourceType); + + foreach (var attribute in baseType + .GetCustomAttributes(typeof(ResourceManagerAttribute), true) + .OfType()) + { + var manager = TryGetManager(attribute.ManagerType); + if (manager == null) + throw new InvalidOperationException( + $"Resource manager `{attribute.ManagerType}` declared by `{baseType}` is not registered."); + + if (!resolved.Any(existing => ReferenceEquals(existing, manager))) + resolved.Add(manager); + } + + if (resourceContext is IResourceManagersContext managerContext) + { + foreach (var manager in managerContext.ResourceManagers ?? Array.Empty()) + { + if (manager == null) + throw new InvalidOperationException("A ResourceContext supplied a null manager."); + if (!IsRegisteredManager(manager)) + throw new InvalidOperationException( + $"Resource manager `{manager.GetType()}` supplied by ResourceContext is not registered with this Warehouse."); + + if (!resolved.Any(existing => ReferenceEquals(existing, manager))) + resolved.Add(manager); + } + } + + return resolved.ToArray(); + } + + /// + /// Asks every applicable manager using independent deny-overrides aggregation + /// for permissions, rate control and auditing. + /// + public ResourceManagerEvaluation EvaluateManagers( + ResourceManagerContext context, + IEnumerable? resourceManagers = null) + { + if (context == null) + throw new ArgumentNullException(nameof(context)); + if (!ReferenceEquals(context.Warehouse, this)) + throw new ArgumentException( + "The manager context belongs to another Warehouse.", + nameof(context)); + + var localManagers = resourceManagers?.ToArray() + ?? context.Resource?.Instance?.Managers.ToArray() + ?? Array.Empty(); + + var managers = new List(); + foreach (var manager in GetDefaultManagers().Concat(localManagers)) + if (manager != null && !managers.Any(existing => ReferenceEquals(existing, manager))) + managers.Add(manager); + + var permissionsAllowed = false; + var permissionsDenied = false; + var rateAllowed = false; + var rateDenied = false; + var auditingAllowed = false; + var auditingDenied = false; + var delay = TimeSpan.Zero; + string permissionsReason = null; + string rateReason = null; + string auditingReason = null; + + foreach (var manager in managers) + { + var registered = IsRegisteredManager(manager); + context.Delay = TimeSpan.Zero; + context.DenialReason = null; + + try + { + if (!registered) + throw new InvalidOperationException( + $"Resource manager `{manager.GetType()}` is not registered with this Warehouse."); + + if (manager is IPermissionsManager permissionsManager) + { + var ruling = permissionsManager.Applicable( + context.Resource, + context.Session, + context.Action, + context.Member, + context.Inquirer); + + permissionsDenied |= ruling == Ruling.Denied; + permissionsAllowed |= ruling == Ruling.Allowed; + if (ruling == Ruling.Denied && permissionsReason == null) + permissionsReason = context.DenialReason + ?? $"Permissions manager `{manager.GetType().Name}` denied the operation."; + } + else if (manager is IRateControlManager rateControlManager) + { + var ruling = rateControlManager.Applicable(context); + rateDenied |= ruling == Ruling.Denied; + rateAllowed |= ruling == Ruling.Allowed; + if (context.Delay > delay) + delay = context.Delay; + if (ruling == Ruling.Denied && rateReason == null) + rateReason = context.DenialReason + ?? $"Rate-control manager `{manager.GetType().Name}` denied the operation."; + } + else if (manager is IAuditingManager auditingManager) + { + var ruling = auditingManager.Applicable(context); + auditingDenied |= ruling == Ruling.Denied; + auditingAllowed |= ruling == Ruling.Allowed; + if (ruling == Ruling.Denied && auditingReason == null) + auditingReason = context.DenialReason + ?? $"Auditing manager `{manager.GetType().Name}` denied the operation."; + } + } + catch (Exception exception) + { + Global.Log( + "ResourceManager", + LogType.Error, + $"Manager `{manager.GetType()}` failed while evaluating `{context.Action}`: {exception}"); + + if (manager is IPermissionsManager) + { + permissionsDenied = true; + permissionsReason ??= "A permissions manager failed while evaluating the operation."; + } + else if (manager is IRateControlManager) + { + rateDenied = true; + rateReason ??= "A rate-control manager failed while evaluating the operation."; + } + else if (manager is IAuditingManager) + { + auditingDenied = true; + auditingReason ??= "An auditing manager failed while evaluating the operation."; + } + } + } + + var permissions = permissionsDenied + ? Ruling.Denied + : permissionsAllowed + ? Ruling.Allowed + : DefaultPermissions(context.Action); + var rateControl = rateDenied + ? Ruling.Denied + : rateAllowed ? Ruling.Allowed : Ruling.DontCare; + var auditing = auditingDenied + ? Ruling.Denied + : auditingAllowed ? Ruling.Allowed : Ruling.DontCare; + + context.Delay = delay; + context.DenialReason = permissions == Ruling.Denied + ? permissionsReason + : auditing == Ruling.Denied + ? auditingReason + : rateReason; + + return new ResourceManagerEvaluation( + permissions, + rateControl, + auditing, + delay, + permissionsReason, + rateReason, + auditingReason); + } + + internal Ruling EvaluatePermissions( + ResourceManagerContext context, + IEnumerable resourceManagers) + { + if (context == null) + throw new ArgumentNullException(nameof(context)); + if (!ReferenceEquals(context.Warehouse, this)) + throw new ArgumentException( + "The manager context belongs to another Warehouse.", + nameof(context)); + + var managers = new List(); + foreach (var manager in GetDefaultManagers() + .Concat(resourceManagers ?? Array.Empty()) + .OfType()) + if (!managers.Any(existing => ReferenceEquals(existing, manager))) + managers.Add(manager); + + var allowed = false; + var denied = false; + + foreach (var manager in managers) + { + try + { + if (!IsRegisteredManager(manager)) + throw new InvalidOperationException( + $"Resource manager `{manager.GetType()}` is not registered with this Warehouse."); + + var ruling = manager.Applicable( + context.Resource, + context.Session, + context.Action, + context.Member, + context.Inquirer); + denied |= ruling == Ruling.Denied; + allowed |= ruling == Ruling.Allowed; + } + catch (Exception exception) + { + denied = true; + Global.Log( + "ResourceManager", + LogType.Error, + $"Permissions manager `{manager.GetType()}` failed while evaluating `{context.Action}`: {exception}"); + } + } + + return denied + ? Ruling.Denied + : allowed ? Ruling.Allowed : DefaultPermissions(context.Action); + } + + internal static Ruling DefaultPermissions(ActionType action) + => action == ActionType.GetProperty + || action == ActionType.ViewTypeDef + || action == ActionType.ReceiveEvent + || action == ActionType.Attach + || action == ActionType.Execute + || action == ActionType.Detach + || action == ActionType.Subscribe + || action == ActionType.Unsubscribe + || action == ActionType.PullStream + || action == ActionType.TerminateExecution + || action == ActionType.HaltExecution + || action == ActionType.ResumeExecution + ? Ruling.Allowed + : Ruling.Denied; + /// /// Registers a named rate policy referenced by RateControl attributes. /// @@ -241,6 +608,8 @@ public class Warehouse { Configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + RegisterManager(new NamedRateControlManager(), true); + Protocols.Add("EP", async (name, context) => await New(name, context)); @@ -540,11 +909,14 @@ public class Warehouse } var resourceReference = new WeakReference(resource); + var resourceManagers = ResolveResourceManagers(resource.GetType(), resourceContext); var resourceId = (uint)Interlocked.Increment(ref _resourceCounter); resource.Instance = new Instance(this, resourceId, instanceName, resource, store, resourceContext?.Age ?? 0); + resource.Instance.Managers.AddRange(resourceManagers); + if (resourceContext?.Attributes != null) resource.Instance.SetAttributes(resourceContext.Attributes); diff --git a/Libraries/Esiur/Security/Management/IRateControlManager.cs b/Libraries/Esiur/Security/Management/IRateControlManager.cs index 67f8fbe..361ac1c 100644 --- a/Libraries/Esiur/Security/Management/IRateControlManager.cs +++ b/Libraries/Esiur/Security/Management/IRateControlManager.cs @@ -5,7 +5,8 @@ namespace Esiur.Security.Management; /// /// Evaluates whether a resource operation is admitted by rate-control policy. /// A manager may assign when allowing -/// an operation that should be queued. +/// an operation that should be queued and +/// is true. /// public interface IRateControlManager : IResourceManager { diff --git a/Libraries/Esiur/Security/Management/ResourceManagerContext.cs b/Libraries/Esiur/Security/Management/ResourceManagerContext.cs index 779c607..d36b8ce 100644 --- a/Libraries/Esiur/Security/Management/ResourceManagerContext.cs +++ b/Libraries/Esiur/Security/Management/ResourceManagerContext.cs @@ -10,9 +10,9 @@ using System.Linq; namespace Esiur.Security.Management; /// -/// Immutable metadata describing a resource operation being evaluated by managers. -/// is the sole mutable value and allows rate-control managers to -/// request deferred execution. +/// Metadata describing a resource operation being evaluated by managers. Operation +/// identity is immutable; and let a +/// manager return admission details. /// public sealed class ResourceManagerContext { @@ -23,9 +23,16 @@ public sealed class ResourceManagerContext public Session? Session { get; } public IResource? Resource { get; } public MemberDef? Member { get; } + public TypeDef? TypeDefinition { get; } public ActionType Action { get; } public object? Inquirer { get; } + /// + /// Indicates whether the protocol operation can honor an allowed delayed ruling. + /// Rate managers should inspect this before reserving queued capacity. + /// + public bool SupportsDelay { get; } + /// /// Gets the local attributes that configure manager policy for the target member. /// The collection is a defensive, read-only snapshot. @@ -33,10 +40,17 @@ public sealed class ResourceManagerContext public IReadOnlyList MemberPolicyAttributes => _memberPolicyAttributes; /// - /// Gets or sets an optional delay requested by a rate-control manager. + /// Gets or sets an optional delay requested by a rate-control manager. Execute, + /// property-set, and pull-stream operations honor delayed rulings; operations + /// that cannot be delayed fail closed. /// public TimeSpan Delay { get; set; } + /// + /// Optional public-safe reason supplied by a manager when it denies an operation. + /// + public string? DenialReason { get; set; } + public ResourceManagerContext( Warehouse warehouse, EpConnection? connection, @@ -45,15 +59,19 @@ public sealed class ResourceManagerContext MemberDef? member, ActionType action, object? inquirer = null, - IEnumerable? memberPolicyAttributes = null) + IEnumerable? memberPolicyAttributes = null, + TypeDef? typeDefinition = null, + bool supportsDelay = false) { Warehouse = warehouse ?? throw new ArgumentNullException(nameof(warehouse)); Connection = connection; Session = session; Resource = resource; Member = member; + TypeDefinition = typeDefinition ?? resource?.Instance?.Definition ?? member?.Definition; Action = action; Inquirer = inquirer; + SupportsDelay = supportsDelay; var policies = memberPolicyAttributes?.ToArray() ?? Array.Empty(); if (policies.Any(attribute => attribute == null)) diff --git a/Libraries/Esiur/Security/Management/ResourceManagerEvaluation.cs b/Libraries/Esiur/Security/Management/ResourceManagerEvaluation.cs new file mode 100644 index 0000000..de9daad --- /dev/null +++ b/Libraries/Esiur/Security/Management/ResourceManagerEvaluation.cs @@ -0,0 +1,43 @@ +using Esiur.Security.Permissions; +using System; + +namespace Esiur.Security.Management; + +/// +/// Aggregated decisions from each independent manager category. +/// An allow in one category never grants admission in another category. +/// +public sealed class ResourceManagerEvaluation +{ + public Ruling Permissions { get; } + public Ruling RateControl { get; } + public Ruling Auditing { get; } + public TimeSpan Delay { get; } + + public string? PermissionsDenialReason { get; } + public string? RateControlDenialReason { get; } + public string? AuditingDenialReason { get; } + + public bool IsAllowed => + Permissions == Ruling.Allowed && + RateControl != Ruling.Denied && + Auditing != Ruling.Denied; + + internal ResourceManagerEvaluation( + Ruling permissions, + Ruling rateControl, + Ruling auditing, + TimeSpan delay, + string? permissionsDenialReason, + string? rateControlDenialReason, + string? auditingDenialReason) + { + Permissions = permissions; + RateControl = rateControl; + Auditing = auditing; + Delay = delay > TimeSpan.Zero ? delay : TimeSpan.Zero; + PermissionsDenialReason = permissionsDenialReason; + RateControlDenialReason = rateControlDenialReason; + AuditingDenialReason = auditingDenialReason; + } +} diff --git a/Libraries/Esiur/Security/Permissions/ActionType.cs b/Libraries/Esiur/Security/Permissions/ActionType.cs index 1253aeb..ad7170f 100644 --- a/Libraries/Esiur/Security/Permissions/ActionType.cs +++ b/Libraries/Esiur/Security/Permissions/ActionType.cs @@ -46,5 +46,12 @@ public enum ActionType RemoveChild, Rename, ReceiveEvent, - ViewTypeDef + ViewTypeDef, + Detach, + Subscribe, + Unsubscribe, + PullStream, + TerminateExecution, + HaltExecution, + ResumeExecution } diff --git a/Libraries/Esiur/Security/Permissions/StorePermissionsManager.cs b/Libraries/Esiur/Security/Permissions/StorePermissionsManager.cs index 8478680..5d2d2f9 100644 --- a/Libraries/Esiur/Security/Permissions/StorePermissionsManager.cs +++ b/Libraries/Esiur/Security/Permissions/StorePermissionsManager.cs @@ -30,6 +30,7 @@ using Esiur.Core; using Esiur.Resource; using Esiur.Security.Authority; using Esiur.Data.Types; +using System.Linq; namespace Esiur.Security.Permissions; @@ -41,7 +42,29 @@ public class StorePermissionsManager : IPermissionsManager public Ruling Applicable(IResource resource, Session session, ActionType action, MemberDef member, object inquirer = null) { - return resource.Instance.Store.Instance.Applicable(session, action, member, inquirer); + var storeInstance = resource?.Instance?.Store?.Instance; + if (storeInstance == null) + return Ruling.DontCare; + + // Delegate only to managers attached to the store. Re-entering + // Instance.Applicable would run Warehouse defaults (including this manager) + // again and can recurse indefinitely or charge rate policies twice. + var store = storeInstance.Resource; + var allowed = false; + + foreach (var manager in storeInstance.Managers + .ToArray() + .OfType() + .Where(manager => !ReferenceEquals(manager, this))) + { + var ruling = manager.Applicable(store, session, action, member, inquirer); + if (ruling == Ruling.Denied) + return Ruling.Denied; + if (ruling == Ruling.Allowed) + allowed = true; + } + + return allowed ? Ruling.Allowed : Ruling.DontCare; } public bool Initialize(Map settings, IResource resource) diff --git a/Libraries/Esiur/Security/RateLimiting/NamedRateControlManager.cs b/Libraries/Esiur/Security/RateLimiting/NamedRateControlManager.cs new file mode 100644 index 0000000..375d888 --- /dev/null +++ b/Libraries/Esiur/Security/RateLimiting/NamedRateControlManager.cs @@ -0,0 +1,53 @@ +using Esiur.Security.Management; +using Esiur.Security.Permissions; +using System; + +namespace Esiur.Security.RateLimiting; + +/// +/// Bridges the existing named RateControl policy registry into the unified +/// resource-manager pipeline. +/// +internal sealed class NamedRateControlManager : IRateControlManager +{ + public Ruling Applicable(ResourceManagerContext context) + { + if (context.Action != ActionType.Execute && + context.Action != ActionType.SetProperty) + return Ruling.DontCare; + + var policyName = context.Member?.RatePolicyName; + if (string.IsNullOrWhiteSpace(policyName)) + return Ruling.DontCare; + + if (context.Connection is null || context.Session is null || context.Member is null) + { + context.DenialReason = $"Rate policy `{policyName}` requires an authenticated connection."; + return Ruling.Denied; + } + + var policy = context.Warehouse.TryGetRatePolicy(policyName); + if (policy is null) + { + context.DenialReason = $"Rate policy `{policyName}` is not registered."; + return Ruling.Denied; + } + + var rateContext = new RateControlContext( + context.Warehouse, + context.Connection, + context.Session, + context.Resource, + context.Member, + context.Action); + + var ruling = policy.Applicable(rateContext); + if (rateContext.Delay > context.Delay) + context.Delay = rateContext.Delay; + + if (ruling == Ruling.Denied && string.IsNullOrWhiteSpace(context.DenialReason)) + context.DenialReason = $"Rate policy `{policyName}` denied `{context.Member.Fullname}`."; + + return ruling; + } +} diff --git a/Stores/Esiur.Stores.EntityCore/EsiurExtensions.cs b/Stores/Esiur.Stores.EntityCore/EsiurExtensions.cs index b906607..d38f7ac 100644 --- a/Stores/Esiur.Stores.EntityCore/EsiurExtensions.cs +++ b/Stores/Esiur.Stores.EntityCore/EsiurExtensions.cs @@ -131,7 +131,13 @@ public static class EsiurExtensions var id = store.TypesByType[typeof(T)].PrimaryKey.GetValue(resource); - await options.Warehouse.Put($"{store.Instance.Name}/{typeof(T).Name}/{id}", res, new ResourceContext(0, null, null, manager)); + var resourceContext = manager == null + ? null + : new ResourceContext(new[] { manager }); + await options.Warehouse.Put( + $"{store.Instance.Name}/{typeof(T).Name}/{id}", + res, + resourceContext); return (T)res; } diff --git a/Stores/Esiur.Stores.EntityCore/EsiurProxyRewrite.cs b/Stores/Esiur.Stores.EntityCore/EsiurProxyRewrite.cs index b2519e7..67d052c 100644 --- a/Stores/Esiur.Stores.EntityCore/EsiurProxyRewrite.cs +++ b/Stores/Esiur.Stores.EntityCore/EsiurProxyRewrite.cs @@ -71,7 +71,13 @@ public class EsiurProxyRewrite : IModelFinalizingConvention // check if the object exists var obj = options.Warehouse.Create(entityType.ClrType, null) as IResource; options.Store.TypesByType[entityType.ClrType].PrimaryKey.SetValue(obj, id); - options.Warehouse.Put($"{options.Store.Instance.Name}/{entityType.ClrType.Name}/{id}", obj, new ResourceContext(0, null, null, manager)).Wait(); + var resourceContext = manager == null + ? null + : new ResourceContext(new[] { manager }); + options.Warehouse.Put( + $"{options.Store.Instance.Name}/{entityType.ClrType.Name}/{id}", + obj, + resourceContext).Wait(); return obj; } else diff --git a/Tests/Features/Functional/ManagerScenarios.cs b/Tests/Features/Functional/ManagerScenarios.cs new file mode 100644 index 0000000..56ff98b --- /dev/null +++ b/Tests/Features/Functional/ManagerScenarios.cs @@ -0,0 +1,365 @@ +/* + +Copyright (c) 2017-2026 Ahmed Kh. Zamil + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +*/ + +using Esiur.Core; +using Esiur.Data; +using Esiur.Data.Types; +using Esiur.Protocol; +using Esiur.Resource; +using Esiur.Security.Authority; +using Esiur.Security.Management; +using Esiur.Security.Permissions; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +#nullable enable + +namespace Esiur.Tests.Functional; + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, Inherited = true)] +internal sealed class FunctionalManagerPolicyAttribute : Attribute +{ + public string Name { get; } + public Ruling Permissions { get; set; } = Ruling.DontCare; + public Ruling RateControl { get; set; } = Ruling.DontCare; + + public FunctionalManagerPolicyAttribute(string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("A manager policy name is required.", nameof(name)); + + Name = name; + } +} + +internal sealed class ManagerObservation +{ + public ActionType Action { get; } + public string? MemberName { get; } + public string? PolicyName { get; } + public string? CallerIdentity { get; } + public bool HasConnection { get; } + public IResource? Resource { get; } + + public ManagerObservation( + ActionType action, + string? memberName, + string? policyName, + string? callerIdentity, + bool hasConnection, + IResource? resource) + { + Action = action; + MemberName = memberName; + PolicyName = policyName; + CallerIdentity = callerIdentity; + HasConnection = hasConnection; + Resource = resource; + } +} + +internal abstract class ManagerObservationRecorder +{ + readonly ConcurrentQueue observations = new(); + + protected static FunctionalManagerPolicyAttribute? GetPolicy(MemberDef? member) + => member?.MemberPolicyAttributes + .OfType() + .SingleOrDefault(); + + protected void Record( + ActionType action, + MemberDef? member, + IResource? resource, + Session? session, + EpConnection? connection) + { + observations.Enqueue(new ManagerObservation( + action, + member?.Name, + GetPolicy(member)?.Name, + session?.RemoteIdentity ?? session?.LocalIdentity, + connection != null, + resource)); + } + + public ManagerObservation[] ExecuteObservations(string memberName) + => observations + .Where(observation => + observation.Action == ActionType.Execute && + string.Equals(observation.MemberName, memberName, StringComparison.Ordinal)) + .ToArray(); +} + +internal sealed class DefaultAllowPermissionsManager : + ManagerObservationRecorder, + IPermissionsManager +{ + public Map Settings { get; } = new(); + + public Ruling Applicable( + IResource resource, + Session session, + ActionType action, + MemberDef member, + object inquirer = null!) + { + Record(action, member, resource, session, inquirer as EpConnection); + + return action == ActionType.Execute || action == ActionType.SetProperty + ? Ruling.Allowed + : Ruling.DontCare; + } + + public bool Initialize(Map settings, IResource resource) => true; +} + +internal sealed class ProbeDenyPermissionsManager : + ManagerObservationRecorder, + IPermissionsManager +{ + public Map Settings { get; } = new(); + + public Ruling Applicable( + IResource resource, + Session session, + ActionType action, + MemberDef member, + object inquirer = null!) + { + Record(action, member, resource, session, inquirer as EpConnection); + return GetPolicy(member)?.Permissions ?? Ruling.DontCare; + } + + public bool Initialize(Map settings, IResource resource) => true; +} + +internal sealed class ProbeRateControlManager : + ManagerObservationRecorder, + IRateControlManager +{ + public Ruling Applicable(ResourceManagerContext context) + { + Record( + context.Action, + context.Member, + context.Resource, + context.Session, + context.Connection); + + var ruling = GetPolicy(context.Member)?.RateControl ?? Ruling.DontCare; + if (ruling == Ruling.Denied) + context.DenialReason = "The functional rate-control manager denied the operation."; + + return ruling; + } +} + +internal sealed class AttributeProbeAuditingManager : + ManagerObservationRecorder, + IAuditingManager +{ + public Ruling Applicable(ResourceManagerContext context) + { + Record( + context.Action, + context.Member, + context.Resource, + context.Session, + context.Connection); + + return Ruling.Allowed; + } +} + +internal sealed class ContextProbeAuditingManager : + ManagerObservationRecorder, + IAuditingManager +{ + public Ruling Applicable(ResourceManagerContext context) + { + Record( + context.Action, + context.Member, + context.Resource, + context.Session, + context.Connection); + + return Ruling.DontCare; + } +} + +[Resource] +[PermissionsManager] +[RateControlManager] +[AuditingManager] +public partial class ManagerProbeResource +{ + int allowedExecutions; + int permissionDeniedExecutions; + int rateDeniedExecutions; + + internal int AllowedExecutions => Volatile.Read(ref allowedExecutions); + internal int PermissionDeniedExecutions => Volatile.Read(ref permissionDeniedExecutions); + internal int RateDeniedExecutions => Volatile.Read(ref rateDeniedExecutions); + + [Export] + [FunctionalManagerPolicy( + "manager-allowed", + Permissions = Ruling.DontCare, + RateControl = Ruling.Allowed)] + public int AllowedCall(int value) + { + Interlocked.Increment(ref allowedExecutions); + return value + 1; + } + + [Export] + [FunctionalManagerPolicy( + "manager-permission-denied", + Permissions = Ruling.Denied, + RateControl = Ruling.Allowed)] + public int PermissionDeniedCall() + { + Interlocked.Increment(ref permissionDeniedExecutions); + return permissionDeniedExecutions; + } + + [Export] + [FunctionalManagerPolicy( + "manager-rate-denied", + Permissions = Ruling.DontCare, + RateControl = Ruling.Denied)] + public int RateDeniedCall() + { + Interlocked.Increment(ref rateDeniedExecutions); + return rateDeniedExecutions; + } +} + +internal sealed class ManagerScenarioFixture +{ + public ManagerProbeResource Resource { get; } + public IReadOnlyList Managers { get; } + + public ManagerScenarioFixture( + ManagerProbeResource resource, + params ManagerObservationRecorder[] managers) + { + Resource = resource; + Managers = managers; + } +} + +internal static class ManagerScenarios +{ + static readonly (string MemberName, string PolicyName)[] expectedCalls = + { + (nameof(ManagerProbeResource.AllowedCall), "manager-allowed"), + (nameof(ManagerProbeResource.PermissionDeniedCall), "manager-permission-denied"), + (nameof(ManagerProbeResource.RateDeniedCall), "manager-rate-denied"), + }; + + public static async Task Run(EpConnection connection, ManagerScenarioFixture fixture) + { + Console.WriteLine("Registered resource managers"); + + var remote = await connection.Get("sys/manager-probe") as EpResource + ?? throw new InvalidOperationException("The manager probe resource was not found."); + + var allowed = GetFunction(remote, nameof(ManagerProbeResource.AllowedCall)); + var permissionDenied = GetFunction(remote, nameof(ManagerProbeResource.PermissionDeniedCall)); + var rateDenied = GetFunction(remote, nameof(ManagerProbeResource.RateDeniedCall)); + + var allowedArguments = new Map { [0] = 41 }; + var allowedResult = await remote._Invoke(allowed.Index, allowedArguments); + Require(Convert.ToInt32(allowedResult) == 42, "The manager-approved invocation returned an unexpected value."); + + await ExpectError( + () => remote._Invoke(permissionDenied.Index, Array.Empty()), + ExceptionCode.InvokeDenied); + await ExpectError( + () => remote._Invoke(rateDenied.Index, Array.Empty()), + ExceptionCode.RateLimitExceeded); + + Require(fixture.Resource.AllowedExecutions == 1, "The allowed manager probe did not execute exactly once."); + Require(fixture.Resource.PermissionDeniedExecutions == 0, "A permissions-denied invocation reached the resource."); + Require(fixture.Resource.RateDeniedExecutions == 0, "A rate-denied invocation reached the resource."); + + foreach (var manager in fixture.Managers) + { + foreach (var expected in expectedCalls) + { + var observations = manager.ExecuteObservations(expected.MemberName); + Require( + observations.Length == 1, + $"Manager `{manager.GetType().Name}` observed `{expected.MemberName}` {observations.Length} times."); + + var observation = observations[0]; + Require( + string.Equals(observation.PolicyName, expected.PolicyName, StringComparison.Ordinal), + $"Manager `{manager.GetType().Name}` did not receive the policy for `{expected.MemberName}`."); + Require( + string.Equals(observation.CallerIdentity, "tester", StringComparison.Ordinal), + $"Manager `{manager.GetType().Name}` did not receive the authenticated identity."); + Require( + observation.HasConnection, + $"Manager `{manager.GetType().Name}` did not receive the connection context."); + Require( + ReferenceEquals(observation.Resource, fixture.Resource), + $"Manager `{manager.GetType().Name}` did not receive the target resource."); + } + } + + Console.WriteLine(" PASS defaults, type attributes, ResourceContext, deny-overrides, and audit fan-out"); + } + + static FunctionDef GetFunction(EpResource resource, string name) + => resource.Instance.Definition.GetFunctionDefByName(name) + ?? throw new InvalidOperationException($"Manager probe function `{name}` was not found."); + + static async Task ExpectError(Func action, ExceptionCode expectedCode) + { + try + { + await action(); + } + catch (AsyncException exception) when (exception.Code == expectedCode) + { + return; + } + + throw new InvalidOperationException($"The request was expected to fail with `{expectedCode}`."); + } + + static void Require(bool condition, string message) + { + if (!condition) + throw new InvalidOperationException(message); + } +} diff --git a/Tests/Features/Functional/Program.cs b/Tests/Features/Functional/Program.cs index aa9b96a..2de9b87 100644 --- a/Tests/Features/Functional/Program.cs +++ b/Tests/Features/Functional/Program.cs @@ -24,12 +24,11 @@ SOFTWARE. using Esiur.Core; using Esiur.Data; -using Esiur.Data.Types; using Esiur.Protocol; using Esiur.Resource; using Esiur.Security.Authority; using Esiur.Security.Cryptography; -using Esiur.Security.Permissions; +using Esiur.Security.Management; using Esiur.Security.RateLimiting; using Esiur.Stores; using System; @@ -55,16 +54,17 @@ internal static class Program try { var port = FindAvailablePort(); - var service = await StartServer(serverWarehouse, port); + var server = await StartServer(serverWarehouse, port); connection = await ConnectClient(clientWarehouse, port); Require(connection.IsEncrypted, "Authenticated connection did not enable AES encryption."); var remote = await connection.Get("sys/service") as EpResource ?? throw new InvalidOperationException("Remote service was not found."); - await RunCoreScenarios(connection, service, remote); + await RunCoreScenarios(connection, server.Service, remote); + await ManagerScenarios.Run(connection, server.ManagerScenario); await RunRateControlScenarios(remote); - await RunStreamingScenarios(service, remote); + await RunStreamingScenarios(server.Service, remote); Console.WriteLine(); Console.WriteLine("All functional scenarios passed."); @@ -77,10 +77,25 @@ internal static class Program } } - static async Task StartServer(Warehouse warehouse, ushort port) + static async Task<(MyService Service, ManagerScenarioFixture ManagerScenario)> StartServer( + Warehouse warehouse, + ushort port) { warehouse.RegisterAuthenticationProvider(new ServerAuthenticationProvider()); warehouse.RegisterEncryptionProvider(new AesEncryptionProvider()); + + var defaultPermissions = new DefaultAllowPermissionsManager(); + var denyPermissions = new ProbeDenyPermissionsManager(); + var rateControl = new ProbeRateControlManager(); + var attributeAudit = new AttributeProbeAuditingManager(); + var contextAudit = new ContextProbeAuditingManager(); + + warehouse.RegisterPermissionsManager(defaultPermissions); + warehouse.RegisterManager(denyPermissions); + warehouse.RegisterRateControlManager(rateControl); + warehouse.RegisterAuditingManager(attributeAudit); + warehouse.RegisterAuditingManager(contextAudit); + warehouse.Configuration.Parser.MaximumPacketSize = 8 * 1024 * 1024; warehouse.Configuration.Parser.MaximumAllocationSize = 4 * 1024 * 1024; warehouse.Configuration.Parser.MaximumCollectionItems = 65_536; @@ -111,7 +126,10 @@ internal static class Program }); var service = await warehouse.Put("sys/service", new MyService()); - service.Instance!.Managers.Add(new AllowPropertySetPermissions()); + var managerProbe = await warehouse.Put( + "sys/manager-probe", + new ManagerProbeResource(), + new ResourceContext(new IResourceManager[] { contextAudit })); var resource1 = await warehouse.Put("sys/service/r1", new MyResource { Description = "Testing 1", @@ -144,7 +162,15 @@ internal static class Program server.MapCall("temp", () => child2); await warehouse.Open(); - return service; + return ( + service, + new ManagerScenarioFixture( + managerProbe, + defaultPermissions, + denyPermissions, + rateControl, + attributeAudit, + contextAudit)); } static async Task ConnectClient(Warehouse warehouse, ushort port) @@ -358,18 +384,4 @@ internal static class Program return port; } - sealed class AllowPropertySetPermissions : IPermissionsManager - { - public Map Settings { get; } = new(); - - public Ruling Applicable( - IResource resource, - Session session, - ActionType action, - MemberDef member, - object inquirer = null!) - => action == ActionType.SetProperty ? Ruling.Allowed : Ruling.DontCare; - - public bool Initialize(Map settings, IResource resource) => true; - } } diff --git a/Tests/Features/Functional/README.md b/Tests/Features/Functional/README.md index 0ba118f..c9cf35a 100644 --- a/Tests/Features/Functional/README.md +++ b/Tests/Features/Functional/README.md @@ -8,6 +8,8 @@ Coverage includes: - resource attachment and property serialization; - procedure and resource calls, optional arguments, tuples, and events; +- registered resource managers from Warehouse defaults, generic type attributes, + and `ResourceContext`, including deny-overrides and audit fan-out; - named rate policies for function calls and property setters; - rate-limit denial propagation to callers; - pull streams backed by `IAsyncEnumerable`; diff --git a/Tests/Unit/Integration/RateControlIntegrationTests.cs b/Tests/Unit/Integration/RateControlIntegrationTests.cs index a89030e..443f7b8 100644 --- a/Tests/Unit/Integration/RateControlIntegrationTests.cs +++ b/Tests/Unit/Integration/RateControlIntegrationTests.cs @@ -104,8 +104,11 @@ public class RateControlIntegrationTests QueueLimit = queueLimit, }); + var permissions = new AllowPropertySetPermissions(); + warehouse.RegisterManager(permissions); + var resource = await warehouse.Put("sys/rate", new RateLimitedResource()); - resource.Instance!.Managers.Add(new AllowPropertySetPermissions()); + resource.Instance!.Managers.Add(permissions); }); static async Task GetRemote(IntegrationCluster cluster) diff --git a/Tests/Unit/ResourceManagerPipelineTests.cs b/Tests/Unit/ResourceManagerPipelineTests.cs new file mode 100644 index 0000000..9bf63df --- /dev/null +++ b/Tests/Unit/ResourceManagerPipelineTests.cs @@ -0,0 +1,285 @@ +using Esiur.Data; +using Esiur.Data.Types; +using Esiur.Protocol; +using Esiur.Resource; +using Esiur.Security.Authority; +using Esiur.Security.Management; +using Esiur.Security.Permissions; +using Esiur.Stores; +using System.Text; + +namespace Esiur.Tests.Unit; + +public class ResourceManagerPipelineTests +{ + [Fact] + public async Task Put_WithUnregisteredTypeManager_FailsBeforeAssigningInstance() + { + var warehouse = await CreateWarehouseAsync(); + var resource = new AttributeManagedResource(); + + var exception = await Assert.ThrowsAsync(async () => + { + await warehouse.Put("sys/unregistered-manager", resource); + }); + + Assert.Contains(nameof(AttributePermissionsManager), exception.Message); + Assert.Null(resource.Instance); + } + + [Fact] + public async Task ResourceContext_RequiresTheExactRegisteredManagerInstance() + { + var warehouse = await CreateWarehouseAsync(); + var registered = new ContextPermissionsManager(); + var differentInstance = new ContextPermissionsManager(); + warehouse.RegisterManager(registered); + + var rejectedResource = new PlainResource(); + var rejectedContext = new ResourceContext( + new IResourceManager[] { differentInstance }); + + await Assert.ThrowsAsync(async () => + { + await warehouse.Put("sys/rejected-context-manager", rejectedResource, rejectedContext); + }); + + Assert.Null(rejectedResource.Instance); + + var acceptedContext = new ResourceContext( + new IResourceManager[] { registered }); + var acceptedResource = await warehouse.Put( + "sys/accepted-context-manager", + new PlainResource(), + acceptedContext); + + Assert.Contains( + acceptedResource.Instance!.Managers.ToArray(), + manager => ReferenceEquals(manager, registered)); + } + + [Fact] + public void EvaluateManagers_ExecutesAllPermissionsManagersAndDenialOverrides() + { + var warehouse = new Warehouse(); + var defaultAllow = new DefaultAllowPermissionsManager(); + var localDeny = new DenyPermissionsManager(); + var trailingObserver = new ObserverPermissionsManager(); + + warehouse.RegisterManager(defaultAllow, useAsDefault: true); + warehouse.RegisterManager(localDeny); + warehouse.RegisterManager(trailingObserver); + + Assert.Same(defaultAllow, warehouse.TryGetManager()); + Assert.Contains(typeof(DefaultAllowPermissionsManager), warehouse.GetRegisteredManagerTypes()); + Assert.Contains( + warehouse.GetDefaultManagers(), + manager => ReferenceEquals(manager, defaultAllow)); + + var context = new ResourceManagerContext( + warehouse, + null, + new Session(), + null, + new FunctionDef { Name = "Call" }, + ActionType.Execute); + + var evaluation = warehouse.EvaluateManagers( + context, + new IResourceManager[] { localDeny, trailingObserver }); + + Assert.Equal(1, defaultAllow.Calls); + Assert.Equal(1, localDeny.Calls); + Assert.Equal(1, trailingObserver.Calls); + Assert.Equal(Ruling.Denied, evaluation.Permissions); + Assert.False(evaluation.IsAllowed); + } + + [Fact] + public void RateControlAllow_DoesNotGrantDefaultDeniedPropertySet() + { + var warehouse = new Warehouse(); + var rateManager = new AllowRateControlManager(); + warehouse.RegisterManager(rateManager, useAsDefault: true); + + var context = new ResourceManagerContext( + warehouse, + null, + new Session(), + null, + new PropertyDef { Name = "Value" }, + ActionType.SetProperty); + + var evaluation = warehouse.EvaluateManagers(context); + + Assert.Equal(1, rateManager.Calls); + Assert.Equal(Ruling.Allowed, evaluation.RateControl); + Assert.Equal(Ruling.Denied, evaluation.Permissions); + Assert.False(evaluation.IsAllowed); + } + + [Fact] + public async Task MemberPolicyAttributes_AreLocalAndNotTransportedToRemoteTypeDefs() + { + var warehouse = await CreateWarehouseAsync(); + var local = new LocalTypeDef(typeof(MemberPolicyResource), warehouse); + + var localFunction = local.GetFunctionDefByName(nameof(MemberPolicyResource.Call)); + var localProperty = local.GetPropertyDefByName(nameof(MemberPolicyResource.Value)); + var localEvent = local.GetEventDefByName(nameof(MemberPolicyResource.Changed)); + + Assert.Equal("call-rate", localFunction!.RatePolicyName); + Assert.Equal("set-rate", localProperty!.RatePolicyName); + Assert.Equal( + "call-policy", + Assert.Single(localFunction.MemberPolicyAttributes.OfType()).Name); + Assert.Equal( + "property-policy", + Assert.Single(localProperty.MemberPolicyAttributes.OfType()).Name); + Assert.Equal( + "event-policy", + Assert.Single(localEvent!.MemberPolicyAttributes.OfType()).Name); + + var bytes = Codec.Compose(TypeDefInfo.FromTypeDef(local), warehouse, null!); + var wireText = Encoding.UTF8.GetString(bytes); + Assert.DoesNotContain("call-policy", wireText); + Assert.DoesNotContain("property-policy", wireText); + Assert.DoesNotContain("event-policy", wireText); + Assert.DoesNotContain("call-rate", wireText); + Assert.DoesNotContain("set-rate", wireText); + + var connection = await warehouse.Put("sys/type-parser", new EpConnection()); + connection.RemoteDomain = "remote.test"; + var remote = await RemoteTypeDef.Parse( + new RemoteTypeDef(), + connection.RemoteDomain, + bytes, + connection, + Array.Empty()); + + Assert.Empty(remote.GetFunctionDefByName(nameof(MemberPolicyResource.Call))! + .MemberPolicyAttributes); + Assert.Empty(remote.GetPropertyDefByName(nameof(MemberPolicyResource.Value))! + .MemberPolicyAttributes); + Assert.Empty(remote.GetEventDefByName(nameof(MemberPolicyResource.Changed))! + .MemberPolicyAttributes); + Assert.Null(remote.GetFunctionDefByName(nameof(MemberPolicyResource.Call))!.RatePolicyName); + Assert.Null(remote.GetPropertyDefByName(nameof(MemberPolicyResource.Value))!.RatePolicyName); + } + + static async Task CreateWarehouseAsync() + { + var warehouse = new Warehouse(); + await warehouse.Put("sys", new MemoryStore()); + return warehouse; + } + + abstract class TestPermissionsManager : IPermissionsManager + { + readonly Ruling _ruling; + + protected TestPermissionsManager(Ruling ruling) + { + _ruling = ruling; + } + + public int Calls { get; private set; } + public Map Settings { get; } = new(); + + public Ruling Applicable( + IResource resource, + Session session, + ActionType action, + MemberDef member, + object inquirer = null!) + { + Calls++; + return _ruling; + } + + public bool Initialize(Map settings, IResource resource) => true; + } + + sealed class AttributePermissionsManager : TestPermissionsManager + { + public AttributePermissionsManager() : base(Ruling.DontCare) + { + } + } + + sealed class ContextPermissionsManager : TestPermissionsManager + { + public ContextPermissionsManager() : base(Ruling.DontCare) + { + } + } + + sealed class DefaultAllowPermissionsManager : TestPermissionsManager + { + public DefaultAllowPermissionsManager() : base(Ruling.Allowed) + { + } + } + + sealed class DenyPermissionsManager : TestPermissionsManager + { + public DenyPermissionsManager() : base(Ruling.Denied) + { + } + } + + sealed class ObserverPermissionsManager : TestPermissionsManager + { + public ObserverPermissionsManager() : base(Ruling.DontCare) + { + } + } + + sealed class AllowRateControlManager : IRateControlManager + { + public int Calls { get; private set; } + + public Ruling Applicable(ResourceManagerContext context) + { + Calls++; + return Ruling.Allowed; + } + } + + [PermissionsManager] + sealed class AttributeManagedResource : Esiur.Resource.Resource + { + } + + sealed class PlainResource : Esiur.Resource.Resource + { + } + + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Event)] + sealed class LocalMemberPolicyAttribute : Attribute + { + public string Name { get; } + + public LocalMemberPolicyAttribute(string name) + { + Name = name; + } + } + + [Export] + sealed class MemberPolicyResource : Esiur.Resource.Resource + { + [RateControl("call-rate")] + [LocalMemberPolicy("call-policy")] + public void Call() + { + } + + [RateControl("set-rate")] + [LocalMemberPolicy("property-policy")] + public int Value { get; set; } + + [LocalMemberPolicy("event-policy")] + public event ResourceEventHandler? Changed; + } +}